@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/CHANGELOG.md +87 -0
- package/CONTRIBUTING.md +13 -4
- package/README.md +67 -36
- package/SECURITY.md +1 -1
- package/bin/cli.mjs +21 -15
- package/lib/actions.mjs +1 -45
- package/lib/commands.mjs +111 -109
- package/lib/content.mjs +31 -1
- package/lib/guide.mjs +39 -38
- package/lib/hosts.mjs +44 -42
- package/lib/lock.mjs +51 -14
- package/lib/out.mjs +27 -6
- package/lib/prompt.mjs +18 -23
- package/lib/render.mjs +365 -0
- package/package.json +1 -1
- package/plugin.json +1 -1
- package/skills/sw-design-requirements/SKILL.md +1 -1
- package/skills/sw-design-requirements/reference/editor-gate.md +1 -1
- package/skills/sw-design-requirements/reference/editor-mode.md +2 -2
- package/skills/sw-design-solution/SKILL.md +1 -1
- package/skills/sw-design-solution/reference/editor-mode.md +2 -2
- package/skills/sw-discover-tender/SKILL.md +2 -2
- package/skills/sw-discover-tender/reference/editor-session.md +3 -3
- package/skills/sw-discover-tender/reference/procedure.md +2 -2
- package/skills/sw-setup/SKILL.md +19 -17
- package/skills/sw-setup/reference/rows.md +120 -77
- package/lib/extra-components.mjs +0 -117
package/lib/commands.mjs
CHANGED
|
@@ -1,21 +1,23 @@
|
|
|
1
1
|
// status / plan / apply / uninstall.
|
|
2
2
|
//
|
|
3
|
-
// Error aggregation lives here: each
|
|
4
|
-
// own try, failures accumulate, and the process fails only after every
|
|
5
|
-
// has been attempted. One unwritable
|
|
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,
|
|
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 {
|
|
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 {
|
|
18
|
-
import {
|
|
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
|
|
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
|
|
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 === '--
|
|
39
|
-
if (arg === '--
|
|
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 (!
|
|
42
|
-
throw new UsageError(`unknown --
|
|
43
|
-
|
|
45
|
+
if (!AGENTS.includes(value)) {
|
|
46
|
+
throw new UsageError(`unknown --agent ${value ?? '(missing)'}`,
|
|
47
|
+
AGENTS.map((a) => `${RUN} ${command} --agent ${a}`));
|
|
44
48
|
}
|
|
45
|
-
|
|
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,
|
|
69
|
-
// Empty means "nothing chosen yet" —
|
|
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
|
-
|
|
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
|
|
81
|
-
* 1. `--
|
|
82
|
-
* 2. else a lock already records
|
|
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
|
|
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
|
|
94
|
-
if (ctx.
|
|
95
|
-
if (command === 'status' || command === 'uninstall') return {
|
|
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
|
|
98
|
-
if (
|
|
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 {
|
|
108
|
+
if (command === 'plan') return { agents: [...AGENTS], chosen: false };
|
|
101
109
|
|
|
102
110
|
if (process.stdin.isTTY) {
|
|
103
|
-
const chosen = await
|
|
104
|
-
if (chosen) return {
|
|
111
|
+
const chosen = await askAgents(AGENTS);
|
|
112
|
+
if (chosen) return { agents: chosen, chosen: true };
|
|
105
113
|
}
|
|
106
114
|
throw new UsageError(
|
|
107
|
-
'no --
|
|
108
|
-
|
|
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
|
|
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.
|
|
136
|
+
for (const host of ctx.agents) {
|
|
129
137
|
try {
|
|
130
|
-
const forHost = dedupe(buildPlan({ ...ctx,
|
|
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
|
|
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
|
-
({
|
|
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
|
|
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?.
|
|
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) =>
|
|
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
|
-
|
|
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 {
|
|
255
|
-
ctx.
|
|
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 --
|
|
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.
|
|
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,
|
|
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
|
-
({
|
|
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,
|
|
311
|
-
return out.failure(label, `cannot write the lock file at ${
|
|
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
|
|
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 =
|
|
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], {
|
|
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
|
|
359
|
-
lock = { ...lock,
|
|
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.
|
|
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
|
|
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:
|
|
388
|
-
|
|
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
|
-
({
|
|
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 ${
|
|
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.
|
|
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 ===
|
|
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
|
-
|
|
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
|
|
470
|
-
help: [...ctx.
|
|
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
|
|
488
|
-
for (const
|
|
489
|
-
return { ...lock, files, json_pointers, rules, fences,
|
|
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**
|
|
25
|
-
to write into, resolved in order: \`--
|
|
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 --
|
|
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 \`--
|
|
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
|
|
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
|
-
|
|
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
|
-
--
|
|
51
|
-
see the
|
|
52
|
-
step 1 above; a skill
|
|
53
|
-
\`install\`/\`apply\` must
|
|
54
|
-
pass it, since it has no
|
|
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
|
-
--
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
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
|
-
-
|
|
77
|
-
\`next_step\` string: the literal command to run next.
|
|
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 \`--
|
|
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
|
|
104
|
-
names the restart or reload each one needs — pass it on to the user
|
|
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
|
}
|