diff --git a/CHANGELOG.md b/CHANGELOG.md index c3bc75d..46123f9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,9 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain - Agents and skills accept a tool-neutral `effort:` — `low`, `medium`, `high`, `xhigh`, `max` or `ultra`. Claude Code agents and skills receive it as `effort:` (`ultra` becomes `max`), Codex agents as `model_reasoning_effort`, and the shared `.agents/skills/` tree keeps it as written; Copilot, Cursor, OpenCode, Antigravity and Pi receive no effort key (decision 0015). - An `effort:` off that scale prints a `WARNING:` line naming the source file and the value, also under `sync --compact` and `init`, renders as absent and leaves sync successful; an empty `effort:` is absent without a warning. - `targets.copilot.commit_output: true` keeps generated Copilot output tracked for Copilot on github.com (decision 0020). +- `sources:` names a package's directory by the package as well as by its store path: `@scope/name/` for a package `packages:` declares, and `:` through the alias that package declares. Both render exactly as the store path. The CLI still writes store paths and rewrites no entry: wiring leaves a directory a section already names in any spelling where it stands, and `package remove` removes the package's entries in every spelling. An `@scope/name/…` entry for an undeclared package stays an ordinary path, which `status --check` now judges like any project directory. A CLI `0.18` or earlier reads a reference as a path that does not exist and skips it (decision 0019). +- `intelligence package alias <@scope/name> ` and `package add --alias ` set the alias a package declares, `package alias <@scope/name> --remove` drops it, and `package list` shows it. Both refuse a malformed alias or one another package declares, and refuse to remove or replace an alias `sources:` still uses, naming the entries. +- `sync` skips a reference that names nothing — an alias no package declares or two packages declare, or a `` that is empty or leaves its package — with a `WARNING:` line naming it, and `status --check` reports it, a malformed or shared alias, and a section that lists one package directory under two spellings. `source add` and `source remove` refuse a reference, naming `intelligence package`, and take any spelling of a package's directory as a `--before` / `--after` anchor. ### Changed @@ -21,6 +24,8 @@ Legacy Intelligence Sync history remains in its [archive](https://github.com/ain ### Fixed - `status --check` no longer reports `.gitignore` patterns as missing when the file has CRLF line endings on Linux and macOS; it decides presence the way the `.gitignore` writer does, so `intelligence init` clears the finding (#45). +- `update` keeps a package's `sources:` entries where they stand. It removed them and wired them first again, so updating one of two packages moved it ahead of the other and reversed which package's same-named files won. +- An entry in `sources:`, `ignore:` or `submodules:` followed by a `# comment` reads without the comment, and without whitespace outside its quotes. The comment was read as part of the path, so sync skipped that source without a word and `status --check` refused the `#`; a `#` inside quotes is still part of the value, and the `source` and `package` commands read entries the same way. ### Breaking diff --git a/cli/commands/package.sh b/cli/commands/package.sh index 740e90e..0bc1101 100644 --- a/cli/commands/package.sh +++ b/cli/commands/package.sh @@ -1,10 +1,10 @@ #!/bin/bash -# intelligence package ... +# intelligence package ... set -euo pipefail source "$CLI_DIR/lib/cli-common.sh" action="${1:-}" -[ -n "$action" ] || die "usage: intelligence package [args]" +[ -n "$action" ] || die "usage: intelligence package [args]" shift case "$action" in @@ -18,6 +18,11 @@ case "$action" in ensure_project_current "$IP_ROOT" exec bash "$CLI_DIR/internal/package-remove.sh" "$@" ;; + alias) + require_cli_project + ensure_project_current "$IP_ROOT" + exec bash "$CLI_DIR/internal/package-alias.sh" "$@" + ;; list) exec bash "$CLI_DIR/internal/package-list.sh" "$@" ;; @@ -25,6 +30,6 @@ case "$action" in exec bash "$CLI_DIR/internal/package-search.sh" "$@" ;; *) - die "usage: intelligence package [args]" + die "usage: intelligence package [args]" ;; esac diff --git a/cli/commands/source.sh b/cli/commands/source.sh index 14f9e0b..bce4eb6 100644 --- a/cli/commands/source.sh +++ b/cli/commands/source.sh @@ -10,8 +10,11 @@ # that ships it) belongs after the store entries and before the project's. # # Installed package content is NOT managed here: `.intelligence/` entries are -# written by `package add` and removed by `package remove`, and a hand-placed -# entry under the store does not survive the next lifecycle alignment. +# written by `package add` and removed by `package remove`, and so are the +# entries that name a package's directory by its full name or alias +# (`@scope/name/`, `:`, decision 0019). Any of those spellings +# is still a valid `--before` / `--after` anchor: that is how a directory lands +# next to a package. set -euo pipefail source "$CLI_DIR/lib/cli-common.sh" @@ -36,11 +39,32 @@ assert_source_section() { # repaired — a source entry decides which artifact wins, so a path the user did # not type is the wrong kind of help. +# assert_not_package_reference — refuse an entry +# the engine reads as a package reference: a directory of a package `packages:` +# declares, by full name or alias, or an alias no package declares. It is +# package territory however it is spelled. +assert_not_package_reference() { + local state rendered rest pkg + IFS=$'\037' read -r state rendered <<< "$(sources_classify_entry "$1" "$2")" + rest="${rendered#.intelligence/packages/}" + pkg="${rest%%/*}/" + rest="${rest#*/}" + pkg="$pkg${rest%%/*}" + case "$state/$3" in + path/*) return 0 ;; + ok/add) die "'$2' names a directory of the installed package $pkg — 'intelligence package add' wires a package's directories into sources" ;; + ok/remove) die "'$2' is installed package content — remove the package instead: intelligence package remove $pkg" ;; + */add) die "'$2' is a package reference, not a project directory — 'intelligence package add' wires a package's directories into sources" ;; + *) die "'$2' is a package reference, not a project directory — 'intelligence package remove <@scope/name>' removes a package's entries" ;; + esac +} + # assert_valid_source_dir — refuse what the engine cannot report. # The shape checks are `source_entry_problem`, shared with `status --check` so # one definition covers both the entry about to be written and the one already -# in the manifest; the store rule is this command's alone, because a -# `.intelligence/` entry is legitimate when `package add` wrote it. +# in the manifest; the package rules are this command's alone, because a +# package entry is legitimate when `package add` wrote it or a person spelled +# it by the package's name. assert_valid_source_dir() { local root="$1" dir="$2" problem [ -n "$dir" ] || die "$USAGE" @@ -50,26 +74,34 @@ assert_valid_source_dir() { die "'$dir' is inside the CLI-managed package store — install content with 'intelligence package add', and it is wired into sources automatically" ;; esac + assert_not_package_reference "$root/intelligence.yaml" "$dir" add problem="$(source_entry_problem "$root" "$dir")" [ -z "$problem" ] || die "invalid source '$dir': it $problem" } # Print one section with its override direction and the state of each entry. # Position is the whole point of the block, so every mutation ends by showing -# the order it produced rather than only naming what it wrote. +# the order it produced rather than only naming what it wrote. Entries are +# listed as written and judged by the directory the engine renders them as. print_section() { - local manifest="$1" root="$2" section="$3" entry n=0 note + local manifest="$1" root="$2" section="$3" entry state dir n=0 note echo "sources.$section (a later entry overrides an earlier one):" - while IFS= read -r entry; do + [ -f "$manifest" ] || { echo " (none)"; return 0; } + while IFS=$'\037' read -r entry state dir _; do [ -n "$entry" ] || continue n=$((n + 1)) note="" - case "$entry" in - .intelligence/*) note=" package" ;; + case "$state" in + path|ok) + case "$dir" in + .intelligence/*) note=" package" ;; + esac + [ -d "$root/$dir" ] || note="$note MISSING" + ;; + *) note=" UNRESOLVED — intelligence status --check" ;; esac - [ -d "$root/$entry" ] || note="$note MISSING" printf ' %d. %s%s\n' "$n" "$entry" "$note" - done < <(sources_list_entries "$manifest" "$section") + done < <(read_source_entries "$manifest" "$section") [ "$n" -eq 0 ] && echo " (none)" return 0 } @@ -110,10 +142,16 @@ case "$action" in ensure_project_current "$IP_ROOT" manifest="$IP_ROOT/intelligence.yaml" - if [ -n "$anchor" ] && ! sources_has_entry "$manifest" "$section" "$anchor"; then - echo "ERROR: sources.$section does not list '$anchor'." >&2 - print_section "$manifest" "$IP_ROOT" "$section" >&2 - exit 1 + # The anchor may be spelled differently from the entry it means — a + # package's store path, full name or alias — so it is matched by the + # directory it names and placed against the entry as written. + if [ -n "$anchor" ]; then + if ! listed="$(sources_find_entry "$manifest" "$section" "$anchor")"; then + echo "ERROR: sources.$section does not list '$anchor'." >&2 + print_section "$manifest" "$IP_ROOT" "$section" >&2 + exit 1 + fi + anchor="$listed" fi if sources_has_entry "$manifest" "$section" "$dir"; then @@ -149,8 +187,9 @@ case "$action" in esac require_cli_project - ensure_project_current "$IP_ROOT" manifest="$IP_ROOT/intelligence.yaml" + assert_not_package_reference "$manifest" "$dir" remove + ensure_project_current "$IP_ROOT" if ! sources_has_entry "$manifest" "$section" "$dir"; then echo "not listed: sources.$section does not hold $dir" diff --git a/cli/intelligence b/cli/intelligence index 0e502e4..4af4025 100644 --- a/cli/intelligence +++ b/cli/intelligence @@ -61,7 +61,7 @@ Usage: intelligence [args] --latest Move one named package past its range to the newest version upgrade [--next] [--preview|--apply] Replace the installed CLI with the newest npm version --next Follow the prerelease line instead of the stable one - package add | remove | list | search + package add | remove | alias | list | search source add | remove | list — the sources: block (own directories) adapter list | create | enable | disable | remove status [--check] Project state; --check runs deep consistency checks diff --git a/cli/internal/check.sh b/cli/internal/check.sh index 46413ee..6117e5f 100644 --- a/cli/internal/check.sh +++ b/cli/internal/check.sh @@ -119,6 +119,18 @@ while IFS= read -r name; do fi done < <(qmap_keys "$manifest" "packages") +# Aliases name packages in sources: (decision 0019). One the engine would not +# resolve, or one two packages state, leaves every : entry naming +# nothing. An invalid alias is never echoed: it is manifest input that may hold +# anything. +while IFS=$'\037' read -r name alias verdict; do + [ -n "$name" ] || continue + case "$verdict" in + invalid) warn "$name declares an alias that is not one — expected $IS_PKG_ALIAS_RULE: intelligence package alias $name " ;; + ambiguous) warn "$name declares alias '$alias', which another package declares too — an alias names one package" ;; + esac +done < <(read_package_aliases "$manifest") + # Adapter contracts are the shared source of truth for dependencies, rollback, # onboarding backup and Git policy. Validate every declared target, including # disabled ones, so a later enable cannot reveal a stale custom adapter. @@ -199,22 +211,50 @@ if [ "$lock_valid" -eq 1 ] && [ -f "$lock" ]; then fi # Sources must exist (a missing dir is silently skipped by the engine, which -# is exactly why doctor names it). +# is exactly why doctor names it). Each entry is judged by the directory the +# engine renders it as, read through the same parser: a package reference by +# its store path, one that resolves to nothing by why. for section in rules agents skills; do - while IFS= read -r src; do + seen=$'\n' + while IFS=$'\037' read -r src state dir alias holders; do [ -n "$src" ] || continue + case "$state" in + unknown|ambiguous|invalid) + source_reference_problem_var "$state" "$alias" "$holders" + case "$state" in + unknown) warn "sources.$section '$src' $IS_SOURCE_PROBLEM — declare it with 'intelligence package alias <@scope/name> $alias', or correct the entry" ;; + *) warn "sources.$section '$src' $IS_SOURCE_PROBLEM" ;; + esac + continue + ;; + esac + # One package directory under two spellings is listed twice, and the + # later one moves the package past every entry between them. + case "$dir" in + .intelligence/packages/*) + case "$seen" in + *$'\n'"$dir"$'\037'*) + first="${seen#*$'\n'"$dir"$'\037'}" + first="${first%%$'\n'*}" + [ "$first" = "$src" ] \ + || warn "sources.$section names $dir twice, as '$first' and as '$src' — keep one: where it stands decides which artifacts win" + ;; + *) seen="$seen$dir"$'\037'"$src"$'\n' ;; + esac + ;; + esac case "$src" in - git+*|@*) continue ;; + git+*) continue ;; esac # A hand-edited entry the engine cannot render is worse than a missing # one: it is skipped without a word and the sync still reports ok. - problem="$(source_entry_problem "$IP_ROOT" "$src")" + problem="$(source_entry_problem "$IP_ROOT" "$dir")" if [ -n "$problem" ]; then warn "sources.$section '$src' $problem" continue fi - if [ ! -d "$IP_ROOT/$src" ]; then - case "$src" in + if [ ! -d "$IP_ROOT/$dir" ]; then + case "$dir" in # Store content is restorable state: absent means un-installed. .intelligence/*) if [ "$lock_valid" -eq 1 ]; then @@ -230,7 +270,7 @@ for section in rules agents skills; do *) ok "sources.$section '$src' — not created yet (optional)" ;; esac fi - done < <(read_yaml_list "$manifest" "$section") + done < <(read_source_entries "$manifest" "$section") done if [ "$problems" -eq 0 ]; then diff --git a/cli/internal/package-add.sh b/cli/internal/package-add.sh index 644d5d7..fc675af 100644 --- a/cli/internal/package-add.sh +++ b/cli/internal/package-add.sh @@ -9,15 +9,18 @@ set -euo pipefail source "$CLI_DIR/lib/cli-common.sh" spec="${1:-}" -[ -n "$spec" ] || die "usage: intelligence package add <@scope/name[@range] | github:org/repo[#path] | git+[@ref][#path]>" +[ -n "$spec" ] || die "usage: intelligence package add <@scope/name[@range] | github:org/repo[#path] | git+[@ref][#path]> [--name @scope/name] [--alias ] [--no-sync]" shift || true no_sync=0 name_override="" +alias_given=0 +alias_req="" while [ $# -gt 0 ]; do case "$1" in --no-sync) no_sync=1 ;; --name) shift; name_override="${1:-}" ;; + --alias) shift; alias_given=1; alias_req="${1:-}" ;; *) die "unknown option '$1'" ;; esac shift || true @@ -77,6 +80,20 @@ esac [ -n "$name_override" ] && name="$name_override" assert_valid_pkg_name "$name" +# The alias is settled before anything is fetched or written: a refusal leaves +# the manifest, the lock and the store as they were. Re-adding a package keeps +# the alias it declares unless --alias names another one, and never takes away +# an alias `sources:` still spells entries with. +alias="$(package_alias_of "$manifest" "$name")" +if [ "$alias_given" -eq 1 ]; then + assert_valid_alias "$alias_req" + assert_alias_free "$manifest" "$name" "$alias_req" + if [ -n "$alias" ] && [ "$alias" != "$alias_req" ]; then + assert_alias_unused "$manifest" "$name" "$alias" replace + fi + alias="$alias_req" +fi + if [ "$mode" = "registry" ]; then resolve_package_source "$manifest" "$name" url="$RES_URL"; path="$RES_PATH" @@ -129,8 +146,6 @@ rel=".intelligence/packages/$name" store_record_remove "$IP_ROOT" "$name" sha="$(fetch_package "$url" "${ref:-$resolved_tag}" "$path" "$IP_ROOT/$rel")" -wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT" - # The manifest records requested intent only. The resolved source URL/path, # tag/ref and SHA live in the required lock. Re-adding a package is the # explicit operation for changing its trusted source. @@ -142,6 +157,12 @@ elif [ -n "$requested" ]; then else die "internal: package '$name' has neither a requested version nor ref" fi +[ -z "$alias" ] || qmap_set "$manifest" "packages" "$name" "alias" "$alias" + +# Wired once the package and its alias are declared, so a section that already +# names one of its directories as @scope/name/ or : is left as +# it stands instead of gaining the store path beside it. +wire_package_sources "$manifest" "$name" "$rel" "$IP_ROOT" lock_upsert "$lock" "$name" "$requested" "$url" "$path" "${ref:-$resolved_tag}" "$sha" store_record_set "$IP_ROOT" "$name" "$url" "$path" "${ref:-$resolved_tag}" "$sha" @@ -150,7 +171,7 @@ sections="" for s in rules agents skills; do [ -d "$IP_ROOT/$rel/$s" ] && sections="$sections $s" done -echo "+ $name@${resolved_tag:-${ref:-HEAD}} (${sections# })" +echo "+ $name@${resolved_tag:-${ref:-HEAD}} (${sections# })${alias:+ alias $alias}" if [ "$no_sync" -eq 0 ]; then exec bash "$CLI_DIR/commands/sync.sh" diff --git a/cli/internal/package-alias.sh b/cli/internal/package-alias.sh new file mode 100644 index 0000000..8b093ec --- /dev/null +++ b/cli/internal/package-alias.sh @@ -0,0 +1,51 @@ +#!/bin/bash +# Internal package alias operation: set, replace or remove the alias a package +# declares in `packages:`. `sources:` spells that package's directories as +# : (decision 0019); the alias is the CLI's to write, like the rest +# of the block, and one alias names exactly one package. +set -euo pipefail +source "$CLI_DIR/lib/cli-common.sh" + +USAGE="usage: intelligence package alias <@scope/name> + intelligence package alias <@scope/name> --remove" + +[ $# -eq 2 ] || die "$USAGE" +name="$1" +want="$2" + +require_cli_project +project_lock_hold "$IP_ROOT" +manifest="$IP_ROOT/intelligence.yaml" +assert_valid_pkg_name "$name" + +known=0 +while IFS= read -r k; do + [ "$k" = "$name" ] && known=1 +done < <(qmap_keys "$manifest" "packages") +[ "$known" -eq 1 ] || die "package '$name' is not in the manifest — 'intelligence package list' shows the declared ones" + +current="$(package_alias_of "$manifest" "$name")" + +if [ "$want" = "--remove" ]; then + # An entry can only spell a well-formed alias, so only a well-formed one can + # still be in use; a malformed one is dropped like any other. + [ -z "$current" ] || assert_alias_unused "$manifest" "$name" "$current" remove + if [ -z "$(qmap_field "$manifest" "packages" "$name" "alias")" ]; then + echo "$name declares no alias" + exit 0 + fi + qmap_delete_field "$manifest" "packages" "$name" "alias" + echo "$name: alias removed" + exit 0 +fi + +case "$want" in -*) die "$USAGE" ;; esac +assert_valid_alias "$want" +if [ "$current" = "$want" ]; then + echo "$name: alias $want (unchanged)" + exit 0 +fi +assert_alias_free "$manifest" "$name" "$want" +[ -z "$current" ] || assert_alias_unused "$manifest" "$name" "$current" replace +qmap_set "$manifest" "packages" "$name" "alias" "$want" +echo "$name: alias $want — sources: may name its directories $want:" diff --git a/cli/internal/package-list.sh b/cli/internal/package-list.sh index 2c1029a..4e8354a 100644 --- a/cli/internal/package-list.sh +++ b/cli/internal/package-list.sh @@ -9,6 +9,10 @@ lock="$IP_ROOT/intelligence.lock" lock_valid=1 if ! check_project_lock "$IP_ROOT"; then lock_valid=0; fi +# Aliases come from the parser the engine resolves them with, in one pass; an +# alias it would not resolve is shown as such, never echoed. +aliases="$(read_package_aliases "$manifest")" + count=0 while IFS= read -r name; do [ -n "$name" ] || continue @@ -29,11 +33,21 @@ while IFS= read -r name; do else state="store missing — run 'intelligence sync'" fi - printf '%s %s locked:%s %s\n' \ + alias_note="" + while IFS=$'\037' read -r a_name a_alias a_verdict; do + [ "$a_name" = "$name" ] || continue + case "$a_verdict" in + ok) alias_note=" alias:$a_alias" ;; + ambiguous) alias_note=" alias:$a_alias (another package declares it too)" ;; + *) alias_note=" alias:invalid — intelligence status --check" ;; + esac + done <<< "$aliases" + printf '%s %s locked:%s %s%s\n' \ "$name" \ "${ref:+ref:$ref}${ref:-${range:-*}}" \ "$(pin_label "$ref" "$resolved" "$sha")" \ - "$state" + "$state" \ + "$alias_note" done < <(qmap_keys "$manifest" "packages") if [ "$count" -eq 0 ]; then diff --git a/cli/internal/package-remove.sh b/cli/internal/package-remove.sh index 93fad36..70b8fa7 100644 --- a/cli/internal/package-remove.sh +++ b/cli/internal/package-remove.sh @@ -39,7 +39,9 @@ while IFS= read -r k; do done < <(qmap_keys "$manifest" "packages") [ "$known" -eq 1 ] || die "package '$name' is not in the manifest" -unwire_package_sources "$manifest" "$rel" +# Every entry naming the package, in every spelling, goes while `packages:` +# still declares it and its alias: that is what resolves the references. +unwire_package_sources "$manifest" "$name" qmap_delete_key "$manifest" "packages" "$name" lock_remove "$lock" "$name" rm -rf "${IP_ROOT:?}/$rel" diff --git a/cli/internal/package-update.sh b/cli/internal/package-update.sh index e69a371..b6b80a8 100644 --- a/cli/internal/package-update.sh +++ b/cli/internal/package-update.sh @@ -167,13 +167,21 @@ while IFS= read -r name; do fi rel=".intelligence/packages/$name" # Fetch into staging FIRST: a failed clone must leave the current install - # fully wired and intact. Only after success is the old shape unwired - # (the new version may have dropped a section dir), the store swapped, - # and the new shape wired. + # fully wired and intact. Only after success are the sections the new + # version dropped unwired, in every spelling, the store swapped, and the + # sections it gained wired. A section it keeps stays where it stands in + # the list: position is the override order between packages, and an + # update changes content, not which package wins. staging="$IP_ROOT/.intelligence/.staging-$$" rm -rf "$staging" sha="$(fetch_package "$url" "$tag" "$path" "$staging")" - unwire_package_sources "$manifest" "$rel" + dropped=() + for section in rules agents skills; do + [ -d "$staging/$section" ] || dropped+=("$section") + done + if [ "${#dropped[@]}" -gt 0 ]; then + unwire_package_sources "$manifest" "$name" "${dropped[@]}" + fi store_record_remove "$IP_ROOT" "$name" rm -rf "${IP_ROOT:?}/$rel" mkdir -p "$(dirname "$IP_ROOT/$rel")" diff --git a/cli/lib/cli-common.sh b/cli/lib/cli-common.sh index 25a5e43..d77dd1d 100644 --- a/cli/lib/cli-common.sh +++ b/cli/lib/cli-common.sh @@ -5,7 +5,8 @@ # All YAML reading goes through the engine's lib/common.sh — the CLI never # grows a parallel parser for shapes the engine can already read. The only # CLI-owned parsing lives in lib/manifest.sh (quoted-key `packages:` / -# `registries:` blocks, which the engine deliberately never reads). +# `registries:` blocks, which the engine never reads beyond the package names +# and aliases its list parser resolves `sources:` references against). # Engine libraries (readers, is_status, IS_RC_*, engine_version). CLI_DIR / # IS_ENGINE_DIR come exported from the dispatcher. @@ -545,6 +546,8 @@ project_store_missing() { assert_valid_pkg_name "$name" [ -d "$root/.intelligence/packages/$name" ] || return 0 done < <(qmap_keys "$manifest" "packages") + # The parser hands a package reference over as the store directory it + # names, so every spelling is checked here as that directory. for section in rules agents skills; do while IFS= read -r src; do case "$src" in diff --git a/cli/lib/manifest.sh b/cli/lib/manifest.sh index 6ee2e90..da0fdda 100644 --- a/cli/lib/manifest.sh +++ b/cli/lib/manifest.sh @@ -2,9 +2,10 @@ # The CLI-owned YAML shapes: quoted-key maps (`packages:`, `registries:` in # the manifest, `packages:` in the lock and in registry indexes). # -# The engine deliberately never reads these blocks, and its readers cannot -# hold `@scope/name` keys — so this is the CLI's single, deliberate parser, -# scoped to exactly one shape: +# The engine reads none of these blocks beyond the names and aliases in +# `packages:`, which resolve package references in `sources:` +# (engine/lib/common.sh), and its list readers cannot hold `@scope/name` keys — +# so this is the CLI's single, deliberate parser, scoped to exactly one shape: # # block: # "@scope/name": # 2-space indent, key always quoted @@ -407,12 +408,14 @@ registries_remove() { # meaning: a later entry overrides a same-named artifact from an earlier one. # Reading goes through the engine's own list parser, so the CLI sees exactly # what the engine will render instead of a second reading of the same file. +# The editors read it raw — a package reference as written, which is what they +# compare and rewrite — and read_source_entries says what each one renders. -# sources_list_entries
— entries of sources.
, one per -# line, in manifest order. +# sources_list_entries
— entries of sources.
as +# written, one per line, in manifest order. sources_list_entries() { [ -f "$1" ] || return 0 - read_yaml_list "$1" "$2" + read_yaml_list_raw "$1" "$2" } # sources_has_entry
— true (0) when that section lists @@ -439,7 +442,7 @@ sources_has_entry() { sources_add_entry() { local file="$1" section="$2" entry="$3" pos="${4:-last}" anchor="${5:-}" sources_has_entry "$file" "$section" "$entry" && return 0 - _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" ' + _qmap_stage "$file" -v section="$section" -v entry="$entry" -v pos="$pos" -v anchor="$anchor" "$IS_YAML_ITEM_AWK"' function line() { return " - \"" entry "\"" } function place() { print line(); done = 1 } # Blank lines inside sources: are held back so an insert lands next to @@ -447,13 +450,11 @@ sources_add_entry() { # one section from the next. function flush_tail( i) { for (i = 1; i <= ntail; i++) print tail[i]; blanks = ntail; ntail = 0 } function open_section() { flush_tail(); print " " section ":"; place(); if (blanks) print "" } + # The entry as the engine reads it (yaml_item, engine/lib/common.sh). function value(s, v) { v = s - sub(/^[ \t]*-[ \t]*/, "", v) - gsub(/["\x27]/, "", v) - sub(/[ \t]+#.*$/, "", v) - sub(/[ \t]+$/, "", v) - return v + sub(/^[ \t]*-/, "", v) + return yaml_item(v) } BEGIN { anchored = (pos == "before" || pos == "after") } { sub(/\r$/, "") } @@ -509,24 +510,171 @@ sources_add_entry_first() { } # sources_remove_entry
— remove `- "entry"` from -# sources.
. +# sources.
, matching each item as the engine reads it (yaml_item). sources_remove_entry() { local file="$1" section="$2" entry="$3" [ -f "$file" ] || return 0 - _qmap_stage "$file" -v section="$section" -v entry="$entry" ' + _qmap_stage "$file" -v section="$section" -v entry="$entry" "$IS_YAML_ITEM_AWK"' { sub(/\r$/, "") } /^sources:[ \t]*$/ { ins = 1; print; next } ins && /^[^ #]/ { ins = 0; insec = 0 } ins && $0 ~ "^ " section ":[ \t]*$" { insec = 1; print; next } ins && /^ [A-Za-z_]/ { insec = 0 } - insec { + insec && /^[ \t]*-/ { line = $0 - sub(/^[ \t]*-[ \t]*/, "", line) - gsub(/["\x27]/, "", line) - sub(/[ \t]+#.*$/, "", line) - sub(/[ \t]+$/, "", line) - if (line == entry) next + sub(/^[ \t]*-/, "", line) + if (yaml_item(line) == entry) next } { print } ' } + +# --- package references and aliases ------------------------------------------ +# `sources:` may name a package's directory by its store path, by the package's +# full name or by the alias its `packages:` entry declares (decision 0019). The +# engine's list parser (IS_PKG_REF_AWK in engine/lib/common.sh) is the only +# definition of those spellings; everything below reads through it, so the CLI +# never judges an entry differently from the way the engine renders it. The CLI +# itself only ever writes store paths, and writes an alias only through +# `package add --alias` and `package alias`. + +# assert_valid_alias — refuse an alias the engine would never resolve. +assert_valid_alias() { + pkg_alias_valid "$1" \ + || die "invalid alias '$1' — expected $IS_PKG_ALIAS_RULE" +} + +# package_alias_of <@scope/name> — the alias that package declares, when it +# is well formed; nothing otherwise (an invalid one resolves nothing). +package_alias_of() { + local name alias verdict + while IFS=$'\037' read -r name alias verdict; do + [ "$name" = "$2" ] || continue + [ "$verdict" = invalid ] || printf '%s' "$alias" + return 0 + done < <(read_package_aliases "$1") +} + +# assert_alias_free <@scope/name> — refuse an alias another package +# already declares: one alias names exactly one package. +assert_alias_free() { + local file="$1" self="$2" want="$3" name alias verdict + while IFS=$'\037' read -r name alias verdict; do + [ "$name" != "$self" ] && [ "$alias" = "$want" ] || continue + die "alias '$want' already names $name — one alias names one package; choose another" + done < <(read_package_aliases "$file") +} + +# sources_alias_users — every sources entry spelled :, +# one `sources.
''` line each. +sources_alias_users() { + local file="$1" alias="$2" section entry + for section in rules agents skills; do + while IFS= read -r entry; do + case "$entry" in + "$alias":*) printf "sources.%s '%s'\n" "$section" "$entry" ;; + esac + done < <(sources_list_entries "$file" "$section") + done +} + +# assert_alias_unused <@scope/name> — refuse to +# take away an alias `sources:` still spells entries with: they would stop +# resolving, and the package's content would leave the outputs in silence. +assert_alias_unused() { + local file="$1" name="$2" alias="$3" verb="$4" users + users="$(sources_alias_users "$file" "$alias")" + [ -n "$users" ] || return 0 + echo "ERROR: cannot $verb alias '$alias' of $name — sources: still uses it:" >&2 + printf '%s\n' "$users" | sed 's/^/ /' >&2 + echo " Spell those entries $name/ instead, then $verb the alias." >&2 + exit 1 +} + +# sources_classify_entry — `\037` for an entry +# that is not in the manifest (yet): what the engine would render it as against +# the packages the manifest declares. States are pkr_parse's. +sources_classify_entry() { + local file="$1" + [ -f "$file" ] || file=/dev/null + IS_PKR_CANDIDATE="$2" awk "$IS_PKG_REF_AWK"' + { sub(/\r$/, ""); pkr_collect($0) } + END { dir = pkr_expand(ENVIRON["IS_PKR_CANDIDATE"]); print PKR_STATE "\037" dir } + ' "$file" +} + +# sources_find_entry
— print the entry of +# sources.
, as written, that names the same directory as in any +# spelling; fail when the section names it nowhere. An entry that renders +# nothing matches only itself. +sources_find_entry() { + local file="$1" section="$2" entry="$3" want _state raw _st dir _rest + [ -f "$file" ] || return 1 + IFS=$'\037' read -r _state want <<< "$(sources_classify_entry "$file" "$entry")" + while IFS=$'\037' read -r raw _st dir _rest; do + if [ "$raw" = "$entry" ] || { [ -n "$want" ] && [ "$dir" = "$want" ]; }; then + printf '%s' "$raw" + return 0 + fi + done < <(read_source_entries "$file" "$section") + return 1 +} + +# package_source_listed
— true when sources.
+# already names , in whichever spelling. +package_source_listed() { + local _raw _st dir _rest + [ -f "$1" ] || return 1 + while IFS=$'\037' read -r _raw _st dir _rest; do + [ "$dir" = "$3" ] && return 0 + done < <(read_source_entries "$1" "$2") + return 1 +} + +# sources_remove_package <@scope/name> [section...] — drop the package's +# entries from sources:, in every spelling. With sections, only the entry of +# sources.
that names the package's own
directory goes; +# without, every rules/agents/skills entry naming a directory inside the +# package. The references resolve through `packages:`, so call it while the +# package and its alias are still declared. Every other line survives byte for +# byte, and an unchanged file is not rewritten. +sources_remove_package() { + local file="$1" name="$2" tmp rc=0 + shift 2 + [ -f "$file" ] || return 0 + tmp="$file.cli.tmp" + awk -v name="$name" -v only=" $* " "$IS_PKG_REF_AWK$IS_YAML_ITEM_AWK"' + BEGIN { store = ".intelligence/packages/" name } + FNR == 1 { pass++ } + pass == 1 { sub(/\r$/, ""); pkr_collect($0); next } + { sub(/\r$/, "") } + /^sources:[ \t]*$/ { ins = 1; print; next } + ins && /^[^ #]/ { ins = 0; sec = "" } + ins && /^ [A-Za-z_]/ { + sec = $0 + sub(/^ /, "", sec) + sub(/:.*$/, "", sec) + if (sec != "rules" && sec != "agents" && sec != "skills") sec = "" + print + next + } + ins && sec != "" && /^[ \t]*-/ { + v = $0 + sub(/^[ \t]*-/, "", v) + dir = pkr_expand(yaml_item(v)) + if (only == " ") drop = dir == store || index(dir, store "/") == 1 + else drop = index(only, " " sec " ") && dir == store "/" sec + if (drop) { changed = 1; next } + } + { print } + END { exit (changed ? 0 : 10) } + ' "$file" "$file" > "$tmp" || rc=$? + case "$rc" in + 0) + [ -s "$tmp" ] || { rm -f "$tmp"; die "internal: sources edit produced an empty file for $file"; } + mv "$tmp" "$file" + ;; + 10) rm -f "$tmp" ;; + *) rm -f "$tmp"; die "internal: sources edit refused (awk exit $rc) for $file" ;; + esac +} diff --git a/cli/lib/registry.sh b/cli/lib/registry.sh index 3ffa6bc..e068b9f 100644 --- a/cli/lib/registry.sh +++ b/cli/lib/registry.sh @@ -200,25 +200,30 @@ fetch_package() { printf '%s' "$sha" } -# wire_package_sources <@scope/name> +# wire_package_sources <@scope/name> # The provides convention: whichever of rules/agents/skills the installed -# package has becomes a sources entry — inserted FIRST in its section, so -# project-owned entries come later and win on same-named artifacts. +# package has becomes a sources entry — its store path, inserted FIRST in its +# section, so project-owned entries come later and win on same-named artifacts. +# A section that already names that directory, in any spelling, keeps the entry +# where it stands: the person who wrote `@scope/name/` or `:` +# chose both the spelling and the position. Call it once `packages:` declares +# the package and its alias, which is what resolves those spellings. wire_package_sources() { - local manifest="$1" name="$2" rel="$3" root="$4" + local manifest="$1" rel="$3" root="$4" local section for section in rules agents skills; do - if [ -d "$root/$rel/$section" ]; then - sources_add_entry_first "$manifest" "$section" "$rel/$section" - fi + [ -d "$root/$rel/$section" ] || continue + package_source_listed "$manifest" "$section" "$rel/$section" && continue + sources_add_entry_first "$manifest" "$section" "$rel/$section" done } -# unwire_package_sources +# unwire_package_sources <@scope/name> [section...] +# Drop the package's sources entries in every spelling (sources_remove_package): +# every one without sections, as `package remove` does; only the named +# sections' own directories with them, as `update` does for the directories a +# new version no longer has. Call it while `packages:` still declares the +# package and its alias. unwire_package_sources() { - local manifest="$1" rel="$2" - local section - for section in rules agents skills; do - sources_remove_entry "$manifest" "$section" "$rel/$section" - done + sources_remove_package "$@" } diff --git a/cli/tests/e2e-packages.sh b/cli/tests/e2e-packages.sh index b8e4be0..935d424 100644 --- a/cli/tests/e2e-packages.sh +++ b/cli/tests/e2e-packages.sh @@ -11,6 +11,19 @@ CLI="$REPO/cli/intelligence" fail=0 chk() { if ! "$@" >/dev/null 2>&1; then echo "FAIL: $*"; fail=1; fi; } chknot() { if "$@" >/dev/null 2>&1; then echo "FAIL(not): $*"; fail=1; fi; } +# is