@1aboveio/skills 0.12.1 → 0.13.0

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.
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  import { spawnSync } from 'node:child_process';
4
- import { readFileSync, statSync } from 'node:fs';
4
+ import { mkdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
5
+ import { homedir } from 'node:os';
5
6
  import { emitKeypressEvents } from 'node:readline';
6
7
  import { dirname, join, resolve } from 'node:path';
7
8
  import { fileURLToPath } from 'node:url';
8
9
 
9
10
  import { isMainModule } from '../../engineering/engineering-runtime/scripts/main-module.mjs';
11
+ // The digest authority for the update skip is the ONE existing predicate pair: contentDigest with
12
+ // the published-content scope for first-party members, exact-tree for external ones — the same
13
+ // semantics release coherence validates installed trees with. The coordinator never re-derives or
14
+ // widens that scope; it only asks "does what is installed already digest to what this run would
15
+ // install?" and skips the native add when the answer is yes.
16
+ import { contentDigest, firstPartySource } from '../../engineering/engineering-runtime/scripts/workflow-coherence.mjs';
10
17
 
11
18
  // The coordinator is one command rendered into two transports: the private checkout and the
12
19
  // unpacked @1aboveio/skills package. Both defaults below are anchored on this file's own location,
@@ -29,6 +36,162 @@ const COMMAND_TRANSPORTS = ['local', 'https'];
29
36
  export const SUPPORT_GROUPS = new Set(['harness-runtime']);
30
37
  const ACTIONS = new Set(['install', 'update']);
31
38
 
39
+ // Selection memory (ADR 0002/0004 amendment, epic #1124): the remembered user-facing group
40
+ // set lives in XDG state so a no-argument `update` can replay it instead of reopening the picker.
41
+ // The file records only user-facing successes (#1129): it is written when at least one user-facing
42
+ // member's native add succeeded or was already digest-equal unchanged, with acceptedMembers
43
+ // holding exactly those succeeded and unchanged members; failed and declined members are omitted,
44
+ // a selected group left with no recordable member is omitted from the written set rather than
45
+ // remembered with an empty acceptance (a failed run never advances its group ids into remembered
46
+ // groups), and a run where nothing recordable succeeded leaves the prior file byte-for-byte
47
+ // untouched. It is never a source of filesystem inference: missing or corrupt memory is a clear
48
+ // error, not a reason to guess groups from disk.
49
+ export const SELECTION_SCHEMA_VERSION = 1;
50
+
51
+ export function selectionStatePath(env = process.env) {
52
+ const stateHome = env.XDG_STATE_HOME || join(env.HOME || homedir(), '.local', 'state');
53
+ return join(stateHome, '1aboveio-skills', 'selection.json');
54
+ }
55
+
56
+ function selectionError(path, detail) {
57
+ return new Error(`Remembered selection at ${path} ${detail}`);
58
+ }
59
+
60
+ export function loadSelectionState(env = process.env) {
61
+ const path = selectionStatePath(env);
62
+ let raw;
63
+ try {
64
+ raw = readFileSync(path, 'utf8');
65
+ } catch {
66
+ throw selectionError(path, 'does not exist. Run install or update with --group <id> or --all once to record a selection');
67
+ }
68
+ let parsed;
69
+ try {
70
+ parsed = JSON.parse(raw);
71
+ } catch (error) {
72
+ throw selectionError(path, `is not valid JSON: ${error.message}`);
73
+ }
74
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
75
+ throw selectionError(path, 'is invalid: expected an object');
76
+ }
77
+ if (parsed.schemaVersion !== SELECTION_SCHEMA_VERSION) {
78
+ throw selectionError(path, `has unsupported schemaVersion ${JSON.stringify(parsed.schemaVersion)} (expected ${SELECTION_SCHEMA_VERSION})`);
79
+ }
80
+ if (!Array.isArray(parsed.groups)) {
81
+ throw selectionError(path, 'is invalid: groups must be an array');
82
+ }
83
+ for (const group of parsed.groups) {
84
+ const valid = group
85
+ && typeof group === 'object'
86
+ && typeof group.id === 'string'
87
+ && group.id.length > 0
88
+ && Array.isArray(group.acceptedMembers)
89
+ && group.acceptedMembers.every((member) => typeof member === 'string');
90
+ if (!valid) {
91
+ throw selectionError(path, 'is invalid: every group needs an id and an acceptedMembers string array');
92
+ }
93
+ }
94
+ return { path, state: parsed };
95
+ }
96
+
97
+ function writeSelectionState(state, env = process.env) {
98
+ const path = selectionStatePath(env);
99
+ mkdirSync(dirname(path), { recursive: true });
100
+ writeFileSync(path, `${JSON.stringify(state, null, 2)}\n`);
101
+ return path;
102
+ }
103
+
104
+ // The group ids a remembered selection resolves to against the CURRENT catalog. Remembered ids
105
+ // that are no longer selectable (renamed, removed, or a support group that must never be
106
+ // user-selected) are dropped with a warning; memory never resurrects them and never falls back to
107
+ // filesystem inference. Nothing left is an error, not an empty plan.
108
+ function rememberedGroupIds(state, catalog, stderr) {
109
+ const known = new Set(catalog.map((bundle) => bundle.id));
110
+ const kept = [];
111
+ const dropped = [];
112
+ for (const group of state.groups) {
113
+ if (!known.has(group.id) || SUPPORT_GROUPS.has(group.id)) {
114
+ if (!dropped.includes(group.id)) dropped.push(group.id);
115
+ continue;
116
+ }
117
+ if (!kept.includes(group.id)) kept.push(group.id);
118
+ }
119
+ if (dropped.length > 0) {
120
+ stderr.write(`Ignoring unknown remembered bundle(s): ${dropped.join(', ')}\n`);
121
+ }
122
+ if (kept.length === 0) {
123
+ throw new Error('No known bundles remain in the remembered selection. Run update with --group <id> or --all to record a new selection');
124
+ }
125
+ return kept;
126
+ }
127
+
128
+ // The replacement memory for one run: the user-facing groups this run selected that have at least
129
+ // one recordable member, each with acceptedMembers holding the user-facing members that were
130
+ // successfully added or already digest-equal this run. Declined and failed members are omitted;
131
+ // support-only members are never remembered; a group whose user-facing members all failed or were
132
+ // declined is dropped entirely, so the next no-arg update never replays a group this run did not
133
+ // actually deliver. The transport label is observational only — both render targets share this
134
+ // file.
135
+ export function selectionForRun(action, groupIds, { renderTarget, now = new Date(), succeededMembers, omitMembers, ...transport } = {}) {
136
+ const omitted = new Set(omitMembers ?? []);
137
+ const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
138
+ const groups = new Map(recipes.groups.map((group) => [group.id, group]));
139
+ const succeeded = succeededMembers ? new Set(succeededMembers) : undefined;
140
+ return {
141
+ schemaVersion: SELECTION_SCHEMA_VERSION,
142
+ updatedAt: now.toISOString(),
143
+ transport: { renderTarget },
144
+ groups: groupIds
145
+ .map((groupId) => {
146
+ const group = groups.get(groupId);
147
+ if (!group) throw new Error(`Unknown bundle: ${groupId}`);
148
+ const acceptedMembers = [...new Set(group[action].steps.flatMap((step) => step.members))]
149
+ .filter((member) => !SUPPORT_GROUPS.has(member))
150
+ .filter((member) => !omitted.has(member))
151
+ .filter((member) => !succeeded || succeeded.has(member));
152
+ return { id: groupId, acceptedMembers };
153
+ })
154
+ // Zero user-facing accepted members means zero delivered acceptance for that group:
155
+ // remembering the id would let a later no-arg update treat it as consented with no record.
156
+ .filter((group) => group.acceptedMembers.length > 0),
157
+ };
158
+ }
159
+
160
+ // Newly declared members (issue #1128): per selected group, the CURRENT user-facing recipe members
161
+ // the remembered acceptedMembers does not know yet. A group with no prior acceptance contributes
162
+ // nothing — a first-time selection is itself the consent (#1126 semantics). Support members
163
+ // (harness-runtime) are silent dependencies and are never presented as a new user capability, so
164
+ // they are filtered out even though acceptedMembers never lists them.
165
+ export function newMemberCandidates(action, groupIds, priorState, transport = {}) {
166
+ if (!priorState) return [];
167
+ const recipes = readRecipes(resolveBundleTransport(transport).recipesPath);
168
+ const groups = new Map(recipes.groups.map((group) => [group.id, group]));
169
+ const acceptedByGroup = new Map(priorState.groups.map((group) => [group.id, new Set(group.acceptedMembers)]));
170
+ const candidates = [];
171
+ for (const groupId of groupIds) {
172
+ const accepted = acceptedByGroup.get(groupId);
173
+ if (!accepted) continue;
174
+ const group = groups.get(groupId);
175
+ if (!group) continue;
176
+ const members = [...new Set(group[action].steps.flatMap((step) => step.members))]
177
+ .filter((member) => !SUPPORT_GROUPS.has(member));
178
+ for (const member of members) {
179
+ if (!accepted.has(member)) candidates.push({ groupId, groupName: group.displayName, member });
180
+ }
181
+ }
182
+ return candidates;
183
+ }
184
+
185
+ // Prior acceptance for the new-member consent delta on explicit selections (picker, --group,
186
+ // --all). ABSENT memory is tolerated — a first-time selection needs no delta — but CORRUPT memory
187
+ // fails closed with the same clear error as the no-arg replay: guessing acceptance from a file we
188
+ // cannot read could silently install a member the user never consented to.
189
+ function loadPriorSelection(env) {
190
+ const path = selectionStatePath(env);
191
+ if (!statSync(path, { throwIfNoEntry: false })?.isFile()) return null;
192
+ return loadSelectionState(env).state;
193
+ }
194
+
32
195
  export const selectorTerminalControl = Object.freeze({
33
196
  enter: '\x1b[?1049h\x1b[?25l',
34
197
  redraw: '\x1b[2J\x1b[H',
@@ -42,6 +205,23 @@ export function resolveBundleTransport({ recipesPath, localRoot } = {}) {
42
205
  };
43
206
  }
44
207
 
208
+ // The render target recorded in selection memory is declared by the recipes DOCUMENT, never
209
+ // inferred from the recipes path: inside the packaged @1aboveio/skills CLI the entrypoint injects
210
+ // the package's own staged recipes, which IS this file's default path, so a path comparison would
211
+ // mislabel every public-npm run as a private checkout. The value is validated against the known
212
+ // targets so a foreign or hand-written recipes file fails closed instead of recording a made-up
213
+ // channel.
214
+ const RENDER_TARGETS = new Set(['private-checkout', 'public-npm']);
215
+
216
+ function declaredRenderTarget(transport) {
217
+ const { recipesPath } = resolveBundleTransport(transport);
218
+ const recipes = readRecipes(recipesPath);
219
+ if (!RENDER_TARGETS.has(recipes.renderTarget)) {
220
+ throw new Error(`Bundle recipes at ${recipesPath} declare an unknown renderTarget: ${JSON.stringify(recipes.renderTarget)}`);
221
+ }
222
+ return recipes.renderTarget;
223
+ }
224
+
45
225
  function readRecipes(recipesPath) {
46
226
  let contents;
47
227
  try {
@@ -65,7 +245,11 @@ export function bundleCatalog(transport = {}) {
65
245
  .map((group) => ({
66
246
  id: group.id,
67
247
  displayName: group.displayName,
68
- members: group.install.steps.flatMap((step) => step.members),
248
+ // User-facing members only: the picker's count and Includes line are user-facing surfaces,
249
+ // and a support dependency (harness-runtime) is never presented as part of what a group
250
+ // offers — it installs silently with the group that needs it.
251
+ members: group.install.steps.flatMap((step) => step.members)
252
+ .filter((member) => !SUPPORT_GROUPS.has(member)),
69
253
  }));
70
254
  }
71
255
 
@@ -85,15 +269,24 @@ function localFirstPartyCommand(command, localRoot, groupId) {
85
269
  return command.replace(NATIVE_ADD_SOURCE, (_match, prefix) => `${prefix}${shellArgument(localRoot)}`);
86
270
  }
87
271
 
88
- // Native `skills add` prompts once per invocation for the installation method. Selecting several
89
- // bundles used to emit one add per group, so interactive installs asked the same question N times.
90
- // Coalesce by sourceId: one native add per upstream, with the union of --skill names, in the order
91
- // sources first appear across the selection. Identical full commands still collapse as before.
272
+ // The coordinator executes one native add PER SKILL (#1129) rather than coalescing a whole source
273
+ // into one invocation. A coalesced add can only attribute a failure to the entire source; per-skill
274
+ // adds let the run record exactly which skill failed, continue with the remaining skills, and keep
275
+ // only the succeeded skills in selection memory. Recipe step/member order is preserved
276
+ // (dependency-first), and a member shared by several selected groups (harness-runtime) is still
277
+ // planned exactly once, at its first occurrence. Native `skills add` prompts once per invocation
278
+ // for the installation method, so per-skill execution relies on the native CLI's own non-interactive
279
+ // handling; what the coordinator must guarantee is attribution, ordering and deduplication.
92
280
  const SKILL_TAIL = /^(.*? --skill )(.+?)( && .*)?$/;
93
281
 
282
+ // The generated step binds its runtime suffix (` && npm ci ...`) to the whole coalesced command.
283
+ // Per skill it belongs to the runtime member's own add, so the dependency install runs only after
284
+ // engineering-runtime itself was actually added — never after a sibling, never when it was skipped.
285
+ const RUNTIME_MEMBER = 'engineering-runtime';
286
+
94
287
  function skillsFromCommand(command) {
95
288
  const match = SKILL_TAIL.exec(command);
96
- if (!match) throw new Error(`Native add has no --skill list to coalesce: ${command}`);
289
+ if (!match) throw new Error(`Native add has no --skill list to split: ${command}`);
97
290
  return match[2].trim().split(/\s+/).filter(Boolean);
98
291
  }
99
292
 
@@ -110,19 +303,12 @@ function withYesFlag(command) {
110
303
  return `${match[1]}${match[2]} --yes${match[3] || ''}`;
111
304
  }
112
305
 
113
- function mergeSkillLists(existing, incoming) {
114
- const seen = new Set(existing);
115
- const merged = [...existing];
116
- for (const skill of incoming) {
117
- if (seen.has(skill)) continue;
118
- seen.add(skill);
119
- merged.push(skill);
120
- }
121
- return merged;
122
- }
123
-
124
- export function planBundleCommands(action, groupIds, { yes = false, ...transport } = {}) {
306
+ export function planBundleCommands(action, groupIds, { yes = false, skipMembers, omitMembers, ...transport } = {}) {
125
307
  if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
308
+ // Members the user declined this run (#1128) are filtered out of every native --skill list.
309
+ // Support dependencies (harness-runtime) are never omittable: they are silent install
310
+ // dependencies, not user capabilities, so consent never applies to them.
311
+ const omit = new Set(omitMembers ?? []);
126
312
  const { recipesPath, localRoot } = resolveBundleTransport(transport);
127
313
  const recipes = readRecipes(recipesPath);
128
314
  assertLocalRoot(localRoot);
@@ -133,11 +319,11 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
133
319
  if (!requested.includes(groupId)) requested.push(groupId);
134
320
  }
135
321
 
136
- // Always coalesce from the selected groups (not recipes.all). A public package may expose only a
137
- // subset of groups while still shipping a full recipes.all from the private catalog; using that
138
- // all-plan would install skills that are not in the selection / not in the package.
139
- const bySource = new Map();
140
- const sourceOrder = [];
322
+ // Plan from the selected groups (not recipes.all). A public package may expose only a subset of
323
+ // groups while still shipping a full recipes.all from the private catalog; using that all-plan
324
+ // would install skills that are not in the selection / not in the package.
325
+ const planned = [];
326
+ const plannedByMember = new Map();
141
327
 
142
328
  for (const groupId of requested) {
143
329
  const group = groups.get(groupId);
@@ -149,66 +335,188 @@ export function planBundleCommands(action, groupIds, { yes = false, ...transport
149
335
  const command = step.sourceId === 'first-party'
150
336
  ? localFirstPartyCommand(generated.command, localRoot, groupId)
151
337
  : generated.command;
152
- const skills = skillsFromCommand(command);
338
+ // An explicit member subset (the digest skip) removes members from the native add but never
339
+ // reorders the rest: the remaining skills keep recipe order, support dependency first.
340
+ // Declined new members (#1128) are also filtered out, but consent never applies to support
341
+ // dependencies (harness-runtime): they are silent install dependencies, not capabilities.
342
+ const skills = skillsFromCommand(command)
343
+ .filter((skill) => !skipMembers?.has(skill))
344
+ .filter((skill) => SUPPORT_GROUPS.has(skill) || !omit.has(skill));
153
345
  const suffix = SKILL_TAIL.exec(command)?.[3] || '';
154
- const existing = bySource.get(step.sourceId);
155
- if (!existing) {
156
- sourceOrder.push(step.sourceId);
157
- bySource.set(step.sourceId, {
346
+ const bare = suffix ? command.slice(0, command.length - suffix.length) : command;
347
+ for (const skill of skills) {
348
+ const existing = plannedByMember.get(skill);
349
+ if (existing) {
350
+ if (!existing.groupIds.includes(groupId)) existing.groupIds.push(groupId);
351
+ continue;
352
+ }
353
+ let skillCommand = withSkills(bare, [skill]);
354
+ if (suffix && skill === RUNTIME_MEMBER) skillCommand = `${skillCommand}${suffix}`;
355
+ if (yes) skillCommand = withYesFlag(skillCommand);
356
+ const entry = {
357
+ member: skill,
158
358
  groupIds: [groupId],
159
- groupNames: [group.displayName],
359
+ groupId,
360
+ groupName: group.displayName,
160
361
  sourceId: step.sourceId,
161
- template: command,
162
- skills: [...skills],
163
- suffix,
362
+ command: skillCommand,
164
363
  cwd: localRoot,
165
364
  failureMessage: step.onFailure.message,
166
- });
167
- continue;
365
+ };
366
+ plannedByMember.set(skill, entry);
367
+ planned.push(entry);
168
368
  }
169
- existing.groupIds.push(groupId);
170
- if (!existing.groupNames.includes(group.displayName)) existing.groupNames.push(group.displayName);
171
- existing.skills = mergeSkillLists(existing.skills, skills);
172
- if (!existing.suffix && suffix) existing.suffix = suffix;
173
369
  }
174
370
  }
175
371
 
176
- return sourceOrder.map((sourceId) => {
177
- const planned = bySource.get(sourceId);
178
- let command = withSkills(planned.template, planned.skills);
179
- if (planned.suffix && !command.includes(' && ')) command = `${command}${planned.suffix}`;
180
- if (yes) command = withYesFlag(command);
181
- return {
182
- groupId: planned.groupIds.length === 1 ? planned.groupIds[0] : planned.groupIds.join('+'),
183
- groupName: planned.groupNames.length === 1 ? planned.groupNames[0] : planned.groupNames.join(' + '),
184
- sourceId: planned.sourceId,
185
- command,
186
- cwd: planned.cwd,
187
- failureMessage: planned.failureMessage,
188
- };
189
- });
372
+ return planned;
190
373
  }
191
374
 
375
+ // Continue-on-failure (#1129): every planned per-skill add is attempted in dependency order; a
376
+ // failed skill is recorded (its recipe failure message goes to stderr) and the run proceeds to the
377
+ // remaining skills. Nothing here stops early — the caller summarizes the buckets and decides the
378
+ // exit status, and selection persistence sees exactly which members succeeded.
192
379
  export function runBundleCommands(commands, {
193
380
  execute = (command, cwd) => spawnSync('/bin/bash', ['-lc', command], { cwd, stdio: 'inherit' }),
194
381
  stdout = process.stdout,
195
382
  stderr = process.stderr,
196
383
  } = {}) {
384
+ const succeeded = [];
385
+ const failed = [];
197
386
  for (const planned of commands) {
198
- stdout.write(`\nInstalling ${planned.groupName} (${planned.sourceId})...\n`);
387
+ stdout.write(`\nInstalling ${planned.member} (${planned.sourceId})...\n`);
199
388
  const result = execute(planned.command, planned.cwd);
200
- if (result.status !== 0) {
201
- stderr.write(`${planned.failureMessage}\n`);
202
- return Number.isInteger(result.status) ? result.status : 1;
389
+ if (result.status === 0) {
390
+ succeeded.push(planned);
391
+ continue;
392
+ }
393
+ stderr.write(`${planned.failureMessage}\n`);
394
+ failed.push({ planned, status: Number.isInteger(result.status) ? result.status : 1 });
395
+ }
396
+ return { succeeded, failed };
397
+ }
398
+
399
+ // The four run buckets, printed after every non-dry run. `unchanged` (digest-identical skip, #1127)
400
+ // and `declined` (new-member consent, #1128) render from empty lists until those units land — the
401
+ // summary contract is stable from here on, so both buckets are always shown.
402
+ function printRunSummary(stdout, { succeeded, failed, unchanged = [], declined = [] }) {
403
+ // The summary is a user-facing surface: support dependencies (harness-runtime) are planned and
404
+ // executed like any member, but they are dependency plumbing and are never named in a bucket.
405
+ const visible = (name) => !SUPPORT_GROUPS.has(name);
406
+ const render = (names) => (names.length > 0 ? names.join(', ') : '(none)');
407
+ stdout.write('\nRun summary:\n');
408
+ stdout.write(` succeeded: ${render(succeeded.map((planned) => planned.member).filter(visible))}\n`);
409
+ stdout.write(` unchanged: ${render(unchanged.filter(visible))}\n`);
410
+ stdout.write(` declined: ${render(declined.filter(visible))}\n`);
411
+ stdout.write(` failed: ${render(failed.map((entry) => entry.planned.member).filter(visible))}\n`);
412
+ }
413
+
414
+ // --- Digest classification (epic #1124 / issue #1127) --------------------------------------------
415
+ //
416
+ // An `update` that re-adds a skill whose installed content already digests to what this invocation
417
+ // would install buys nothing and costs the native overwrite prompt. So update classifies every
418
+ // accepted member first and omits the digest-equal ones from the native add — skipping is the ONLY
419
+ // way an unchanged member avoids the prompt, because the native CLI is the sole write authority and
420
+ // the coordinator may not copy, link, or overwrite anything itself.
421
+ //
422
+ // The digest semantics are borrowed, never re-invented: first-party members digest their PUBLISHED
423
+ // content (`published: true`) on both transports, external members digest the exact reviewed tree,
424
+ // exactly as release coherence validates them. And a member with NO expected digest — Impeccable is
425
+ // the one on tip — can never be `unchanged`: there is nothing to compare against, so an installed
426
+ // one refreshes and an absent one is missing.
427
+
428
+ // The pure verdict, separated from the filesystem so the whole table is testable in one place.
429
+ export function classifyMember({ installed, expectedDigest, actualDigest }) {
430
+ if (!installed) return 'missing';
431
+ if (typeof expectedDigest !== 'string' || expectedDigest.length === 0) return 'refresh';
432
+ return actualDigest === expectedDigest ? 'unchanged' : 'refresh';
433
+ }
434
+
435
+ // What this run expects a member to digest to. First-party expectation is computed from THIS
436
+ // invocation's local source root (the private checkout or the unpacked package — both stage the
437
+ // published file set, so the published-scope digest is the same on either transport); an external
438
+ // member's expectation is the reviewed tree digest the recipe carries, or null when it carries none.
439
+ export function expectedMemberDigest(source, member, { localRoot }) {
440
+ if (firstPartySource(source)) {
441
+ return contentDigest(join(localRoot, member.path), { published: true });
442
+ }
443
+ return typeof member?.contentDigest === 'string' ? member.contentDigest : null;
444
+ }
445
+
446
+ // Classify one installed member directory against its expectation. `skillsRoot` is the canonical
447
+ // installed store (`~/.agents/skills`); the member lives under it by installName.
448
+ export function classifyInstalledMember(source, member, { localRoot, skillsRoot }) {
449
+ const directory = join(skillsRoot, member.installName);
450
+ const entry = { member: member.installName, sourceId: source?.id ?? null, directory };
451
+ if (!statSync(directory, { throwIfNoEntry: false })?.isDirectory()) {
452
+ return { ...entry, status: 'missing' };
453
+ }
454
+ const expectedDigest = expectedMemberDigest(source, member, { localRoot });
455
+ const actualDigest = contentDigest(directory, { published: firstPartySource(source) });
456
+ return {
457
+ ...entry,
458
+ status: classifyMember({ installed: true, expectedDigest, actualDigest }),
459
+ expectedDigest,
460
+ actualDigest,
461
+ };
462
+ }
463
+
464
+ // Every member the selected groups would install/update, classified in recipe step order and
465
+ // deduplicated across groups (the shared support runtime classifies once). The buckets drive both
466
+ // the planner's skip set and the dry-run/no-op rendering. A member the recipe names but its source
467
+ // does not declare gets no expected digest — refresh-or-missing, never a silent skip.
468
+ export function classifySelectedMembers(action, groupIds, { skillsRoot, ...transport } = {}) {
469
+ if (!ACTIONS.has(action)) throw new Error(`Unsupported bundle action: ${action}`);
470
+ const { recipesPath, localRoot } = resolveBundleTransport(transport);
471
+ const recipes = readRecipes(recipesPath);
472
+ assertLocalRoot(localRoot);
473
+ const sourcesById = new Map((recipes.sources ?? []).map((source) => [source.id, source]));
474
+ const groups = new Map(recipes.groups.map((group) => [group.id, group]));
475
+ const seen = new Set();
476
+ const buckets = { unchanged: [], refresh: [], missing: [] };
477
+ for (const groupId of groupIds) {
478
+ const group = groups.get(groupId);
479
+ if (!group || SUPPORT_GROUPS.has(groupId)) throw new Error(`Unknown bundle: ${groupId}`);
480
+ for (const step of group[action].steps) {
481
+ const source = sourcesById.get(step.sourceId);
482
+ const membersByName = new Map((source?.members ?? []).map((member) => [member.installName, member]));
483
+ for (const installName of step.members) {
484
+ if (seen.has(installName)) continue;
485
+ seen.add(installName);
486
+ const member = membersByName.get(installName) ?? { installName };
487
+ const entry = classifyInstalledMember(source, member, { localRoot, skillsRoot });
488
+ buckets[entry.status].push(entry);
489
+ }
203
490
  }
204
491
  }
205
- return 0;
492
+ return buckets;
493
+ }
494
+
495
+ function printUpdateBuckets(stdout, buckets) {
496
+ // Bucket listings are user-facing: the support runtime classifies with everything else (it
497
+ // drives the planner's skip set) but is never named to the user.
498
+ const visible = (entry) => !SUPPORT_GROUPS.has(entry.member);
499
+ const unchanged = buckets.unchanged.filter(visible).map((entry) => entry.member);
500
+ const refresh = [
501
+ ...buckets.refresh.filter(visible).map((entry) => entry.member),
502
+ // Missing accepted members are refresh candidates (reinstall), not "new" — they belong to the
503
+ // would-refresh bucket, annotated so a dry run explains why an absent skill will be added.
504
+ ...buckets.missing.filter(visible).map((entry) => `${entry.member} (not installed)`),
505
+ ];
506
+ stdout.write(`Unchanged (installed content matches): ${unchanged.length > 0 ? unchanged.join(', ') : 'none'}\n`);
507
+ stdout.write(`Would refresh: ${refresh.length > 0 ? refresh.join(', ') : 'none'}\n`);
206
508
  }
207
509
 
208
510
  function usage() {
209
511
  return `Usage:
210
512
  node skills/distribution/scripts/bundles.mjs list [--recipes <path>]
211
513
  node skills/distribution/scripts/bundles.mjs <install|update> [--group <id> ... | --all] [--dry-run] [--yes] [--recipes <path>] [--local-root <path>]
514
+
515
+ install without --group/--all opens the interactive picker; update without them replays the
516
+ remembered selection (\${XDG_STATE_HOME:-~/.local/state}/1aboveio-skills/selection.json).
517
+ When a remembered group's current recipe declares members you have not accepted yet, each new skill
518
+ is offered individually and defaults to No; --yes accepts them all, --dry-run lists them without
519
+ prompting.
212
520
  `;
213
521
  }
214
522
 
@@ -310,35 +618,187 @@ async function promptForGroups(catalog, { stdin = process.stdin, stdout = proces
310
618
  });
311
619
  }
312
620
 
313
- export async function runCli(args) {
621
+ // A minimal line prompt on the injected streams — deliberately separate from the raw-mode group
622
+ // picker: this is a sequence of independent yes/no questions, not a multi-select. EOF or an empty
623
+ // line is the default answer (No), so a closed or ended stdin can never accept a new skill.
624
+ function createLinePrompter({ stdin, stdout }) {
625
+ let buffer = '';
626
+ let ended = false;
627
+ const waiters = [];
628
+ const flush = () => {
629
+ while (waiters.length > 0) {
630
+ const newline = buffer.search(/\r?\n/);
631
+ if (newline !== -1) {
632
+ const line = buffer.slice(0, newline);
633
+ buffer = buffer.slice(newline).replace(/^\r?\n/, '');
634
+ waiters.shift()(line.trim());
635
+ } else if (ended) {
636
+ const rest = buffer;
637
+ buffer = '';
638
+ waiters.shift()(rest.trim());
639
+ } else {
640
+ return;
641
+ }
642
+ }
643
+ };
644
+ stdin.on('data', (chunk) => { buffer += String(chunk); flush(); });
645
+ stdin.on('end', () => { ended = true; flush(); });
646
+ stdin.resume?.();
647
+ return {
648
+ ask(question) {
649
+ stdout.write(question);
650
+ return new Promise((answer) => { waiters.push(answer); flush(); });
651
+ },
652
+ };
653
+ }
654
+
655
+ // Offer each newly declared member individually and return the DECLINED member names (#1128). The
656
+ // default is No: --yes accepts all without prompting; a non-interactive stdin declines all with a
657
+ // note rather than guessing consent; interactively, anything but an explicit y/yes declines.
658
+ async function consentNewMembers(candidates, { yes, stdin, stdout, stderr }) {
659
+ const declined = new Set();
660
+ if (yes) {
661
+ for (const candidate of candidates) {
662
+ stdout.write(`Accepting new skill "${candidate.member}" in ${candidate.groupName} (--yes)\n`);
663
+ }
664
+ return declined;
665
+ }
666
+ if (!stdin.isTTY) {
667
+ for (const candidate of candidates) {
668
+ stderr.write(`Declining new skill "${candidate.member}" in ${candidate.groupName}: no interactive terminal (default No; re-run with --yes to accept)\n`);
669
+ declined.add(candidate.member);
670
+ }
671
+ return declined;
672
+ }
673
+ const prompter = createLinePrompter({ stdin, stdout });
674
+ for (const candidate of candidates) {
675
+ const answer = await prompter.ask(`Add new skill "${candidate.member}" to ${candidate.groupName} (${candidate.groupId})? [y/N] `);
676
+ if (/^y(es)?$/i.test(answer)) {
677
+ stdout.write(`Adding ${candidate.member}\n`);
678
+ } else {
679
+ stdout.write(`Skipping ${candidate.member}\n`);
680
+ declined.add(candidate.member);
681
+ }
682
+ }
683
+ return declined;
684
+ }
685
+
686
+ // Dependencies are injectable so the coordinator seam can be tested black-box: a stubbed native
687
+ // executor, controllable TTY streams, and a fixture XDG environment, without reaching process
688
+ // globals. The default execute is the real shell out to the native Skills CLI — the sole
689
+ // lifecycle write authority; the coordinator only selects, plans, invokes, and persists memory
690
+ // AFTER those native commands settle.
691
+ export async function runCli(args, {
692
+ execute,
693
+ stdin = process.stdin,
694
+ stdout = process.stdout,
695
+ stderr = process.stderr,
696
+ env = process.env,
697
+ } = {}) {
314
698
  try {
315
699
  const parsed = parseArgs(args);
316
700
  const transport = { recipesPath: parsed.recipesPath, localRoot: parsed.localRoot };
317
701
  if (parsed.action === 'list') {
318
- printCatalog(process.stdout, bundleCatalog(transport));
702
+ printCatalog(stdout, bundleCatalog(transport));
319
703
  return 0;
320
704
  }
321
705
  if (!ACTIONS.has(parsed.action)) {
322
- process.stderr.write(usage());
706
+ stderr.write(usage());
323
707
  return 2;
324
708
  }
325
709
  const catalog = bundleCatalog(transport);
326
- const groupIds = parsed.all
327
- ? catalog.map((bundle) => bundle.id)
328
- : parsed.groupIds.length > 0
329
- ? parsed.groupIds
330
- : await promptForGroups(catalog);
710
+ let groupIds;
711
+ let priorState = null;
712
+ if (parsed.all) {
713
+ groupIds = catalog.map((bundle) => bundle.id);
714
+ priorState = loadPriorSelection(env);
715
+ } else if (parsed.groupIds.length > 0) {
716
+ groupIds = parsed.groupIds;
717
+ priorState = loadPriorSelection(env);
718
+ } else if (parsed.action === 'update') {
719
+ // No-arg update replays the remembered selection. It never opens the picker and never
720
+ // infers groups from the filesystem: missing/corrupt/foreign memory is a hard error.
721
+ const { state } = loadSelectionState(env);
722
+ priorState = state;
723
+ groupIds = rememberedGroupIds(state, catalog, stderr);
724
+ } else {
725
+ groupIds = await promptForGroups(catalog, { stdin, stdout });
726
+ priorState = loadPriorSelection(env);
727
+ }
331
728
  if (groupIds.length === 0) throw new Error('Select at least one bundle');
332
- const commands = planBundleCommands(parsed.action, groupIds, { ...transport, yes: parsed.yes });
729
+ // New-member consent (#1128): newly declared user-facing members of a group with prior
730
+ // acceptance are offered one at a time, defaulting to No. A dry run only LISTS the would-be
731
+ // candidates — it never prompts and never writes state.
732
+ const candidates = newMemberCandidates(parsed.action, groupIds, priorState, transport);
733
+ let declined = new Set();
734
+ if (candidates.length > 0 && parsed.dryRun) {
735
+ for (const candidate of candidates) {
736
+ stdout.write(`New skill "${candidate.member}" in ${candidate.groupName} (${candidate.groupId}) — would prompt for consent (default: No); --yes accepts all new skills\n`);
737
+ }
738
+ } else if (candidates.length > 0) {
739
+ declined = await consentNewMembers(candidates, { yes: parsed.yes, stdin, stdout, stderr });
740
+ }
741
+ // Digest skip (#1127): `update` classifies the accepted members against the installed tree and
742
+ // omits the digest-equal ones from the native add. `install` never skips — it is an explicit
743
+ // request to install, whose overwrite consent belongs to the native prompt. Declined new
744
+ // members (#1128) are excluded from classification: they never run, so they are not buckets.
745
+ let buckets;
746
+ let skipMembers;
747
+ if (parsed.action === 'update') {
748
+ const skillsRoot = join(env.HOME || homedir(), '.agents', 'skills');
749
+ buckets = classifySelectedMembers(parsed.action, groupIds, { ...transport, skillsRoot });
750
+ skipMembers = new Set(buckets.unchanged.map((entry) => entry.member));
751
+ if (declined.size > 0) {
752
+ for (const key of Object.keys(buckets)) {
753
+ buckets[key] = buckets[key].filter((entry) => !declined.has(entry.member));
754
+ }
755
+ }
756
+ }
757
+ const commands = planBundleCommands(parsed.action, groupIds, { ...transport, yes: parsed.yes, skipMembers, omitMembers: declined });
333
758
  if (parsed.dryRun) {
759
+ if (buckets) printUpdateBuckets(stdout, buckets);
334
760
  for (const planned of commands) {
335
- process.stdout.write(`\n${planned.groupName} / ${planned.sourceId}\n${planned.command}\n`);
761
+ stdout.write(`\n${planned.groupName} / ${planned.sourceId} / ${planned.member}\n${planned.command}\n`);
336
762
  }
763
+ if (buckets && commands.length === 0) stdout.write('Nothing to do: every accepted skill is already up to date.\n');
337
764
  return 0;
338
765
  }
339
- return runBundleCommands(commands);
766
+ if (commands.length === 0 && buckets) {
767
+ stdout.write('All accepted skills are already up to date; nothing to do.\n');
768
+ } else if (buckets?.unchanged.length > 0) {
769
+ const skipping = buckets.unchanged.map((entry) => entry.member).filter((member) => !SUPPORT_GROUPS.has(member));
770
+ if (skipping.length > 0) stdout.write(`Up to date, skipping: ${skipping.join(', ')}\n`);
771
+ }
772
+ const { succeeded, failed } = runBundleCommands(commands, { execute, stdout, stderr });
773
+ printRunSummary(stdout, {
774
+ succeeded,
775
+ failed,
776
+ unchanged: buckets?.unchanged.map((entry) => entry.member),
777
+ declined: [...declined],
778
+ });
779
+ // Mixed-success memory (#1129): retain only user-facing members that either succeeded at
780
+ // native add or were already digest-equal. A group with no recordable member is omitted from
781
+ // the written set, and a run with no recordable member at all leaves prior memory untouched;
782
+ // a decline never becomes remembered acceptance.
783
+ const succeededMembers = [
784
+ ...succeeded.map((planned) => planned.member),
785
+ ...(buckets?.unchanged.map((entry) => entry.member) ?? []),
786
+ ];
787
+ if (succeededMembers.length > 0) {
788
+ const renderTarget = declaredRenderTarget(transport);
789
+ const selection = selectionForRun(parsed.action, groupIds, {
790
+ ...transport,
791
+ renderTarget,
792
+ succeededMembers,
793
+ omitMembers: declined,
794
+ });
795
+ if (selection.groups.length > 0) {
796
+ writeSelectionState(selection, env);
797
+ }
798
+ }
799
+ return failed.length > 0 ? failed[0].status : 0;
340
800
  } catch (error) {
341
- process.stderr.write(`${error.message}\n`);
801
+ stderr.write(`${error.message}\n`);
342
802
  return 2;
343
803
  }
344
804
  }