@ainova-systems/intelligence 0.11.0-rc.6 → 0.11.0-rc.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +15 -16
  2. package/cli/commands/adapter.sh +153 -0
  3. package/cli/commands/init.sh +113 -22
  4. package/cli/commands/package.sh +30 -0
  5. package/cli/commands/registry.sh +5 -2
  6. package/cli/commands/status.sh +12 -4
  7. package/cli/commands/sync.sh +8 -21
  8. package/cli/commands/update.sh +76 -61
  9. package/cli/engine-package.yaml +4 -4
  10. package/cli/intelligence +37 -27
  11. package/cli/{commands/doctor.sh → internal/check.sh} +32 -14
  12. package/cli/{commands/migrate.sh → internal/migrate-v1.sh} +161 -42
  13. package/cli/{commands/add.sh → internal/package-add.sh} +11 -10
  14. package/cli/{commands/list.sh → internal/package-list.sh} +9 -4
  15. package/cli/{commands/remove.sh → internal/package-remove.sh} +3 -3
  16. package/cli/{commands/search.sh → internal/package-search.sh} +4 -4
  17. package/cli/internal/package-update.sh +97 -0
  18. package/cli/{commands/install.sh → internal/restore.sh} +11 -4
  19. package/cli/internal/target-state.sh +57 -0
  20. package/cli/{commands/upgrade.sh → internal/upgrade-v2.sh} +48 -8
  21. package/cli/lib/cli-common.sh +167 -14
  22. package/cli/lib/lockfile.sh +15 -9
  23. package/cli/lib/manifest.sh +106 -1
  24. package/cli/lib/registry.sh +29 -15
  25. package/cli/lib/semver.sh +4 -2
  26. package/engine/ENGINE_SHA +1 -0
  27. package/engine/{scripts/adapters → adapters}/_template.sh +10 -9
  28. package/engine/{scripts/adapters → adapters}/agents.sh +20 -25
  29. package/engine/{scripts/adapters → adapters}/opencode.sh +1 -1
  30. package/engine/{scripts/lib → lib}/common.sh +29 -541
  31. package/engine/lib/contract.sh +120 -0
  32. package/engine/sync.sh +238 -0
  33. package/package.json +6 -5
  34. package/{engine → packages/sync}/agents/intelligence-architect.md +5 -3
  35. package/{engine → packages/sync}/agents/intelligence-operator.md +10 -13
  36. package/packages/sync/references/adapters.md +252 -0
  37. package/packages/sync/references/conventions.md +385 -0
  38. package/{engine → packages/sync}/rules/intelligence-authoring.md +6 -6
  39. package/{engine → packages/sync}/skills/intelligence-add-agent/SKILL.md +5 -5
  40. package/{engine → packages/sync}/skills/intelligence-add-rule/SKILL.md +3 -3
  41. package/{engine → packages/sync}/skills/intelligence-add-skill/SKILL.md +3 -3
  42. package/{engine → packages/sync}/skills/intelligence-extract-skill/SKILL.md +2 -2
  43. package/packages/sync/skills/intelligence-install-adapter/SKILL.md +38 -0
  44. package/{engine → packages/sync}/skills/intelligence-learn-from-context/SKILL.md +3 -3
  45. package/packages/sync/skills/intelligence-learn-from-repository/SKILL.md +55 -0
  46. package/{engine → packages/sync}/skills/intelligence-review-skills/SKILL.md +6 -6
  47. package/packages/sync/skills/intelligence-sync/SKILL.md +20 -0
  48. package/packages/sync/skills/intelligence-uninstall-adapter/SKILL.md +24 -0
  49. package/packages/sync/skills/intelligence-update/SKILL.md +43 -0
  50. package/engine/INIT.md +0 -500
  51. package/engine/docs/ADAPTERS.md +0 -214
  52. package/engine/docs/CLI.md +0 -90
  53. package/engine/docs/CONVENTIONS.md +0 -456
  54. package/engine/scripts/ENGINE_SHA +0 -1
  55. package/engine/scripts/lib/layout.sh +0 -51
  56. package/engine/scripts/lib/migrations.sh +0 -708
  57. package/engine/scripts/sync.sh +0 -311
  58. package/engine/scripts/update.sh +0 -237
  59. package/engine/skills/intelligence-install-adapter/SKILL.md +0 -31
  60. package/engine/skills/intelligence-sync/SKILL.md +0 -16
  61. package/engine/skills/intelligence-uninstall-adapter/SKILL.md +0 -42
  62. package/engine/skills/intelligence-update/SKILL.md +0 -165
  63. /package/engine/{scripts/VERSION → VERSION} +0 -0
  64. /package/engine/{scripts/adapters → adapters}/claude.sh +0 -0
  65. /package/engine/{scripts/adapters → adapters}/codex.sh +0 -0
  66. /package/engine/{scripts/adapters → adapters}/copilot.sh +0 -0
  67. /package/engine/{scripts/adapters → adapters}/cursor.sh +0 -0
  68. /package/engine/{scripts/adapters → adapters}/pi.sh +0 -0
@@ -0,0 +1,57 @@
1
+ #!/bin/bash
2
+ # Internal adapter target-state operation.
3
+ #
4
+ # Change only the manifest. Syncing and deleting generated output are separate
5
+ # explicit actions: a generic target command cannot infer a custom adapter's
6
+ # ownership safely.
7
+ set -euo pipefail
8
+ source "$CLI_DIR/lib/cli-common.sh"
9
+
10
+ action="${1:-}"
11
+ name="${2:-}"
12
+ [ $# -le 2 ] || die "internal target state: expected <enable|disable> <name>"
13
+ case "$action" in enable|disable) ;; *) die "internal target state: expected <enable|disable> <name>" ;; esac
14
+ [ -n "$name" ] || die "internal target state: missing adapter name"
15
+ assert_valid_target_name "$name"
16
+
17
+ require_v2
18
+ manifest="$IP_ROOT/intelligence.yaml"
19
+ content_dir="$(manifest_intelligence_dir "$manifest")"
20
+
21
+ if [ "$action" = "enable" ]; then
22
+ project_adapter="$IP_ROOT/$content_dir/adapters/$name.sh"
23
+ bundled_adapter="$IS_ENGINE_DIR/adapters/$name.sh"
24
+ if [ ! -f "$project_adapter" ] && [ ! -f "$bundled_adapter" ]; then
25
+ die "adapter '$name' not found — create it first: intelligence adapter create $name"
26
+ fi
27
+
28
+ # These adapters intentionally omit always-on rules because their tools
29
+ # read AGENTS.md. Do not create a manifest that full sync must refuse.
30
+ case "$name" in
31
+ cursor|copilot|codex|pi|opencode)
32
+ if [ "$(is_target_enabled "$manifest" "agents")" != "1" ]; then
33
+ die "target '$name' requires target 'agents' — enable it first"
34
+ fi
35
+ ;;
36
+ esac
37
+
38
+ output="$(get_target_output "$manifest" "$name")"
39
+ [ -n "$output" ] || output="$(default_target_output "$name")"
40
+ target_set_enabled "$manifest" "$name" true "$output"
41
+ echo "enabled: $name (output: $output)"
42
+ exit 0
43
+ fi
44
+
45
+ target_exists "$manifest" "$name" || die "target '$name' is not in the manifest"
46
+ if [ "$name" = "agents" ]; then
47
+ for dependent in cursor copilot codex pi opencode; do
48
+ if [ "$(is_target_enabled "$manifest" "$dependent")" = "1" ]; then
49
+ die "target 'agents' is required by enabled target '$dependent' — disable '$dependent' first"
50
+ fi
51
+ done
52
+ fi
53
+ output="$(get_target_output "$manifest" "$name")"
54
+ [ -n "$output" ] || output="$(default_target_output "$name")"
55
+ target_set_enabled "$manifest" "$name" false "$output"
56
+ echo "disabled: $name"
57
+ echo " generated output was kept; remove only paths owned by this adapter"
@@ -1,17 +1,34 @@
1
1
  #!/bin/bash
2
- # intelligence upgrade — bring the project to this CLI's engine: apply v2
2
+ # Internal: bring the project to this CLI's engine: apply v2
3
3
  # schema migrations, align the engine-content package to the bundled version,
4
- # restamp sync_version, sync.
4
+ # restamp schema_version, sync.
5
5
  set -euo pipefail
6
6
  source "$CLI_DIR/lib/cli-common.sh"
7
7
 
8
+ no_sync=0
9
+ while [ $# -gt 0 ]; do
10
+ case "$1" in
11
+ --no-sync) no_sync=1 ;;
12
+ *) die "internal upgrade: unknown option '$1'" ;;
13
+ esac
14
+ shift
15
+ done
16
+
8
17
  require_v2
9
18
  manifest="$IP_ROOT/intelligence.yaml"
10
19
 
11
- stamp="$(read_engine_stamp "$manifest")"
20
+ stamp="$(read_schema_version "$manifest")"
21
+ legacy_stamp="$(top_scalar "$manifest" "sync_version")"
22
+ schema_key_count="$(awk '/^schema_version:[[:space:]]*/ { n++ } END { print n + 0 }' "$manifest")"
23
+ legacy_key_count="$(awk '/^sync_version:[[:space:]]*/ { n++ } END { print n + 0 }' "$manifest")"
12
24
  eng="$(bundled_engine_version)"
25
+ [ "$schema_key_count" -le 1 ] || die "manifest has duplicate schema_version keys"
26
+ [ "$legacy_key_count" -le 1 ] || die "manifest has duplicate sync_version keys"
13
27
  if [ -n "$stamp" ] && _ver_gt "$stamp" "$eng"; then
14
- die "manifest schema $stamp is newer than this CLI's engine $eng — update the CLI first: npm i -g @ainova-systems/intelligence@latest"
28
+ die "manifest schema $stamp is newer than this CLI's engine $eng — update the global CLI first"
29
+ fi
30
+ if [ -n "$legacy_stamp" ] && _ver_gt "$legacy_stamp" "$eng"; then
31
+ die "manifest schema $legacy_stamp is newer than this CLI's engine $eng — update the global CLI first"
15
32
  fi
16
33
 
17
34
  # --- v2 schema migrations -------------------------------------------------
@@ -19,6 +36,19 @@ fi
19
36
  # Same discipline: ascending, append-only, each migration self-detects and
20
37
  # no-ops when already applied.
21
38
 
39
+ # v2 migration 0: the original RC manifest called the permanent schema key
40
+ # `sync_version`. Rename it without changing its value; the final stamp below
41
+ # then aligns it with this engine. The archived v1 reader remains separate.
42
+ if [ -n "$legacy_stamp" ]; then
43
+ if [ -n "$stamp" ]; then
44
+ [ "$stamp" = "$legacy_stamp" ] || die "manifest has conflicting schema_version '$stamp' and sync_version '$legacy_stamp'"
45
+ _qmap_stage "$manifest" '$0 !~ /^sync_version:[[:space:]]*/'
46
+ else
47
+ _qmap_stage "$manifest" '{ sub(/^sync_version:/, "schema_version:"); print }'
48
+ fi
49
+ echo " migrating: sync_version -> schema_version"
50
+ fi
51
+
22
52
  # v2 migration 1: staged engine content -> the @ainova-systems/sync package.
23
53
  # Pre-package manifests listed `.intelligence/engine/{rules,agents,skills}`
24
54
  # as sources fed by a CLI-staged copy; that copy is now an ordinary package
@@ -59,10 +89,20 @@ if [ "$have_pkg" -eq 1 ] && [ "$migrated" -eq 0 ]; then
59
89
  sync_pkg_install "$IP_ROOT"
60
90
  fi
61
91
  if [ "$have_pkg" -eq 0 ] && [ "$migrated" -eq 0 ]; then
62
- echo " NOTE: $SYNC_PKG_NAME is not in this manifest (bare setup) — engine meta-skills stay uninstalled; 'intelligence add $SYNC_PKG_NAME' opts back in." >&2
92
+ echo " NOTE: $SYNC_PKG_NAME is not in this manifest (bare setup) — engine meta-skills stay uninstalled; 'intelligence package add $SYNC_PKG_NAME' opts back in." >&2
63
93
  fi
64
94
 
65
- stamp_version "$manifest" "$eng"
66
- echo " sync_version -> $eng"
95
+ # A previous RC could leave this managed directory even after its source
96
+ # entries were already removed. Its presence is itself an upgrade predicate,
97
+ # so always close that post-condition.
98
+ if [ -d "$IP_ROOT/.intelligence/engine" ]; then
99
+ rm -rf "$IP_ROOT/.intelligence/engine"
100
+ echo " removed stale .intelligence/engine"
101
+ fi
67
102
 
68
- exec bash "$CLI_DIR/commands/sync.sh"
103
+ stamp_schema_version "$manifest" "$eng"
104
+ echo " schema_version -> $eng"
105
+
106
+ if [ "$no_sync" -eq 0 ]; then
107
+ exec bash "$CLI_DIR/commands/sync.sh"
108
+ fi
@@ -7,18 +7,45 @@
7
7
  # CLI-owned parsing lives in lib/manifest.sh (quoted-key `packages:` /
8
8
  # `registries:` blocks, which the engine deliberately never reads).
9
9
 
10
- # Engine libraries (readers, is_status, IS_RC_*, engine_version). Sourced in
11
- # the same order sync.sh uses. CLI_DIR / IS_ENGINE_DIR come exported from the
12
- # dispatcher.
13
- source "$IS_ENGINE_DIR/scripts/lib/common.sh"
14
- source "$IS_ENGINE_DIR/scripts/lib/layout.sh"
15
- source "$IS_ENGINE_DIR/scripts/lib/migrations.sh"
10
+ # Engine libraries (readers, is_status, IS_RC_*, engine_version). CLI_DIR /
11
+ # IS_ENGINE_DIR come exported from the dispatcher.
12
+ source "$IS_ENGINE_DIR/lib/common.sh"
13
+ source "$IS_ENGINE_DIR/lib/contract.sh"
16
14
 
17
15
  die() {
18
16
  echo "ERROR: $*" >&2
19
17
  exit 1
20
18
  }
21
19
 
20
+ # --- Untrusted-input guards ----------------------------------------------
21
+ # Manifest, lock and registry indexes arrive with a cloned repo: every value
22
+ # they carry is attacker-adjacent. URLs and refs reach git argv (a leading
23
+ # `-` would be an option — `--upload-pack=<cmd>` is code execution), names
24
+ # become store paths fed to rm -rf. Guard at the choke points, loudly.
25
+
26
+ # assert_safe_source_url <url> — scheme allowlist (the engine's own list) or
27
+ # scp-like user@host:path; never option-shaped, never quote-bearing.
28
+ assert_safe_source_url() {
29
+ local url="$1"
30
+ case "$url" in
31
+ ""|-*) die "unsafe source url '$url' — option-shaped or empty" ;;
32
+ *[\"\'\ ]*) die "unsafe source url '$url' — quotes or spaces" ;;
33
+ https://*|http://*|ssh://*|git://*|file://*) ;;
34
+ *@*:*) ;;
35
+ *) die "unsafe source url '$url' — allowed: https, http, ssh, git, file, or user@host:path" ;;
36
+ esac
37
+ }
38
+
39
+ # assert_safe_ref <ref-or-empty> — tags/branches/SHAs; never option-shaped.
40
+ assert_safe_ref() {
41
+ local ref="$1"
42
+ [ -z "$ref" ] && return 0
43
+ case "$ref" in
44
+ -*) die "unsafe git ref '$ref' — option-shaped" ;;
45
+ *[\"\'\ \\]*) die "unsafe git ref '$ref'" ;;
46
+ esac
47
+ }
48
+
22
49
  source "$CLI_DIR/lib/manifest.sh"
23
50
  source "$CLI_DIR/lib/semver.sh"
24
51
  source "$CLI_DIR/lib/registry.sh"
@@ -27,8 +54,8 @@ source "$CLI_DIR/lib/lockfile.sh"
27
54
  # The engine-content package: OPTIONAL but auto-selected at init. Package by
28
55
  # UX (manifest entry, lockfile row, list/search/remove), bundle by mechanics —
29
56
  # at the version the CLI ships, it materializes from the npm bundle without
30
- # network; only a cross-version install reaches git. The pin is held exactly
31
- # at the bundled engine version and moved only by `upgrade`.
57
+ # network; only a cross-version acquisition reaches git. The pin is held
58
+ # exactly at the bundled engine version and moved by lifecycle alignment.
32
59
  #
33
60
  # Its identity is DATA shipped with the distribution (cli/engine-package.yaml),
34
61
  # never a name compiled into cli code — a fork edits the file.
@@ -86,7 +113,7 @@ require_v2() {
86
113
  detect_project
87
114
  case "$IP_MODE" in
88
115
  v2) ;;
89
- legacy) die "this is a vendored (v1) setup — run 'intelligence migrate' first, or keep using its own flow" ;;
116
+ legacy) die "this is a vendored (v1) setup — run 'intelligence init' to migrate it, or keep using its own flow" ;;
90
117
  *) die "no intelligence project found here — run 'intelligence init'" ;;
91
118
  esac
92
119
  }
@@ -99,8 +126,16 @@ manifest_intelligence_dir() {
99
126
  printf '%s' "${v:-intelligence}"
100
127
  }
101
128
 
129
+ default_target_output() {
130
+ case "$1" in
131
+ agents) printf '%s' "AGENTS.md" ;;
132
+ copilot) printf '%s' ".github" ;;
133
+ *) printf '.%s' "$1" ;;
134
+ esac
135
+ }
136
+
102
137
  bundled_engine_version() {
103
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/VERSION"
138
+ tr -d ' \t\r\n' < "$IS_ENGINE_DIR/VERSION"
104
139
  }
105
140
 
106
141
  # --- The sync package's manifest/lock plumbing ---------------------------
@@ -129,14 +164,132 @@ sync_pkg_install() {
129
164
  # Everything the IS_CLI mode of sync.sh needs, derived from the manifest.
130
165
  export_engine_env() {
131
166
  local root="$1"
132
- local umbrella
133
- umbrella="$(manifest_intelligence_dir "$root/intelligence.yaml")"
167
+ local content_rel
168
+ content_rel="$(manifest_intelligence_dir "$root/intelligence.yaml")"
134
169
  export IS_CLI=1
135
170
  export CONFIG_FILE="$root/intelligence.yaml"
136
171
  export REPO_ROOT="$root"
137
- export IS_UMBRELLA_REL="$umbrella"
172
+ export IS_CONTENT_REL="$content_rel"
138
173
  export IS_MODULE_REL="$SYNC_PKG_STORE"
139
174
  export IS_SYNC_CMD="intelligence sync"
140
175
  export IS_MANIFEST_NAME="intelligence.yaml"
141
- export IS_PROTECTED_DIRS="$umbrella:.intelligence"
176
+ export IS_PROTECTED_DIRS="$content_rel:.intelligence"
177
+ }
178
+
179
+ # --- Project lifecycle preflight -----------------------------------------
180
+ # Public commands are intentionally few. They share this state gate so a CLI
181
+ # installed at a newer engine version brings the current v2 project forward
182
+ # before a mutating operation. The npm install itself cannot do that: it runs
183
+ # outside any project and does not know which repositories the user owns.
184
+
185
+ is_ci_environment() {
186
+ case "${CI:-}" in
187
+ 1|true|TRUE|True|yes|YES|Yes|on|ON|On) return 0 ;;
188
+ *) return 1 ;;
189
+ esac
190
+ }
191
+
192
+ # Validate a repository-relative project content directory, including every
193
+ # existing symlinked path component. Call before writing a new manifest.
194
+ assert_safe_content_dir() {
195
+ local root="$1" content_dir="$2" repo_phys probe old_ifs part probe_phys
196
+ local -a parts
197
+ case "$content_dir" in
198
+ ""|/*|.|..|../*|*/../*|*/..|.git|.git/*|.intelligence|.intelligence/*|*\\*|[A-Za-z]:*)
199
+ die "unsafe content directory '$content_dir'"
200
+ ;;
201
+ esac
202
+ repo_phys="$(cd "$root" && pwd -P)"
203
+ probe="$root"
204
+ old_ifs="$IFS"; IFS='/'; read -r -a parts <<< "$content_dir"; IFS="$old_ifs"
205
+ for part in "${parts[@]}"; do
206
+ [ -n "$part" ] || continue
207
+ probe="$probe/$part"
208
+ if [ -e "$probe" ] || [ -L "$probe" ]; then
209
+ probe_phys="$(cd "$probe" 2>/dev/null && pwd -P)" \
210
+ || die "cannot resolve project content path '$content_dir'"
211
+ case "$probe_phys" in
212
+ "$repo_phys"|"$repo_phys"/*) ;;
213
+ *) die "project content directory resolves outside the repository: '$content_dir'" ;;
214
+ esac
215
+ fi
216
+ done
217
+ }
218
+
219
+ project_needs_upgrade() {
220
+ local root="$1" manifest="$1/intelligence.yaml" stamp eng pinned locked name
221
+ [ -f "$manifest" ] || return 1
222
+ stamp="$(read_schema_version "$manifest")"
223
+ eng="$(bundled_engine_version)"
224
+ [ -z "$stamp" ] && return 0
225
+ [ -n "$(top_scalar "$manifest" "sync_version")" ] && return 0
226
+ _ver_gt "$eng" "$stamp" && return 0
227
+ [ -d "$root/.intelligence/engine" ] && return 0
228
+
229
+ while IFS= read -r name; do
230
+ [ "$name" = "$SYNC_PKG_NAME" ] || continue
231
+ pinned="$(qmap_field "$manifest" "packages" "$name" "version")"
232
+ locked="$(qmap_field "$root/intelligence.lock" "packages" "$name" "resolved")"
233
+ [ "$pinned" = "$eng" ] || return 0
234
+ if [ -f "$root/intelligence.lock" ]; then
235
+ [ -n "$locked" ] || return 0
236
+ [ "${locked#v}" = "$eng" ] || return 0
237
+ fi
238
+ done < <(qmap_keys "$manifest" "packages")
239
+ return 1
240
+ }
241
+
242
+ # A manifest with packages but no lock has no trustworthy resolved state from
243
+ # which lifecycle alignment can proceed. Never manufacture a partial lock.
244
+ project_has_packages() {
245
+ local root="$1" name found=1
246
+ while IFS= read -r name; do
247
+ [ -n "$name" ] || continue
248
+ assert_valid_pkg_name "$name"
249
+ found=0
250
+ done < <(qmap_keys "$root/intelligence.yaml" "packages")
251
+ return "$found"
252
+ }
253
+
254
+ ensure_project_current() {
255
+ local root="$1" explicit="${2:-}" manifest="$1/intelligence.yaml" stamp eng
256
+ check_version_compat "$manifest" || return $?
257
+ if project_has_packages "$root" && [ ! -f "$root/intelligence.lock" ]; then
258
+ die "manifest declares packages but intelligence.lock is absent — restore the committed lock before running project lifecycle commands"
259
+ fi
260
+ project_needs_upgrade "$root" || return 0
261
+ stamp="$(read_schema_version "$manifest")"
262
+ eng="$(bundled_engine_version)"
263
+ if is_ci_environment && [ "$explicit" != "--explicit" ]; then
264
+ die "project lifecycle requires alignment (stamp ${stamp:-unstamped}, engine $eng) — run 'intelligence init --apply' locally, review and commit the diff"
265
+ fi
266
+ echo " project alignment: stamp ${stamp:-unstamped}, engine $eng"
267
+ bash "$CLI_DIR/internal/upgrade-v2.sh" --no-sync
268
+ }
269
+
270
+ project_store_missing() {
271
+ local root="$1" manifest="$1/intelligence.yaml" name src
272
+ while IFS= read -r name; do
273
+ [ -n "$name" ] || continue
274
+ assert_valid_pkg_name "$name"
275
+ [ -d "$root/.intelligence/packages/$name" ] || return 0
276
+ done < <(qmap_keys "$manifest" "packages")
277
+ for section in rules agents skills; do
278
+ while IFS= read -r src; do
279
+ case "$src" in
280
+ .intelligence/packages/*)
281
+ [ -d "$root/$src" ] || return 0
282
+ ;;
283
+ esac
284
+ done < <(read_yaml_list "$manifest" "$section")
285
+ done
286
+ return 1
287
+ }
288
+
289
+ restore_project_store_if_missing() {
290
+ local root="$1"
291
+ project_store_missing "$root" || return 0
292
+ [ -f "$root/intelligence.lock" ] || die "package store is missing and intelligence.lock is absent — run 'intelligence init'"
293
+ echo " restoring package store from intelligence.lock"
294
+ bash "$CLI_DIR/internal/restore.sh" --frozen --no-sync
142
295
  }
@@ -12,7 +12,7 @@ LOCK_SEP=$'\x1f'
12
12
 
13
13
  # top_scalar <file> <key> — one top-level scalar (`key: "value"`), quotes and
14
14
  # trailing comment stripped. The declared exception to "no new parsing": the
15
- # engine's read_engine_stamp is hardwired to its own key.
15
+ # engine's read_schema_version is hardwired to its own key.
16
16
  top_scalar() {
17
17
  [ -f "$1" ] || return 0
18
18
  awk -v key="$2" '
@@ -54,14 +54,18 @@ lock_write_from_tsv() {
54
54
  echo "lockfile_version: $LOCKFILE_VERSION"
55
55
  echo "engine_version: \"$(bundled_engine_version)\""
56
56
  echo "packages:"
57
+ # Values reach a double-quoted YAML scalar: a `"` or `\` in any of
58
+ # them (registry-supplied url/path included) must be escaped, or it
59
+ # would break or alias the neighbouring field.
57
60
  awk -F"$LOCK_SEP" '
61
+ function esc(s) { gsub(/\\/, "\\\\", s); gsub(/"/, "\\\"", s); return s }
58
62
  NF >= 6 {
59
- print " \"" $1 "\":"
60
- if ($2 != "") print " requested: \"" $2 "\""
61
- print " url: \"" $3 "\""
62
- if ($4 != "") print " path: \"" $4 "\""
63
- if ($5 != "") print " resolved: \"" $5 "\""
64
- print " sha: \"" $6 "\""
63
+ print " \"" esc($1) "\":"
64
+ if ($2 != "") print " requested: \"" esc($2) "\""
65
+ print " url: \"" esc($3) "\""
66
+ if ($4 != "") print " path: \"" esc($4) "\""
67
+ if ($5 != "") print " resolved: \"" esc($5) "\""
68
+ print " sha: \"" esc($6) "\""
65
69
  }
66
70
  ' "$tsv"
67
71
  } > "$tmp"
@@ -72,7 +76,8 @@ lock_write_from_tsv() {
72
76
  # Replace-or-append one entry, keeping the others.
73
77
  lock_upsert() {
74
78
  local lock="$1" name="$2" requested="$3" url="$4" path="$5" resolved="$6" sha="$7"
75
- local tsv="${TMPDIR:-/tmp}/intelligence-lock-$$.tsv"
79
+ local tsv
80
+ tsv="$(mktemp -t intelligence-lock-XXXXXX 2>/dev/null || mktemp)"
76
81
  {
77
82
  lock_to_tsv "$lock" | awk -F"$LOCK_SEP" -v n="$name" '$1 != n'
78
83
  printf '%s\037%s\037%s\037%s\037%s\037%s\n' \
@@ -86,7 +91,8 @@ lock_upsert() {
86
91
  lock_remove() {
87
92
  local lock="$1" name="$2"
88
93
  [ -f "$lock" ] || return 0
89
- local tsv="${TMPDIR:-/tmp}/intelligence-lock-$$.tsv"
94
+ local tsv
95
+ tsv="$(mktemp -t intelligence-lock-XXXXXX 2>/dev/null || mktemp)"
90
96
  lock_to_tsv "$lock" | awk -F"$LOCK_SEP" -v n="$name" '$1 != n' > "$tsv"
91
97
  if [ -s "$tsv" ]; then
92
98
  lock_write_from_tsv "$lock" "$tsv"
@@ -36,6 +36,92 @@ assert_valid_pkg_name() {
36
36
  esac
37
37
  }
38
38
 
39
+ # Target names become adapter filenames and shell function suffixes, so keep
40
+ # them inside the portable shell-identifier subset used by built-in adapters.
41
+ assert_valid_target_name() {
42
+ case "$1" in
43
+ ""|[!abcdefghijklmnopqrstuvwxyz]*|*[!abcdefghijklmnopqrstuvwxyz0123456789_]*)
44
+ die "invalid target name '$1' — expected [a-z][a-z0-9_]*"
45
+ ;;
46
+ esac
47
+ }
48
+
49
+ # target_exists <manifest> <name> — distinguish a disabled target from one
50
+ # that is not declared at all. Both inline and block target shapes count.
51
+ target_exists() {
52
+ local file="$1" target="$2"
53
+ [ -f "$file" ] || return 1
54
+ awk -v target="$target" '
55
+ { sub(/\r$/, "") }
56
+ /^targets:[[:space:]]*$/ { in_targets = 1; next }
57
+ /^[A-Za-z]/ { in_targets = 0 }
58
+ in_targets && $0 ~ "^ " target ":[[:space:]]*" { found = 1; exit }
59
+ END { exit(found ? 0 : 1) }
60
+ ' "$file"
61
+ }
62
+
63
+ # target_set_enabled <manifest> <name> <true|false> <default-output>
64
+ # Transactionally update only `enabled`, preserving every other target field
65
+ # and comment. A missing target is added in the compact form used by init.
66
+ target_set_enabled() {
67
+ local file="$1" target="$2" enabled="$3" output="$4"
68
+ [ -f "$file" ] || die "no such file: $file"
69
+ case "$enabled" in true|false) ;; *) die "internal: invalid target state '$enabled'" ;; esac
70
+ output="$(_yq_esc "$output")"
71
+ _qmap_stage "$file" -v target="$target" -v enabled="$enabled" -v output="$output" '
72
+ function entry() { return " " target ": { enabled: " enabled ", output: \"" output "\" }" }
73
+ function add_enabled_to_inline(line, p) {
74
+ p = index(line, "{")
75
+ if (p > 0) return substr(line, 1, p) " enabled: " enabled "," substr(line, p + 1)
76
+ return line
77
+ }
78
+ function flush_target() {
79
+ if (in_target && !enabled_seen) print " enabled: " enabled
80
+ in_target = 0
81
+ }
82
+ function flush_targets() {
83
+ flush_target()
84
+ if (in_targets && !target_seen) { print entry(); target_seen = 1 }
85
+ in_targets = 0
86
+ }
87
+ { sub(/\r$/, "") }
88
+ /^targets:[[:space:]]*$/ { targets_seen = 1; in_targets = 1; print; next }
89
+ in_targets && /^[A-Za-z]/ { flush_targets() }
90
+ in_targets && /^ [A-Za-z]/ {
91
+ flush_target()
92
+ if ($0 ~ "^ " target ":[[:space:]]*") {
93
+ target_seen = 1
94
+ if (match($0, /enabled:[[:space:]]*(true|false)/)) {
95
+ print substr($0, 1, RSTART - 1) "enabled: " enabled substr($0, RSTART + RLENGTH)
96
+ enabled_seen = 1
97
+ } else if ($0 ~ /\{/) {
98
+ print add_enabled_to_inline($0)
99
+ enabled_seen = 1
100
+ } else {
101
+ print
102
+ in_target = 1
103
+ enabled_seen = 0
104
+ }
105
+ next
106
+ }
107
+ }
108
+ in_target && /^ enabled:[[:space:]]*/ {
109
+ print " enabled: " enabled
110
+ enabled_seen = 1
111
+ next
112
+ }
113
+ { last = $0; print }
114
+ END {
115
+ flush_targets()
116
+ if (!targets_seen) {
117
+ if (last != "") print ""
118
+ print "targets:"
119
+ print entry()
120
+ }
121
+ }
122
+ '
123
+ }
124
+
39
125
  # qmap_keys <file> <block> — quoted keys, one per line.
40
126
  qmap_keys() {
41
127
  [ -f "$1" ] || return 0
@@ -114,9 +200,19 @@ _qmap_stage() {
114
200
 
115
201
  # qmap_set <file> <block> <key> <field> <value> — upsert one field, creating
116
202
  # the block and the key as needed.
203
+ # _yq_esc <string> — escape \ and " for a double-quoted YAML scalar.
204
+ _yq_esc() {
205
+ local s="$1"
206
+ s="${s//\\/\\\\}"
207
+ printf '%s' "${s//\"/\\\"}"
208
+ }
209
+
117
210
  qmap_set() {
118
211
  local file="$1" block="$2" key="$3" field="$4" value="$5"
119
212
  [ -f "$file" ] || die "no such file: $file"
213
+ # url/path values can carry a `"`; escape before it reaches the quoted
214
+ # scalar the writer emits.
215
+ value="$(_yq_esc "$value")"
120
216
  _qmap_stage "$file" -v block="$block" -v key="$key" -v field="$field" -v value="$value" '
121
217
  function keyline() { return " \"" key "\":" }
122
218
  function fieldline() { return " " field ": \"" value "\"" }
@@ -163,6 +259,7 @@ qmap_set() {
163
259
  qmap_set_value() {
164
260
  local file="$1" block="$2" key="$3" value="$4"
165
261
  [ -f "$file" ] || die "no such file: $file"
262
+ value="$(_yq_esc "$value")"
166
263
  _qmap_stage "$file" -v block="$block" -v key="$key" -v value="$value" '
167
264
  function entry() { return " \"" key "\": \"" value "\"" }
168
265
  { sub(/\r$/, "") }
@@ -282,6 +379,14 @@ registries_remove() {
282
379
  { print }
283
380
  '
284
381
  }
382
+ # True (0) if <config> already lists <entry> under any sources section. Quoted
383
+ # and bare spellings both count - a manifest may hold either.
384
+ _mig_has_source() {
385
+ local config="$1" entry="$2"
386
+ [ -f "$config" ] || return 1
387
+ grep -Fq -- "\"$entry\"" "$config" || grep -Fq -- "- $entry" "$config"
388
+ }
389
+
285
390
 
286
391
  # sources_add_entry_first <file> <section> <entry> — idempotent insert at the
287
392
  # TOP of sources.<section> (creating sources:/section as needed). Package
@@ -313,7 +418,7 @@ sources_add_entry_first() {
313
418
  }
314
419
 
315
420
  # sources_remove_entry <file> <section> <entry> — remove `- "entry"` from
316
- # sources.<section>. Inverse of the engine-side _mig_add_source.
421
+ # sources.<section>.
317
422
  sources_remove_entry() {
318
423
  local file="$1" section="$2" entry="$3"
319
424
  [ -f "$file" ] || return 0
@@ -11,7 +11,7 @@
11
11
  # visible, reviewable, deletable.
12
12
  #
13
13
  # The NAME is the trust anchor a developer reasons with; source integrity is
14
- # a separate mechanism: the lock pins url+sha, and doctor flags
14
+ # a separate mechanism: the lock pins url+sha, and `status --check` flags
15
15
  # resolution/lock url drift.
16
16
  #
17
17
  # An index is itself fetched with git (never curl): auth, proxies and private
@@ -21,16 +21,26 @@
21
21
  # _fetch_index <registry-repo-url> — clones the registry repo (shallow) and
22
22
  # prints the path of its index.yaml; empty on failure. Cached per run+url.
23
23
  _fetch_index() {
24
- local url="$1" cache key tmp
25
- cache="${TMPDIR:-/tmp}/intelligence-cli-index-$$"
24
+ local url="$1" key tmp
25
+ assert_safe_source_url "$url"
26
+ # Per-run private cache dir under an UNPREDICTABLE mktemp path (a fixed
27
+ # /tmp/...-$$ would be trusted on cache hit and pre-creatable by others).
28
+ # No EXIT trap: _fetch_index is always called inside $( ), so a trap would
29
+ # fire in that subshell and delete the dir before the caller reads it. The
30
+ # dir is small and OS-reaped from the temp root; IS_REMOTE_CACHE-style
31
+ # persistence is exported so repeated calls in one command share it.
32
+ if [ -z "${IS_CLI_INDEX_CACHE:-}" ]; then
33
+ IS_CLI_INDEX_CACHE="$(mktemp -d -t intelligence-cli-index-XXXXXX 2>/dev/null || mktemp -d)"
34
+ export IS_CLI_INDEX_CACHE
35
+ fi
36
+ local cache="$IS_CLI_INDEX_CACHE"
26
37
  key="$(printf '%s' "$url" | cksum | awk '{print $1}')"
27
38
  if [ -f "$cache/$key/index.yaml" ]; then
28
39
  printf '%s' "$cache/$key/index.yaml"
29
40
  return 0
30
41
  fi
31
- mkdir -p "$cache"
32
42
  tmp="$cache/$key"
33
- if GIT_TERMINAL_PROMPT=0 git -c core.autocrlf=false clone --depth 1 --quiet "$url" "$tmp" 2>/dev/null \
43
+ if GIT_TERMINAL_PROMPT=0 git -c core.autocrlf=false clone --depth 1 --quiet -- "$url" "$tmp" 2>/dev/null \
34
44
  && [ -f "$tmp/index.yaml" ]; then
35
45
  printf '%s' "$tmp/index.yaml"
36
46
  fi
@@ -83,7 +93,7 @@ suggest_similar() {
83
93
  cshort="${cand#*/}"; cshort="${cshort%s}"
84
94
  if [ "$cshort" = "$want" ] && [ "$cand" != "$name" ]; then
85
95
  seen="$seen$cand "
86
- echo " Did you mean: intelligence add $cand" >&2
96
+ echo " Did you mean: intelligence package add $cand" >&2
87
97
  fi
88
98
  done < <(qmap_keys "$idx" "packages")
89
99
  }
@@ -103,19 +113,23 @@ suggest_similar() {
103
113
  # store holds the bytes the package published.
104
114
  fetch_package() {
105
115
  local url="$1" ref="$2" subpath="$3" dest="$4"
116
+ assert_safe_source_url "$url"
117
+ assert_safe_ref "$ref"
106
118
  # Bundle seed: the engine-content package at the CLI's own version copies
107
- # from the npm bundle — no network, which keeps init / fresh-clone install
108
- # / migrate offline exactly like the staging they replace. Keyed on the
119
+ # from the npm bundle — no network, which keeps init, sync restoration and
120
+ # archived-project conversion offline. Keyed on the
109
121
  # full (url, path, ref) triple; any other version or source falls through
110
122
  # to the normal clone.
111
123
  if [ "$url" = "$SYNC_PKG_URL" ] && [ "$subpath" = "$SYNC_PKG_PATH" ] \
112
124
  && [ "${ref#v}" = "$(bundled_engine_version)" ]; then
125
+ [ -n "${IS_BUNDLED_PKG_DIR:-}" ] && [ -d "$IS_BUNDLED_PKG_DIR" ] \
126
+ || die "bundled engine content not found next to the CLI — reinstall @ainova-systems/intelligence"
113
127
  rm -rf "$dest"
114
128
  mkdir -p "$dest"
115
- cp -R "$IS_ENGINE_DIR/." "$dest/"
129
+ cp -R "$IS_BUNDLED_PKG_DIR/." "$dest/"
116
130
  rm -rf "$dest/.git"
117
- if [ -f "$IS_ENGINE_DIR/scripts/ENGINE_SHA" ]; then
118
- tr -d ' \t\r\n' < "$IS_ENGINE_DIR/scripts/ENGINE_SHA"
131
+ if [ -f "$IS_ENGINE_DIR/ENGINE_SHA" ]; then
132
+ tr -d ' \t\r\n' < "$IS_ENGINE_DIR/ENGINE_SHA"
119
133
  fi
120
134
  return 0
121
135
  fi
@@ -124,20 +138,20 @@ fetch_package() {
124
138
  [ -n "$ref" ] && branch_arg=(--branch "$ref")
125
139
  tmp="$(mktemp -d -t intelligence-pkg-XXXXXX 2>/dev/null || mktemp -d)"
126
140
  if ! GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
127
- clone --depth 1 "${branch_arg[@]+"${branch_arg[@]}"}" --quiet "$url" "$tmp/clone" 2>/dev/null; then
141
+ clone --depth 1 "${branch_arg[@]+"${branch_arg[@]}"}" --quiet -- "$url" "$tmp/clone" 2>/dev/null; then
128
142
  # A SHA is not clonable with --branch — fall back to a full clone + checkout.
129
143
  rm -rf "$tmp/clone"
130
144
  GIT_TERMINAL_PROMPT=0 git -c core.symlinks=false -c core.autocrlf=false -c core.eol=lf \
131
- clone --quiet "$url" "$tmp/clone" 2>/dev/null \
145
+ clone --quiet -- "$url" "$tmp/clone" 2>/dev/null \
132
146
  || { rm -rf "$tmp"; die "cannot clone $url"; }
133
- [ -z "$ref" ] || git -C "$tmp/clone" checkout --quiet "$ref" \
147
+ [ -z "$ref" ] || git -C "$tmp/clone" checkout --quiet "$ref" -- \
134
148
  || { rm -rf "$tmp"; die "ref '$ref' not found in $url"; }
135
149
  fi
136
150
  sha="$(git -C "$tmp/clone" rev-parse HEAD)"
137
151
  src="$tmp/clone"
138
152
  if [ -n "$subpath" ]; then
139
153
  case "$subpath" in
140
- *..*) rm -rf "$tmp"; die "unsafe path '$subpath'" ;;
154
+ *..*|/*|*[\"\']*) rm -rf "$tmp"; die "unsafe path '$subpath'" ;;
141
155
  esac
142
156
  src="$tmp/clone/$subpath"
143
157
  [ -d "$src" ] || { rm -rf "$tmp"; die "path '$subpath' not found in $url${ref:+@$ref}" ; }