@execuro-sw-ecosystem/sw-ecosystem-agentic-harness 0.1.6 → 0.1.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.
package/lib/commands.mjs CHANGED
@@ -1,21 +1,23 @@
1
1
  // status / plan / apply / uninstall.
2
2
  //
3
- // Error aggregation lives here: each host is planned and applied inside its
4
- // own try, failures accumulate, and the process fails only after every host
5
- // has been attempted. One unwritable host must not abort the others.
3
+ // Error aggregation lives here: each agent is planned and applied inside its
4
+ // own try, failures accumulate, and the process fails only after every agent
5
+ // has been attempted. One unwritable agent must not abort the others.
6
6
 
7
7
  import { homedir } from 'node:os';
8
8
  import { resolve } from 'node:path';
9
9
  import * as out from './out.mjs';
10
10
  import {
11
- CONTENT_VERSION, LOCK_FILE, PKG_NAME, PKG_VERSION, agents, skills,
11
+ CONTENT_VERSION, LOCK_DIR as LOCK_DIR_LEGACY, PKG_NAME, PKG_VERSION, agents,
12
+ lockDir, lockFile, lockNeedsGitignore, skills,
12
13
  } from './content.mjs';
13
14
  import { exists, isWritable, abs } from './fsx.mjs';
14
- import { HOSTS, buildPlan, detectAll, installedCounts } from './hosts.mjs';
15
+ import { AGENTS, buildPlan, detectAll, installedCounts, skillsDir } from './hosts.mjs';
15
16
  import * as lockfile from './lock.mjs';
16
17
  import { dedupe, execute, isWritableState, preview, pruneDirs, revert } from './actions.mjs';
17
- import { probeAll } from './extra-components.mjs';
18
- import { askHosts } from './prompt.mjs';
18
+ import { removeDirIfEmpty, removeFile } from './fsx.mjs';
19
+ import { askAgents } from './prompt.mjs';
20
+ import { RESTART } from './render.mjs';
19
21
 
20
22
  const RUN = 'sw-ecosystem-agentic-harness';
21
23
 
@@ -25,24 +27,26 @@ class UsageError extends Error {
25
27
 
26
28
  /** Parse the flags every command shares. */
27
29
  export function buildContext(argv, command) {
28
- const hosts = [];
30
+ const agents = [];
29
31
  let scope = 'project';
30
32
  let root = process.cwd();
31
33
  let yes = false;
32
34
  let projectWiki;
33
- let extraComponents = true;
35
+ let asJson = false;
36
+ let verbose = false;
34
37
 
35
38
  for (let i = 0; i < argv.length; i += 1) {
36
39
  const arg = argv[i];
37
40
  if (arg === '--yes' || arg === '-y') { yes = true; continue; }
38
- if (arg === '--no-extra-components') { extraComponents = false; continue; }
39
- if (arg === '--host') {
41
+ if (arg === '--json') { asJson = true; continue; }
42
+ if (arg === '--verbose') { verbose = true; continue; }
43
+ if (arg === '--agent') {
40
44
  const value = argv[++i];
41
- if (!HOSTS.includes(value)) {
42
- throw new UsageError(`unknown --host ${value ?? '(missing)'}`,
43
- HOSTS.map((h) => `${RUN} ${command} --host ${h}`));
45
+ if (!AGENTS.includes(value)) {
46
+ throw new UsageError(`unknown --agent ${value ?? '(missing)'}`,
47
+ AGENTS.map((a) => `${RUN} ${command} --agent ${a}`));
44
48
  }
45
- hosts.push(value);
49
+ agents.push(value);
46
50
  continue;
47
51
  }
48
52
  if (arg === '--scope') {
@@ -59,17 +63,20 @@ export function buildContext(argv, command) {
59
63
  throw new UsageError(`unknown option ${arg}`, [`${RUN} guide`, `${RUN} ${command}`]);
60
64
  }
61
65
 
66
+ out.setFormat(asJson ? 'json' : 'human');
67
+ out.setVerbose(verbose);
68
+
62
69
  // User scope writes under $HOME; project scope stays in the repository.
63
70
  if (scope === 'user') root = homedir();
64
71
  root = resolve(root);
65
72
  if (!exists(root)) throw new UsageError(`--root does not exist: ${root}`, [`${RUN} ${command} --root .`]);
66
73
 
67
74
  return {
68
- root, scope, yes, projectWiki, extraComponents,
69
- // Empty means "nothing chosen yet" — resolveHosts() applies the
75
+ root, scope, yes, projectWiki, json: asJson, verbose,
76
+ // Empty means "nothing chosen yet" — resolveAgents() applies the
70
77
  // per-command fallback once the lock (and, for apply/install, a TTY)
71
78
  // is available to consult.
72
- hosts: [...new Set(hosts)],
79
+ agents: [...new Set(agents)],
73
80
  platform: process.platform,
74
81
  version: PKG_VERSION,
75
82
  lock: null,
@@ -77,35 +84,36 @@ export function buildContext(argv, command) {
77
84
  }
78
85
 
79
86
  /**
80
- * The host-resolution chain every write command shares:
81
- * 1. `--host` given → use exactly those. No question.
82
- * 2. else a lock already records hosts → reuse them. This is an update;
83
- * the choice was already made. No question.
87
+ * The agent-resolution chain every write command shares:
88
+ * 1. `--agent` given → use exactly those. No question.
89
+ * 2. else a lock already records agents → reuse them. This is an update;
90
+ * the choice was already made. No question. A lock written before the
91
+ * rename records them under `hosts`; lib/lock.mjs reads either.
84
92
  * 3. else a TTY is attached (apply/install only) → ask, via lib/prompt.mjs.
85
93
  * 4. else → UsageError, exit 2, `help` lists the four runnable commands.
86
94
  *
87
- * `status` and `uninstall` keep their old, ungated default of every host —
95
+ * `status` and `uninstall` keep their old, ungated default of every agent —
88
96
  * reporting and removal are not consent points. `plan` writes nothing, so it
89
97
  * never reaches step 3: steps 1-2, then it previews all four and says so.
90
98
  *
91
99
  * Call after `loadLock(ctx)` so `ctx.lock` is populated for step 2.
92
100
  */
93
- async function resolveHosts(ctx, command) {
94
- if (ctx.hosts.length) return { hosts: [...new Set(ctx.hosts)], chosen: true };
95
- if (command === 'status' || command === 'uninstall') return { hosts: [...HOSTS], chosen: true };
101
+ async function resolveAgents(ctx, command) {
102
+ if (ctx.agents.length) return { agents: [...new Set(ctx.agents)], chosen: true };
103
+ if (command === 'status' || command === 'uninstall') return { agents: [...AGENTS], chosen: true };
96
104
 
97
- const lockHosts = ctx.lock?.hosts ? Object.keys(ctx.lock.hosts) : [];
98
- if (lockHosts.length) return { hosts: lockHosts, chosen: true };
105
+ const recorded = ctx.lock?.agents ? Object.keys(ctx.lock.agents) : [];
106
+ if (recorded.length) return { agents: recorded, chosen: true };
99
107
 
100
- if (command === 'plan') return { hosts: [...HOSTS], chosen: false };
108
+ if (command === 'plan') return { agents: [...AGENTS], chosen: false };
101
109
 
102
110
  if (process.stdin.isTTY) {
103
- const chosen = await askHosts(HOSTS);
104
- if (chosen) return { hosts: chosen, chosen: true };
111
+ const chosen = await askAgents(AGENTS);
112
+ if (chosen) return { agents: chosen, chosen: true };
105
113
  }
106
114
  throw new UsageError(
107
- 'no --host given, this repository has no recorded install, and there is no terminal to ask which host(s) to use',
108
- HOSTS.map((h) => `${RUN} ${command} --host ${h}`),
115
+ 'no --agent given, this repository has no recorded install, and there is no terminal to ask which coding agent(s) to use',
116
+ AGENTS.map((a) => `${RUN} ${command} --agent ${a}`),
109
117
  );
110
118
  }
111
119
 
@@ -121,13 +129,13 @@ function loadLock(ctx) {
121
129
  return ctx.lock;
122
130
  }
123
131
 
124
- /** Preview every action for the selected hosts, aggregating per-host failures. */
132
+ /** Preview every action for the selected agents, aggregating per-agent failures. */
125
133
  function previewAll(ctx) {
126
134
  const errors = [];
127
135
  const actions = [];
128
- for (const host of ctx.hosts) {
136
+ for (const host of ctx.agents) {
129
137
  try {
130
- const forHost = dedupe(buildPlan({ ...ctx, hosts: [host], selectedHosts: ctx.hosts }));
138
+ const forHost = dedupe(buildPlan({ ...ctx, agents: [host], selectedAgents: ctx.agents }));
131
139
  for (const action of forHost) {
132
140
  try {
133
141
  actions.push(preview(action, ctx));
@@ -143,18 +151,11 @@ function previewAll(ctx) {
143
151
  }
144
152
 
145
153
  function summarise(actions) {
146
- const s = { create: 0, update: 0, unchanged: 0, drift: 0, conflict: 0, manual: 0, skipped: 0 };
154
+ const s = { create: 0, update: 0, unchanged: 0, drift: 0, conflict: 0, manual: 0 };
147
155
  for (const a of actions) if (a.state in s) s[a.state] += 1;
148
156
  return s;
149
157
  }
150
158
 
151
- /** The `npm i -D <spec>` line(s) for every skipped skill-copy action. */
152
- function skippedInstalls(actions) {
153
- return [...new Set(
154
- actions.filter((a) => a.state === 'skipped' && a.remedy?.startsWith('npm i -D')).map((a) => a.remedy),
155
- )];
156
- }
157
-
158
159
  /** Strip the fields that are machinery, not result. */
159
160
  function publicAction(a) {
160
161
  const { doc, fmt, body, value, conflict, newly_declined: nd, absolute, ...rest } = a;
@@ -175,13 +176,13 @@ export async function status(argv) {
175
176
  }
176
177
  throw e;
177
178
  }
178
- ({ hosts: ctx.hosts } = await resolveHosts(ctx, 'status'));
179
+ ({ agents: ctx.agents } = await resolveAgents(ctx, 'status'));
179
180
 
180
181
  const detections = await detectAll(ctx);
181
182
  const { actions, errors } = previewAll(ctx);
182
183
  const byHost = new Map(detections.map((d) => [d.id, d]));
183
184
 
184
- const hosts = detections.map((d) => {
185
+ const agentStates = detections.map((d) => {
185
186
  const mine = actions.filter((a) => a.host === d.id || a.host === 'shared');
186
187
  let state = 'absent';
187
188
  if (d.detected) {
@@ -189,41 +190,29 @@ export async function status(argv) {
189
190
  const bad = mine.filter((a) => a.state === 'conflict' || a.state === 'drift');
190
191
  if (bad.length) state = bad.some((a) => a.state === 'conflict') ? 'conflict' : 'drift';
191
192
  else if (pending.length === 0) state = 'ok';
192
- else state = lock?.hosts?.[d.id] ? 'drift' : 'not-installed';
193
+ else state = lock?.agents?.[d.id] ? 'drift' : 'not-installed';
193
194
  }
194
195
  return {
195
196
  id: d.id, detected: d.detected, marker: d.marker, writable: d.writable,
197
+ skills_dir: skillsDir(d.id, ctx),
196
198
  installed: installedCounts(d.id, ctx), state, notes: d.notes,
197
199
  };
198
200
  });
199
201
 
202
+ // `drift[]` carries conflicts too; `state` says which, so a file someone
203
+ // else wrote is not reported back to the user as their own edit.
200
204
  const drift = actions.filter((a) => a.state === 'drift' || a.state === 'conflict').map((a) => ({
201
- target: a.target, expected: a.hash ?? null, actual: a.prior ?? null,
205
+ target: a.target, state: a.state, expected: a.hash ?? null, actual: a.prior ?? null,
202
206
  reason: a.reason, remedy: a.remedy,
203
207
  }));
204
208
 
205
209
  const pending = actions.filter((a) => isWritableState(a.state)).length;
206
210
  const worst = ['conflict', 'drift', 'not-installed', 'ok', 'absent']
207
- .find((s) => hosts.some((h) => h.state === s)) ?? 'absent';
208
-
209
- const extraComponents = ctx.extraComponents === false ? [] : probeAll({ platform: ctx.platform }).map((p) => ({
210
- id: p.id, package: p.pkg, version: p.version, label: p.label,
211
- available: p.available, probe: p.state, reason: p.reason ?? null,
212
- installed_for: Object.entries(lock?.files ?? {})
213
- .filter(([target]) => target.endsWith(`/skills/${p.id}/SKILL.md`))
214
- .map(([, v]) => v.host),
215
- install: p.available ? null : `npm i -D ${p.spec}`,
216
- }));
211
+ .find((s) => agentStates.some((a) => a.state === s)) ?? 'absent';
217
212
 
218
213
  let next = pending
219
214
  ? `Run \`${RUN} plan\` to see the ${pending} change(s) needed, then \`${RUN} apply --yes\`.`
220
215
  : `Nothing to do — the harness is installed and up to date. Run \`${RUN} plan\` any time to re-check.`;
221
- if (ctx.extraComponents !== false) {
222
- const unavailable = extraComponents.filter((c) => !c.available);
223
- if (unavailable.length) {
224
- next += ` ${unavailable.length} optional extra component(s) are not on this machine — install with ${unavailable.map((c) => `\`${c.install}\``).join(', ')} to enable their skills.`;
225
- }
226
- }
227
216
 
228
217
  return out.ok('status', head(ctx, {
229
218
  installed_version: lock?.version ?? null,
@@ -231,7 +220,7 @@ export async function status(argv) {
231
220
  next_step: next,
232
221
  help: [`${RUN} plan`, `${RUN} apply --yes`, `${RUN} guide`],
233
222
  inventory: { skills: skills().length, agents: agents().length },
234
- hosts, extra_components: extraComponents, drift,
223
+ agents: agentStates, drift,
235
224
  declined: lock?.declined ?? [],
236
225
  manual: lock?.manual ?? [],
237
226
  errors,
@@ -251,27 +240,23 @@ export async function plan(argv) {
251
240
  }
252
241
  throw e;
253
242
  }
254
- const { hosts, chosen } = await resolveHosts(ctx, 'plan');
255
- ctx.hosts = hosts;
243
+ const { agents: chosenAgents, chosen } = await resolveAgents(ctx, 'plan');
244
+ ctx.agents = chosenAgents;
256
245
  const { actions, errors } = previewAll(ctx);
257
246
  const summary = summarise(actions);
258
247
  const changes = summary.create + summary.update;
259
248
 
260
- let next = chosen ? '' : 'No --host given and no existing install found, so this previews all four hosts. ';
249
+ let next = chosen ? '' : 'No --agent given and no existing install found, so this previews all four agents. ';
261
250
  next += changes
262
251
  ? `Nothing was written. Run \`${RUN} apply --yes\` to perform the ${changes} change(s).`
263
252
  + (summary.conflict || summary.manual
264
253
  ? ` The ${summary.conflict} conflict(s) and ${summary.manual} manual step(s) will be reported again and left to you.`
265
254
  : '')
266
255
  : `Nothing to do — every target is already up to date.`;
267
- const installs = skippedInstalls(actions);
268
- if (installs.length) {
269
- next += ` ${summary.skipped} action(s) skipped — run ${installs.map((i) => `\`${i}\``).join(', ')} to enable them.`;
270
- }
271
256
 
272
257
  return out.ok('plan', head(ctx, {
273
258
  summary, next_step: next,
274
- help: [`${RUN} apply --yes`, ...ctx.hosts.map((h) => `${RUN} apply --yes --host ${h}`)],
259
+ help: [`${RUN} apply --yes`, ...ctx.agents.map((a) => `${RUN} apply --yes --agent ${a}`)],
275
260
  actions: actions.map(publicAction),
276
261
  errors,
277
262
  }));
@@ -279,15 +264,8 @@ export async function plan(argv) {
279
264
 
280
265
  // ------------------------------------------------------------------- apply
281
266
 
282
- const RESTART_HELP = {
283
- 'claude-code': 'Restart Claude Code, or run `/mcp` in the running session',
284
- codex: 'Restart `codex` — .codex/config.toml is read once at startup',
285
- copilot: 'Reload the VS Code window (Developer: Reload Window) for .vscode/mcp.json',
286
- cursor: 'Restart Cursor so it re-reads .cursor/mcp.json',
287
- };
288
-
289
267
  export async function apply(argv, label = 'apply') {
290
- const ctx = buildContext(argv, 'apply');
268
+ const ctx = buildContext(argv, label);
291
269
  if (!ctx.yes) {
292
270
  return out.usage(label, 'apply refuses to write without --yes',
293
271
  [`${RUN} plan`, `${RUN} apply --yes`]);
@@ -303,24 +281,46 @@ export async function apply(argv, label = 'apply') {
303
281
  }
304
282
  throw e;
305
283
  }
306
- ({ hosts: ctx.hosts } = await resolveHosts(ctx, label));
284
+ ({ agents: ctx.agents } = await resolveAgents(ctx, label));
307
285
 
308
286
  // An apply we cannot record is an apply we cannot undo. Refuse up front
309
287
  // rather than leave the user with files and no way back.
310
- if (!isWritable(abs(ctx.root, LOCK_FILE))) {
311
- return out.failure(label, `cannot write the lock file at ${LOCK_FILE}`, {
288
+ if (!isWritable(abs(ctx.root, lockFile(ctx.root)))) {
289
+ return out.failure(label, `cannot write the lock file at ${lockFile(ctx.root)}`, {
312
290
  next_step: `Make ${ctx.root} writable, then re-run \`${RUN} apply --yes\`.`,
313
291
  help: [`${RUN} status`],
314
292
  });
315
293
  }
316
294
 
295
+ // Migration: a lock that used to live in the repository root carried a
296
+ // managed `.gitignore` line. Under `var/` the recipe ignores it already, so
297
+ // that line is ours to take back rather than leave behind as litter.
298
+ const staleIgnore = !lockNeedsGitignore(ctx.root)
299
+ && (lock.fences ?? []).find((f) => f.file === '.gitignore' && f.block === 'lock');
300
+ const migrated = [];
301
+ if (staleIgnore) {
302
+ try {
303
+ const result = revert({ kind: 'text-fence', target: '.gitignore', ...staleIgnore }, ctx);
304
+ if (result.state !== 'kept') {
305
+ lock = { ...lock, fences: lock.fences.filter((f) => f !== staleIgnore) };
306
+ migrated.push({
307
+ target: '.gitignore',
308
+ detail: `removed the managed \`${LOCK_DIR_LEGACY}/\` line — the lock now lives in ${lockDir(ctx.root)}/, which this project already ignores`,
309
+ });
310
+ }
311
+ } catch {
312
+ // Cosmetic. An apply that installed everything must not fail over a
313
+ // line we were trying to tidy away.
314
+ }
315
+ }
316
+
317
317
  const { actions, errors } = previewAll(ctx);
318
318
  const results = [];
319
- const perHost = new Map(ctx.hosts.map((h) => [h, { id: h, created: 0, updated: 0, unchanged: 0, failed: 0 }]));
319
+ const perAgent = new Map(ctx.agents.map((a) => [a, { id: a, created: 0, updated: 0, unchanged: 0, failed: 0 }]));
320
320
  const manual = [];
321
321
 
322
322
  for (const action of actions) {
323
- const bucket = perHost.get(action.host) ?? perHost.get(ctx.hosts[0]);
323
+ const bucket = perAgent.get(action.host) ?? perAgent.get(ctx.agents[0]);
324
324
  if (action.state === 'manual') {
325
325
  manual.push({ target: action.target, reason: action.reason, remedy: action.remedy });
326
326
  results.push({ ...publicAction(action), state: 'manual' });
@@ -333,7 +333,7 @@ export async function apply(argv, label = 'apply') {
333
333
  }
334
334
  try {
335
335
  const entry = execute(action, ctx);
336
- lock = lockfile.record(lock, [entry], { host: action.host === 'shared' ? undefined : action.host });
336
+ lock = lockfile.record(lock, [entry], { agent: action.host === 'shared' ? undefined : action.host });
337
337
  if (bucket) bucket[action.state === 'create' ? 'created' : 'updated'] += 1;
338
338
  results.push({ ...publicAction(action), state: action.state === 'create' ? 'created' : 'updated' });
339
339
  } catch (e) {
@@ -355,8 +355,8 @@ export async function apply(argv, label = 'apply') {
355
355
  for (const entry of manual) {
356
356
  lock = lockfile.record(lock, [{ kind: 'manual', target: entry.target, remedy: entry.remedy }]);
357
357
  }
358
- for (const host of ctx.hosts) {
359
- lock = { ...lock, hosts: { ...lock.hosts, [host]: { installed_at: new Date().toISOString(), version: PKG_VERSION } } };
358
+ for (const agent of ctx.agents) {
359
+ lock = { ...lock, agents: { ...lock.agents, [agent]: { installed_at: new Date().toISOString(), version: PKG_VERSION } } };
360
360
  }
361
361
  lockfile.write(ctx.root, lock);
362
362
 
@@ -367,25 +367,21 @@ export async function apply(argv, label = 'apply') {
367
367
  conflict: results.filter((r) => r.state === 'conflict').length,
368
368
  drift: results.filter((r) => r.state === 'drift').length,
369
369
  manual: manual.length,
370
- skipped: results.filter((r) => r.state === 'skipped').length,
371
370
  failed: results.filter((r) => r.state === 'failed').length,
372
371
  };
373
372
 
374
- const help = ctx.hosts.map((h) => RESTART_HELP[h]).filter(Boolean);
373
+ const help = ctx.agents.map((a) => RESTART[a]).filter(Boolean);
375
374
  help.push(`${RUN} status`);
376
375
 
377
376
  let next = summary.failed
378
377
  ? `${summary.failed} change(s) failed — see \`errors\`. Fix those, then re-run \`${RUN} apply --yes\`.`
379
- : `Restart the hosts listed in \`help\` so they pick up the new skills, agents and MCP servers.`
378
+ : `Restart the coding agents listed in \`help\` so they pick up the new skills, sub-agents and MCP servers.`
380
379
  + (manual.length ? ` Then finish the ${manual.length} manual step(s) in \`manual\`.` : '');
381
- const installs = skippedInstalls(results);
382
- if (installs.length) {
383
- next += ` ${summary.skipped} action(s) skipped — run ${installs.map((i) => `\`${i}\``).join(', ')} to enable them.`;
384
- }
385
380
 
386
381
  const body = head(ctx, {
387
- summary, lock_file: LOCK_FILE, next_step: next, help,
388
- hosts: [...perHost.values()], actions: results, manual, errors,
382
+ summary, lock_file: lockFile(ctx.root), next_step: next, help,
383
+ agents: [...perAgent.values()], actions: results, manual, errors,
384
+ ...(migrated.length ? { migrated } : {}),
389
385
  });
390
386
  return summary.failed ? out.failure(label, `${summary.failed} change(s) failed`, body) : out.ok(label, body);
391
387
  }
@@ -412,20 +408,20 @@ export async function uninstall(argv) {
412
408
  }
413
409
  throw e;
414
410
  }
415
- ({ hosts: ctx.hosts } = await resolveHosts(ctx, 'uninstall'));
411
+ ({ agents: ctx.agents } = await resolveAgents(ctx, 'uninstall'));
416
412
 
417
413
  if (!lock) {
418
414
  // No record means no evidence of what we own. Guessing here would delete
419
415
  // someone else's files, so it does nothing and says so.
420
416
  return out.ok('uninstall', head(ctx, {
421
417
  summary: { removed: 0, reverted: 0, kept: 0, missing: 0, failed: 0 },
422
- next_step: `Nothing to remove — no ${LOCK_FILE} was found, so this repository has no recorded install.`,
418
+ next_step: `Nothing to remove — no ${lockFile(ctx.root)} was found, so this repository has no recorded install.`,
423
419
  help: [`${RUN} status`],
424
420
  kept: [], missing: [], errors: [],
425
421
  }));
426
422
  }
427
423
 
428
- const selected = new Set(ctx.hosts);
424
+ const selected = new Set(ctx.agents);
429
425
  const all = lockfile.entries(lock);
430
426
  const mine = all.filter((e) => !e.host || selected.has(e.host) || e.host === 'shared');
431
427
 
@@ -452,11 +448,17 @@ export async function uninstall(argv) {
452
448
  const prunedDirs = pruneDirs(lock.dirs ?? [], ctx);
453
449
  const failed = errors.length;
454
450
 
455
- if (failed === 0 && survivors.length === 0 && selected.size === HOSTS.length) {
451
+ if (failed === 0 && survivors.length === 0 && selected.size === AGENTS.length) {
456
452
  // Everything we recorded is gone; the lock has nothing left to describe.
457
- const { removeFile } = await import('./fsx.mjs');
458
453
  removeFile(lockfile.lockPath(ctx.root));
459
- pruneDirs(['.sw-ai-sdk'], ctx);
454
+ removeFile(lockfile.legacyLockPath(ctx.root));
455
+ // `pruneDirs` deliberately never touches a top-level directory — that
456
+ // rule protects a host's own `.claude`/`.codex`, but `.sw-ai-sdk` and
457
+ // `var/sw-ai-sdk` are ours, and an empty one left behind is the clutter
458
+ // this move set out to remove.
459
+ pruneDirs([lockDir(ctx.root)], ctx);
460
+ removeDirIfEmpty(abs(ctx.root, LOCK_DIR_LEGACY));
461
+ removeDirIfEmpty(abs(ctx.root, lockDir(ctx.root)));
460
462
  } else {
461
463
  // Keep a lock describing exactly what survived, so a re-run resumes.
462
464
  lockfile.write(ctx.root, rebuild(lock, survivors, selected));
@@ -466,8 +468,8 @@ export async function uninstall(argv) {
466
468
  summary: { removed, reverted, kept: kept.length, missing: missing.length, failed },
467
469
  next_step: failed
468
470
  ? `${failed} item(s) could not be removed — see \`errors\`.`
469
- : `Restart your hosts.` + (kept.length ? ` ${kept.length} file(s) were left in place because you edited them — they are listed under \`kept\`.` : ''),
470
- help: [...ctx.hosts.map((h) => RESTART_HELP[h]).filter(Boolean), `${RUN} status`],
471
+ : `Restart your coding agent(s).` + (kept.length ? ` ${kept.length} file(s) were left in place because you edited them — they are listed under \`kept\`.` : ''),
472
+ help: [...ctx.agents.map((a) => RESTART[a]).filter(Boolean), `${RUN} status`],
471
473
  kept, missing, pruned_dirs: prunedDirs, errors,
472
474
  });
473
475
  return failed ? out.failure('uninstall', `${failed} item(s) could not be removed`, body) : out.ok('uninstall', body);
@@ -484,9 +486,9 @@ function rebuild(lock, survivors, selected) {
484
486
  else if (entry.kind === 'json-merge') json_pointers.push(entry);
485
487
  else fences.push(entry);
486
488
  }
487
- const hosts = { ...lock.hosts };
488
- for (const host of selected) delete hosts[host];
489
- return { ...lock, files, json_pointers, rules, fences, hosts };
489
+ const agents = { ...lock.agents };
490
+ for (const agent of selected) delete agents[agent];
491
+ return { ...lock, files, json_pointers, rules, fences, agents };
490
492
  }
491
493
 
492
494
  export { UsageError };
package/lib/content.mjs CHANGED
@@ -9,7 +9,7 @@
9
9
  import { readFileSync, readdirSync, statSync } from 'node:fs';
10
10
  import { dirname, join, resolve } from 'node:path';
11
11
  import { fileURLToPath } from 'node:url';
12
- import { walk } from './fsx.mjs';
12
+ import { isDir, walk } from './fsx.mjs';
13
13
 
14
14
  export const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
15
15
 
@@ -25,8 +25,38 @@ export const PKG_VERSION = pkg.version;
25
25
  export const CONTENT_VERSION = 1;
26
26
  export const SUPPORTED_CONTENT = [1];
27
27
 
28
+ /**
29
+ * Where the lock lives, relative to the install root.
30
+ *
31
+ * A Shopware project ignores the whole of `var/` through the `shopware/core`
32
+ * Flex recipe (`/var/*` with a single `!/var/.htaccess` negation), and `var/`
33
+ * is already where that project keeps machine-local state — `var/cache`,
34
+ * `var/log`. Writing the lock there keeps the repository root clean and means
35
+ * the installer needs no `.gitignore` line at all.
36
+ *
37
+ * A root with no `var/` — a user-scope install under $HOME, or a checkout that
38
+ * is not a Shopware project — keeps the original dot-directory, which is
39
+ * gitignored through the managed block as before. Nothing here looks at the
40
+ * platform or at any path outside the install root.
41
+ */
28
42
  export const LOCK_DIR = '.sw-ai-sdk';
29
43
  export const LOCK_FILE = `${LOCK_DIR}/harness.lock.json`;
44
+ export const VAR_LOCK_DIR = 'var/sw-ai-sdk';
45
+ export const VAR_LOCK_FILE = `${VAR_LOCK_DIR}/harness.lock.json`;
46
+
47
+ /** `var/sw-ai-sdk` when the root has a `var/`, else `.sw-ai-sdk`. */
48
+ export function lockDir(root) {
49
+ return isDir(join(root, 'var')) ? VAR_LOCK_DIR : LOCK_DIR;
50
+ }
51
+
52
+ export function lockFile(root) {
53
+ return `${lockDir(root)}/harness.lock.json`;
54
+ }
55
+
56
+ /** Whether this root still needs the managed `.gitignore` line for the lock. */
57
+ export function lockNeedsGitignore(root) {
58
+ return lockDir(root) === LOCK_DIR;
59
+ }
30
60
 
31
61
  function dirsIn(abs) {
32
62
  return readdirSync(abs)
package/lib/guide.mjs CHANGED
@@ -6,10 +6,8 @@
6
6
 
7
7
  import * as out from './out.mjs';
8
8
  import { PKG_NAME, PKG_VERSION } from './content.mjs';
9
- import { EXTRA_COMPONENTS } from './extra-components.mjs';
10
9
 
11
10
  const RUN = `npx -y ${PKG_NAME}@${PKG_VERSION}`;
12
- const EXTRA_COMPONENT_INSTALLS = EXTRA_COMPONENTS.map((c) => `npm i -D ${c.pkg}@${c.version}`).join('\n');
13
11
 
14
12
  export const GUIDE = `# ${PKG_NAME} — install protocol
15
13
 
@@ -21,16 +19,16 @@ registers the MCP servers and permission rules they depend on.
21
19
 
22
20
  1. \`${RUN} install\`
23
21
  The whole install in one command. It never prompts for consent — typing
24
- the verb is the consent — but it does still need to know **which** host(s)
25
- to write into, resolved in order: \`--host\`, if given; else the hosts a
26
- lock file already records, if this is a re-run; else, with a real
22
+ the verb is the consent — but it does still need to know **which** coding
23
+ agent(s) to write into, resolved in order: \`--agent\`, if given; else the
24
+ agents a lock file already records, if this is a re-run; else, with a real
27
25
  terminal attached, an interactive picker; else it exits 2 with the four
28
- \`${RUN} install --host <h>\` commands as \`help\` rather than guess or
26
+ \`${RUN} install --agent <a>\` commands as \`help\` rather than guess or
29
27
  hang — Codex's question tool does not block outside Plan mode, and CI has
30
- no terminal, so a skill invoking this must always pass \`--host\`.
28
+ no terminal, so a skill invoking this must always pass \`--agent\`.
31
29
 
32
30
  2. \`${RUN} status\`
33
- Read-only, optional. Reports which hosts are present, what is already
31
+ Read-only, optional. Reports which agents are present, what is already
34
32
  installed, and anything that has drifted since it was installed. Always safe.
35
33
 
36
34
  3. \`${RUN} plan\`
@@ -40,44 +38,47 @@ registers the MCP servers and permission rules they depend on.
40
38
  4. \`${RUN} apply --yes\`
41
39
  The scriptable name for the same write \`install\` performs — use it for
42
40
  re-runs and repairs. It refuses without \`--yes\`, and resolves which
43
- host(s) to touch the same way \`install\` does (see step 1).
41
+ agent(s) to touch the same way \`install\` does (see step 1).
44
42
 
45
43
  5. \`${RUN} uninstall --yes\`
46
44
  Removes what the lock file records and reverts the managed config blocks.
47
45
 
48
46
  ## Flags
49
47
 
50
- --host claude-code|codex|copilot|cursor select one host, repeatable —
51
- see the host-resolution order in
52
- step 1 above; a skill invoking
53
- \`install\`/\`apply\` must always
54
- pass it, since it has no terminal
48
+ --agent claude-code|codex|copilot|cursor select one coding agent,
49
+ repeatable — see the resolution
50
+ order in step 1 above; a skill
51
+ invoking \`install\`/\`apply\` must
52
+ always pass it, since it has no
53
+ terminal
55
54
  --scope project|user default: project
56
55
  --root <path> default: the current directory
57
- --no-extra-components skip the optional extra-component skills
58
-
59
- ## Optional extra components
60
-
61
- Two skills — Specs Editor and Tender Discovery Tool — ship inside their own
62
- npm packages, not this one. \`plan\`/\`apply\` detect them offline
63
- (\`npx --no-install\`) and never fetch them, probing for the exact version
64
- this release pins — any other version, including \`@latest\`, is treated as
65
- absent and the skill is skipped:
66
-
67
- \`\`\`bash
68
- ${EXTRA_COMPONENT_INSTALLS}
69
- \`\`\`
70
-
71
- The exact command is also in a \`skipped\` action's \`remedy\`, and in
72
- \`status\`'s \`extra_components\` array. Install it, then re-run \`${RUN} apply --yes\`.
56
+ --json print the result object instead of
57
+ the human summary — a skill or
58
+ agent always passes this
59
+ --verbose include the per-file detail
60
+
61
+ ## The two visual editors
62
+
63
+ The Specs Editor and the Tender Discovery Tool are optional, ship their own
64
+ skill inside their own npm package, and are **not installed by this CLI**.
65
+ The \`sw-setup\` skill owns their whole lifecycle — it offers them, installs
66
+ each one with that package's own \`install-skill --target <skills dir>\`,
67
+ updates them by re-running it, and removes them with \`uninstall-skill\`.
68
+ This CLI never probes for them, never reports them, and never reaches the
69
+ network for them; \`status --json\` reports each agent's \`skills_dir\`,
70
+ which is the target \`sw-setup\` hands them.
73
71
 
74
72
  ## What you can rely on
75
73
 
76
- - **stdout is exactly one JSON object.** Every successful result carries a
77
- \`next_step\` string: the literal command to run next. Errors carry a
78
- \`help\` array of runnable commands.
74
+ - **\`--json\` puts exactly one JSON object on stdout.** Every successful
75
+ result carries a \`next_step\` string: the literal command to run next.
76
+ Errors carry a \`help\` array of runnable commands. Without \`--json\`,
77
+ \`install\`, \`status\`, \`plan\` and \`uninstall\` print a short human
78
+ summary of that same object instead — same body, one view for a person and
79
+ one for a program. Anything parsing this output must pass \`--json\`.
79
80
  - **Exit codes:** 0 success, 2 usage error (your invocation was wrong,
80
- including \`install\`/\`apply\` run with no \`--host\`, no existing lock and
81
+ including \`install\`/\`apply\` run with no \`--agent\`, no existing lock and
81
82
  no terminal to ask on), 1 the work could not be done.
82
83
  - **Nothing is silently overwritten.** A file we did not install, or one you
83
84
  edited after we installed it, is reported as \`conflict\` or \`drift\` and
@@ -100,9 +101,9 @@ trailing commas is never rewritten at all; it becomes a manual step instead.
100
101
 
101
102
  ## After applying
102
103
 
103
- Every host reads its configuration at startup. The result's \`help\` array
104
- names the restart or reload each one needs — pass it on to the user rather
105
- than assuming the change is already live.
104
+ Every coding agent reads its configuration at startup. The result's \`help\`
105
+ array names the restart or reload each one needs — pass it on to the user
106
+ rather than assuming the change is already live.
106
107
 
107
108
  ## Keeping it current
108
109
 
@@ -122,7 +123,7 @@ export async function main() {
122
123
  return out.ok('guide', {
123
124
  version: PKG_VERSION,
124
125
  next_step: `${RUN} install`,
125
- help: [`${RUN} install`, `${RUN} status`, `${RUN} plan`, `${RUN} apply --yes`],
126
+ help: [`${RUN} install`, `${RUN} status --json`, `${RUN} plan --json`, `${RUN} apply --yes --json`],
126
127
  guide: GUIDE,
127
128
  });
128
129
  }