@execuro-sw-ecosystem/sw-ecosystem-agentic-harness 0.1.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.
- package/.mcp.json +8 -0
- package/CHANGELOG.md +78 -0
- package/CONTRIBUTING.md +54 -0
- package/LICENSE +21 -0
- package/README.md +304 -0
- package/SECURITY.md +50 -0
- package/agents/sw-admin-frontend-developer.md +52 -0
- package/agents/sw-php-backend-developer.md +63 -0
- package/agents/sw-product-manager.md +62 -0
- package/agents/sw-qa-engineer.md +160 -0
- package/agents/sw-shopware-architect.md +79 -0
- package/agents/sw-storefront-developer.md +93 -0
- package/agents/sw-tender-editor.md +43 -0
- package/bin/cli.mjs +73 -0
- package/codex/agents/sw-admin-frontend-developer.toml +54 -0
- package/codex/agents/sw-php-backend-developer.toml +65 -0
- package/codex/agents/sw-product-manager.toml +66 -0
- package/codex/agents/sw-qa-engineer.toml +161 -0
- package/codex/agents/sw-shopware-architect.toml +81 -0
- package/codex/agents/sw-storefront-developer.toml +96 -0
- package/codex/agents/sw-tender-editor.toml +50 -0
- package/content/AGENTS.md +63 -0
- package/content/claude-settings.json +31 -0
- package/content/mcp-servers.json +18 -0
- package/copilot/agents/sw-admin-frontend-developer.agent.md +53 -0
- package/copilot/agents/sw-php-backend-developer.agent.md +64 -0
- package/copilot/agents/sw-product-manager.agent.md +65 -0
- package/copilot/agents/sw-qa-engineer.agent.md +160 -0
- package/copilot/agents/sw-shopware-architect.agent.md +80 -0
- package/copilot/agents/sw-storefront-developer.agent.md +94 -0
- package/copilot/agents/sw-tender-editor.agent.md +46 -0
- package/lib/actions.mjs +449 -0
- package/lib/commands.mjs +446 -0
- package/lib/companions.mjs +117 -0
- package/lib/content.mjs +101 -0
- package/lib/fsx.mjs +188 -0
- package/lib/guide.mjs +98 -0
- package/lib/hosts.mjs +283 -0
- package/lib/lock.mjs +158 -0
- package/lib/merge.mjs +338 -0
- package/lib/out.mjs +74 -0
- package/mcp.json +10 -0
- package/package.json +58 -0
- package/plugin.json +19 -0
- package/skills/sw-design-requirements/SKILL.md +102 -0
- package/skills/sw-design-requirements/reference/clarification-loop.md +20 -0
- package/skills/sw-design-requirements/reference/confidence-rubric.md +82 -0
- package/skills/sw-design-requirements/reference/consistency-probes.md +65 -0
- package/skills/sw-design-requirements/reference/editor-gate.md +15 -0
- package/skills/sw-design-requirements/reference/editor-mode.md +15 -0
- package/skills/sw-design-requirements/reference/prd-confidence-checklist.md +95 -0
- package/skills/sw-design-requirements/reference/prd-template.md +153 -0
- package/skills/sw-design-requirements/reference/readiness-and-report.md +21 -0
- package/skills/sw-design-requirements/reference/splitting-guidelines.md +69 -0
- package/skills/sw-design-solution/SKILL.md +48 -0
- package/skills/sw-design-solution/reference/adr-flow.md +13 -0
- package/skills/sw-design-solution/reference/adr-template.md +40 -0
- package/skills/sw-design-solution/reference/clarification-loop.md +13 -0
- package/skills/sw-design-solution/reference/draft-and-spawn.md +21 -0
- package/skills/sw-design-solution/reference/editor-mode.md +16 -0
- package/skills/sw-design-solution/reference/pre-check-and-target.md +19 -0
- package/skills/sw-design-solution/reference/readiness-and-report.md +15 -0
- package/skills/sw-design-solution/reference/research-and-evidence.md +25 -0
- package/skills/sw-design-solution/reference/tech-spec-template.md +112 -0
- package/skills/sw-design-solution/reference/toolchain-conformance.md +9 -0
- package/skills/sw-discover-tender/SKILL.md +68 -0
- package/skills/sw-discover-tender/reference/agent-briefs.md +135 -0
- package/skills/sw-discover-tender/reference/analysis-template.md +224 -0
- package/skills/sw-discover-tender/reference/assumption-catalogue.md +104 -0
- package/skills/sw-discover-tender/reference/client-question-rules.md +47 -0
- package/skills/sw-discover-tender/reference/confidence-rubric.md +80 -0
- package/skills/sw-discover-tender/reference/context-parameters.md +116 -0
- package/skills/sw-discover-tender/reference/coverage-mapping.md +62 -0
- package/skills/sw-discover-tender/reference/editor-mode.md +11 -0
- package/skills/sw-discover-tender/reference/editor-session.md +115 -0
- package/skills/sw-discover-tender/reference/estimation-model.md +106 -0
- package/skills/sw-discover-tender/reference/procedure.md +98 -0
- package/skills/sw-discover-tender/reference/response-rules.md +13 -0
- package/skills/sw-document-feature/SKILL.md +64 -0
- package/skills/sw-document-feature/reference/adr-promotion.md +64 -0
- package/skills/sw-document-feature/reference/built-check.md +71 -0
- package/skills/sw-document-feature/reference/domain-guidelines.md +59 -0
- package/skills/sw-document-feature/reference/page-rules.md +129 -0
- package/skills/sw-document-feature/reference/procedure.md +58 -0
- package/skills/sw-document-feature/reference/scaffold/README.md +78 -0
- package/skills/sw-document-feature/reference/scaffold/_config.yml +51 -0
- package/skills/sw-document-feature/reference/scaffold/adr/index.md +44 -0
- package/skills/sw-document-feature/reference/scaffold/baseline/glossary.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/baseline/tech-stack.md +48 -0
- package/skills/sw-document-feature/reference/scaffold/domains/index.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/configuration.md +54 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/customization-guidelines.md +47 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/debugging.md +55 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/environments-and-deployment.md +49 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/extensions-inventory.md +40 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/index.md +39 -0
- package/skills/sw-document-feature/reference/scaffold/domains/platform/logging.md +46 -0
- package/skills/sw-document-feature/reference/scaffold/guidelines/documentation-guidelines.md +102 -0
- package/skills/sw-document-feature/reference/scaffold/guidelines/index.md +50 -0
- package/skills/sw-document-feature/reference/scaffold/index.md +68 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/README.md +24 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/adr/index.md +36 -0
- package/skills/sw-document-feature/reference/scaffold/vanilla/index.md +68 -0
- package/skills/sw-document-feature/reference/setup-wiki.md +142 -0
- package/skills/sw-document-feature/reference/source-mapping.md +71 -0
- package/skills/sw-document-feature/reference/templates/adr.md +26 -0
- package/skills/sw-document-feature/reference/templates/domain-index.md +36 -0
- package/skills/sw-document-feature/reference/templates/feature-page.md +64 -0
- package/skills/sw-document-feature/reference/templates/guideline.md +42 -0
- package/skills/sw-implement-feature/SKILL.md +52 -0
- package/skills/sw-implement-feature/reference/ac-loop.md +24 -0
- package/skills/sw-implement-feature/reference/build-order.md +17 -0
- package/skills/sw-implement-feature/reference/delegation.md +18 -0
- package/skills/sw-setup/SKILL.md +144 -0
- package/skills/sw-setup/reference/rows.md +223 -0
- package/skills/sw-verify-feature/SKILL.md +64 -0
- package/skills/sw-verify-feature/reference/reconcile-and-report.md +42 -0
- package/skills/sw-verify-feature/reference/verification-report-template.md +57 -0
- package/skills/sw-verify-feature-ac-tests/SKILL.md +76 -0
- package/skills/sw-verify-feature-ac-tests/reference/audit-execute-score.md +40 -0
- package/skills/sw-verify-feature-architecture/SKILL.md +66 -0
- package/skills/sw-verify-feature-architecture/reference/guidelines-and-twig.md +27 -0
- package/skills/sw-verify-feature-code-quality/SKILL.md +73 -0
- package/skills/sw-verify-feature-code-quality/reference/gates.md +22 -0
package/lib/commands.mjs
ADDED
|
@@ -0,0 +1,446 @@
|
|
|
1
|
+
// status / plan / apply / uninstall.
|
|
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.
|
|
6
|
+
|
|
7
|
+
import { homedir } from 'node:os';
|
|
8
|
+
import { resolve } from 'node:path';
|
|
9
|
+
import * as out from './out.mjs';
|
|
10
|
+
import {
|
|
11
|
+
CONTENT_VERSION, LOCK_FILE, PKG_NAME, PKG_VERSION, agents, skills,
|
|
12
|
+
} from './content.mjs';
|
|
13
|
+
import { exists, isWritable, abs } from './fsx.mjs';
|
|
14
|
+
import { HOSTS, buildPlan, detectAll, installedCounts } from './hosts.mjs';
|
|
15
|
+
import * as lockfile from './lock.mjs';
|
|
16
|
+
import { dedupe, execute, isWritableState, preview, pruneDirs, revert } from './actions.mjs';
|
|
17
|
+
import { probeAll } from './companions.mjs';
|
|
18
|
+
|
|
19
|
+
const RUN = 'sw-ecosystem-agentic-harness';
|
|
20
|
+
|
|
21
|
+
class UsageError extends Error {
|
|
22
|
+
constructor(message, help) { super(message); this.help = help; }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Parse the flags every command shares. */
|
|
26
|
+
export function buildContext(argv, command) {
|
|
27
|
+
const hosts = [];
|
|
28
|
+
let scope = 'project';
|
|
29
|
+
let root = process.cwd();
|
|
30
|
+
let yes = false;
|
|
31
|
+
let projectWiki;
|
|
32
|
+
let companions = true;
|
|
33
|
+
|
|
34
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
35
|
+
const arg = argv[i];
|
|
36
|
+
if (arg === '--yes' || arg === '-y') { yes = true; continue; }
|
|
37
|
+
if (arg === '--no-companions') { companions = false; continue; }
|
|
38
|
+
if (arg === '--host') {
|
|
39
|
+
const value = argv[++i];
|
|
40
|
+
if (!HOSTS.includes(value)) {
|
|
41
|
+
throw new UsageError(`unknown --host ${value ?? '(missing)'}`,
|
|
42
|
+
HOSTS.map((h) => `${RUN} ${command} --host ${h}`));
|
|
43
|
+
}
|
|
44
|
+
hosts.push(value);
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
if (arg === '--scope') {
|
|
48
|
+
const value = argv[++i];
|
|
49
|
+
if (value !== 'project' && value !== 'user') {
|
|
50
|
+
throw new UsageError(`unknown --scope ${value ?? '(missing)'}`,
|
|
51
|
+
[`${RUN} ${command} --scope project`, `${RUN} ${command} --scope user`]);
|
|
52
|
+
}
|
|
53
|
+
scope = value;
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (arg === '--root') { root = argv[++i] ?? root; continue; }
|
|
57
|
+
if (arg === '--project-wiki') { projectWiki = argv[++i]; continue; }
|
|
58
|
+
throw new UsageError(`unknown option ${arg}`, [`${RUN} guide`, `${RUN} ${command}`]);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// User scope writes under $HOME; project scope stays in the repository.
|
|
62
|
+
if (scope === 'user') root = homedir();
|
|
63
|
+
root = resolve(root);
|
|
64
|
+
if (!exists(root)) throw new UsageError(`--root does not exist: ${root}`, [`${RUN} ${command} --root .`]);
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
root, scope, yes, projectWiki, companions,
|
|
68
|
+
hosts: hosts.length ? [...new Set(hosts)] : [...HOSTS],
|
|
69
|
+
platform: process.platform,
|
|
70
|
+
version: PKG_VERSION,
|
|
71
|
+
lock: null,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function head(ctx, extra = {}) {
|
|
76
|
+
return {
|
|
77
|
+
version: PKG_VERSION, content_version: CONTENT_VERSION,
|
|
78
|
+
scope: ctx.scope, root: ctx.root, ...extra,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function loadLock(ctx) {
|
|
83
|
+
ctx.lock = lockfile.read(ctx.root, ctx.scope);
|
|
84
|
+
return ctx.lock;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Preview every action for the selected hosts, aggregating per-host failures. */
|
|
88
|
+
function previewAll(ctx) {
|
|
89
|
+
const errors = [];
|
|
90
|
+
const actions = [];
|
|
91
|
+
for (const host of ctx.hosts) {
|
|
92
|
+
try {
|
|
93
|
+
const forHost = dedupe(buildPlan({ ...ctx, hosts: [host], selectedHosts: ctx.hosts }));
|
|
94
|
+
for (const action of forHost) {
|
|
95
|
+
try {
|
|
96
|
+
actions.push(preview(action, ctx));
|
|
97
|
+
} catch (e) {
|
|
98
|
+
errors.push({ host, target: action.target, error: e.message });
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch (e) {
|
|
102
|
+
errors.push({ host, error: e.message });
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return { actions: dedupe(actions), errors };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function summarise(actions) {
|
|
109
|
+
const s = { create: 0, update: 0, unchanged: 0, drift: 0, conflict: 0, manual: 0, skipped: 0 };
|
|
110
|
+
for (const a of actions) if (a.state in s) s[a.state] += 1;
|
|
111
|
+
return s;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The `npm i -D <spec>` line(s) for every skipped skill-copy action. */
|
|
115
|
+
function skippedInstalls(actions) {
|
|
116
|
+
return [...new Set(
|
|
117
|
+
actions.filter((a) => a.state === 'skipped' && a.remedy?.startsWith('npm i -D')).map((a) => a.remedy),
|
|
118
|
+
)];
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Strip the fields that are machinery, not result. */
|
|
122
|
+
function publicAction(a) {
|
|
123
|
+
const { doc, fmt, body, value, conflict, newly_declined: nd, absolute, ...rest } = a;
|
|
124
|
+
return rest;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ------------------------------------------------------------------ status
|
|
128
|
+
|
|
129
|
+
export async function status(argv) {
|
|
130
|
+
const ctx = buildContext(argv, 'status');
|
|
131
|
+
let lock = null;
|
|
132
|
+
try { lock = loadLock(ctx); } catch (e) {
|
|
133
|
+
if (e instanceof lockfile.LockVersionError) {
|
|
134
|
+
return out.failure('status', e.message, {
|
|
135
|
+
next_step: `Upgrade the package: npm i -D ${PKG_NAME}@latest`,
|
|
136
|
+
help: [`npm i -D ${PKG_NAME}@latest`, `${RUN} status`],
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
throw e;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const detections = await detectAll(ctx);
|
|
143
|
+
const { actions, errors } = previewAll(ctx);
|
|
144
|
+
const byHost = new Map(detections.map((d) => [d.id, d]));
|
|
145
|
+
|
|
146
|
+
const hosts = detections.map((d) => {
|
|
147
|
+
const mine = actions.filter((a) => a.host === d.id || a.host === 'shared');
|
|
148
|
+
let state = 'absent';
|
|
149
|
+
if (d.detected) {
|
|
150
|
+
const pending = mine.filter((a) => isWritableState(a.state));
|
|
151
|
+
const bad = mine.filter((a) => a.state === 'conflict' || a.state === 'drift');
|
|
152
|
+
if (bad.length) state = bad.some((a) => a.state === 'conflict') ? 'conflict' : 'drift';
|
|
153
|
+
else if (pending.length === 0) state = 'ok';
|
|
154
|
+
else state = lock?.hosts?.[d.id] ? 'drift' : 'not-installed';
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
id: d.id, detected: d.detected, marker: d.marker, writable: d.writable,
|
|
158
|
+
installed: installedCounts(d.id, ctx), state, notes: d.notes,
|
|
159
|
+
};
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
const drift = actions.filter((a) => a.state === 'drift' || a.state === 'conflict').map((a) => ({
|
|
163
|
+
target: a.target, expected: a.hash ?? null, actual: a.prior ?? null,
|
|
164
|
+
reason: a.reason, remedy: a.remedy,
|
|
165
|
+
}));
|
|
166
|
+
|
|
167
|
+
const pending = actions.filter((a) => isWritableState(a.state)).length;
|
|
168
|
+
const worst = ['conflict', 'drift', 'not-installed', 'ok', 'absent']
|
|
169
|
+
.find((s) => hosts.some((h) => h.state === s)) ?? 'absent';
|
|
170
|
+
|
|
171
|
+
const companions = ctx.companions === false ? [] : probeAll({ platform: ctx.platform }).map((p) => ({
|
|
172
|
+
id: p.id, package: p.pkg, version: p.version, label: p.label,
|
|
173
|
+
available: p.available, probe: p.state, reason: p.reason ?? null,
|
|
174
|
+
installed_for: Object.entries(lock?.files ?? {})
|
|
175
|
+
.filter(([target]) => target.endsWith(`/skills/${p.id}/SKILL.md`))
|
|
176
|
+
.map(([, v]) => v.host),
|
|
177
|
+
install: p.available ? null : `npm i -D ${p.spec}`,
|
|
178
|
+
}));
|
|
179
|
+
|
|
180
|
+
let next = pending
|
|
181
|
+
? `Run \`${RUN} plan\` to see the ${pending} change(s) needed, then \`${RUN} apply --yes\`.`
|
|
182
|
+
: `Nothing to do — the harness is installed and up to date. Run \`${RUN} plan\` any time to re-check.`;
|
|
183
|
+
if (ctx.companions !== false) {
|
|
184
|
+
const unavailable = companions.filter((c) => !c.available);
|
|
185
|
+
if (unavailable.length) {
|
|
186
|
+
next += ` ${unavailable.length} optional companion package(s) are not on this machine — install with ${unavailable.map((c) => `\`${c.install}\``).join(', ')} to enable their skills.`;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
return out.ok('status', head(ctx, {
|
|
191
|
+
installed_version: lock?.version ?? null,
|
|
192
|
+
state: worst,
|
|
193
|
+
next_step: next,
|
|
194
|
+
help: [`${RUN} plan`, `${RUN} apply --yes`, `${RUN} guide`],
|
|
195
|
+
inventory: { skills: skills().length, agents: agents().length },
|
|
196
|
+
hosts, companions, drift,
|
|
197
|
+
declined: lock?.declined ?? [],
|
|
198
|
+
manual: lock?.manual ?? [],
|
|
199
|
+
errors,
|
|
200
|
+
}));
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// -------------------------------------------------------------------- plan
|
|
204
|
+
|
|
205
|
+
export async function plan(argv) {
|
|
206
|
+
const ctx = buildContext(argv, 'plan');
|
|
207
|
+
try { loadLock(ctx); } catch (e) {
|
|
208
|
+
if (e instanceof lockfile.LockVersionError) {
|
|
209
|
+
return out.failure('plan', e.message, {
|
|
210
|
+
next_step: `Upgrade the package: npm i -D ${PKG_NAME}@latest`,
|
|
211
|
+
help: [`npm i -D ${PKG_NAME}@latest`],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
throw e;
|
|
215
|
+
}
|
|
216
|
+
const { actions, errors } = previewAll(ctx);
|
|
217
|
+
const summary = summarise(actions);
|
|
218
|
+
const changes = summary.create + summary.update;
|
|
219
|
+
|
|
220
|
+
let next = changes
|
|
221
|
+
? `Nothing was written. Run \`${RUN} apply --yes\` to perform the ${changes} change(s).`
|
|
222
|
+
+ (summary.conflict || summary.manual
|
|
223
|
+
? ` The ${summary.conflict} conflict(s) and ${summary.manual} manual step(s) will be reported again and left to you.`
|
|
224
|
+
: '')
|
|
225
|
+
: `Nothing to do — every target is already up to date.`;
|
|
226
|
+
const installs = skippedInstalls(actions);
|
|
227
|
+
if (installs.length) {
|
|
228
|
+
next += ` ${summary.skipped} action(s) skipped — run ${installs.map((i) => `\`${i}\``).join(', ')} to enable them.`;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return out.ok('plan', head(ctx, {
|
|
232
|
+
summary, next_step: next,
|
|
233
|
+
help: [`${RUN} apply --yes`, ...ctx.hosts.map((h) => `${RUN} apply --yes --host ${h}`)],
|
|
234
|
+
actions: actions.map(publicAction),
|
|
235
|
+
errors,
|
|
236
|
+
}));
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ------------------------------------------------------------------- apply
|
|
240
|
+
|
|
241
|
+
const RESTART_HELP = {
|
|
242
|
+
'claude-code': 'Restart Claude Code, or run `/mcp` in the running session',
|
|
243
|
+
codex: 'Restart `codex` — .codex/config.toml is read once at startup',
|
|
244
|
+
copilot: 'Reload the VS Code window (Developer: Reload Window) for .vscode/mcp.json',
|
|
245
|
+
cursor: 'Restart Cursor so it re-reads .cursor/mcp.json',
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
export async function apply(argv) {
|
|
249
|
+
const ctx = buildContext(argv, 'apply');
|
|
250
|
+
if (!ctx.yes) {
|
|
251
|
+
return out.usage('apply', 'apply refuses to write without --yes',
|
|
252
|
+
[`${RUN} plan`, `${RUN} apply --yes`]);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let lock;
|
|
256
|
+
try { lock = loadLock(ctx) ?? lockfile.empty(ctx.scope); } catch (e) {
|
|
257
|
+
if (e instanceof lockfile.LockVersionError) {
|
|
258
|
+
return out.failure('apply', e.message, {
|
|
259
|
+
next_step: `Upgrade the package: npm i -D ${PKG_NAME}@latest`,
|
|
260
|
+
help: [`npm i -D ${PKG_NAME}@latest`],
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
throw e;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// An apply we cannot record is an apply we cannot undo. Refuse up front
|
|
267
|
+
// rather than leave the user with files and no way back.
|
|
268
|
+
if (!isWritable(abs(ctx.root, LOCK_FILE))) {
|
|
269
|
+
return out.failure('apply', `cannot write the lock file at ${LOCK_FILE}`, {
|
|
270
|
+
next_step: `Make ${ctx.root} writable, then re-run \`${RUN} apply --yes\`.`,
|
|
271
|
+
help: [`${RUN} status`],
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
const { actions, errors } = previewAll(ctx);
|
|
276
|
+
const results = [];
|
|
277
|
+
const perHost = new Map(ctx.hosts.map((h) => [h, { id: h, created: 0, updated: 0, unchanged: 0, failed: 0 }]));
|
|
278
|
+
const manual = [];
|
|
279
|
+
|
|
280
|
+
for (const action of actions) {
|
|
281
|
+
const bucket = perHost.get(action.host) ?? perHost.get(ctx.hosts[0]);
|
|
282
|
+
if (action.state === 'manual') {
|
|
283
|
+
manual.push({ target: action.target, reason: action.reason, remedy: action.remedy });
|
|
284
|
+
results.push({ ...publicAction(action), state: 'manual' });
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
if (!isWritableState(action.state)) {
|
|
288
|
+
if (action.state === 'unchanged' && bucket) bucket.unchanged += 1;
|
|
289
|
+
results.push(publicAction(action));
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
try {
|
|
293
|
+
const entry = execute(action, ctx);
|
|
294
|
+
lock = lockfile.record(lock, [entry], { host: action.host === 'shared' ? undefined : action.host });
|
|
295
|
+
if (bucket) bucket[action.state === 'create' ? 'created' : 'updated'] += 1;
|
|
296
|
+
results.push({ ...publicAction(action), state: action.state === 'create' ? 'created' : 'updated' });
|
|
297
|
+
} catch (e) {
|
|
298
|
+
if (bucket) bucket.failed += 1;
|
|
299
|
+
errors.push({ host: action.host, target: action.target, error: e.message });
|
|
300
|
+
results.push({ ...publicAction(action), state: 'failed', reason: e.message });
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Rules the user deleted after we installed them are declines, not gaps.
|
|
305
|
+
for (const action of actions) {
|
|
306
|
+
for (const value of action.newly_declined ?? []) {
|
|
307
|
+
lock = lockfile.decline(lock, {
|
|
308
|
+
file: action.target, pointer: action.pointer, value,
|
|
309
|
+
reason: 'removed by the user after install',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
for (const entry of manual) {
|
|
314
|
+
lock = lockfile.record(lock, [{ kind: 'manual', target: entry.target, remedy: entry.remedy }]);
|
|
315
|
+
}
|
|
316
|
+
for (const host of ctx.hosts) {
|
|
317
|
+
lock = { ...lock, hosts: { ...lock.hosts, [host]: { installed_at: new Date().toISOString(), version: PKG_VERSION } } };
|
|
318
|
+
}
|
|
319
|
+
lockfile.write(ctx.root, lock);
|
|
320
|
+
|
|
321
|
+
const summary = {
|
|
322
|
+
created: results.filter((r) => r.state === 'created').length,
|
|
323
|
+
updated: results.filter((r) => r.state === 'updated').length,
|
|
324
|
+
unchanged: results.filter((r) => r.state === 'unchanged').length,
|
|
325
|
+
conflict: results.filter((r) => r.state === 'conflict').length,
|
|
326
|
+
drift: results.filter((r) => r.state === 'drift').length,
|
|
327
|
+
manual: manual.length,
|
|
328
|
+
skipped: results.filter((r) => r.state === 'skipped').length,
|
|
329
|
+
failed: results.filter((r) => r.state === 'failed').length,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const help = ctx.hosts.map((h) => RESTART_HELP[h]).filter(Boolean);
|
|
333
|
+
help.push(`${RUN} status`);
|
|
334
|
+
|
|
335
|
+
let next = summary.failed
|
|
336
|
+
? `${summary.failed} change(s) failed — see \`errors\`. Fix those, then re-run \`${RUN} apply --yes\`.`
|
|
337
|
+
: `Restart the hosts listed in \`help\` so they pick up the new skills, agents and MCP servers.`
|
|
338
|
+
+ (manual.length ? ` Then finish the ${manual.length} manual step(s) in \`manual\`.` : '');
|
|
339
|
+
const installs = skippedInstalls(results);
|
|
340
|
+
if (installs.length) {
|
|
341
|
+
next += ` ${summary.skipped} action(s) skipped — run ${installs.map((i) => `\`${i}\``).join(', ')} to enable them.`;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const body = head(ctx, {
|
|
345
|
+
summary, lock_file: LOCK_FILE, next_step: next, help,
|
|
346
|
+
hosts: [...perHost.values()], actions: results, manual, errors,
|
|
347
|
+
});
|
|
348
|
+
return summary.failed ? out.failure('apply', `${summary.failed} change(s) failed`, body) : out.ok('apply', body);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// --------------------------------------------------------------- uninstall
|
|
352
|
+
|
|
353
|
+
export async function uninstall(argv) {
|
|
354
|
+
const ctx = buildContext(argv, 'uninstall');
|
|
355
|
+
if (!ctx.yes) {
|
|
356
|
+
return out.usage('uninstall', 'uninstall refuses to write without --yes',
|
|
357
|
+
[`${RUN} status`, `${RUN} uninstall --yes`]);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
let lock;
|
|
361
|
+
try { lock = loadLock(ctx); } catch (e) {
|
|
362
|
+
if (e instanceof lockfile.LockVersionError) {
|
|
363
|
+
return out.failure('uninstall', e.message, {
|
|
364
|
+
next_step: `Upgrade the package: npm i -D ${PKG_NAME}@latest`,
|
|
365
|
+
help: [`npm i -D ${PKG_NAME}@latest`],
|
|
366
|
+
});
|
|
367
|
+
}
|
|
368
|
+
throw e;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
if (!lock) {
|
|
372
|
+
// No record means no evidence of what we own. Guessing here would delete
|
|
373
|
+
// someone else's files, so it does nothing and says so.
|
|
374
|
+
return out.ok('uninstall', head(ctx, {
|
|
375
|
+
summary: { removed: 0, reverted: 0, kept: 0, missing: 0, failed: 0 },
|
|
376
|
+
next_step: `Nothing to remove — no ${LOCK_FILE} was found, so this repository has no recorded install.`,
|
|
377
|
+
help: [`${RUN} status`],
|
|
378
|
+
kept: [], missing: [], errors: [],
|
|
379
|
+
}));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
const selected = new Set(ctx.hosts);
|
|
383
|
+
const all = lockfile.entries(lock);
|
|
384
|
+
const mine = all.filter((e) => !e.host || selected.has(e.host) || e.host === 'shared');
|
|
385
|
+
|
|
386
|
+
const kept = [];
|
|
387
|
+
const missing = [];
|
|
388
|
+
const errors = [];
|
|
389
|
+
let removed = 0;
|
|
390
|
+
let reverted = 0;
|
|
391
|
+
const survivors = [];
|
|
392
|
+
|
|
393
|
+
for (const entry of mine) {
|
|
394
|
+
try {
|
|
395
|
+
const result = revert(entry, ctx);
|
|
396
|
+
if (result.state === 'removed') removed += 1;
|
|
397
|
+
else if (result.state === 'reverted') reverted += 1;
|
|
398
|
+
else if (result.state === 'kept') { kept.push(result); survivors.push(entry); }
|
|
399
|
+
else missing.push(result);
|
|
400
|
+
} catch (e) {
|
|
401
|
+
errors.push({ target: entry.target, error: e.message });
|
|
402
|
+
survivors.push(entry);
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
const prunedDirs = pruneDirs(lock.dirs ?? [], ctx);
|
|
407
|
+
const failed = errors.length;
|
|
408
|
+
|
|
409
|
+
if (failed === 0 && survivors.length === 0 && selected.size === HOSTS.length) {
|
|
410
|
+
// Everything we recorded is gone; the lock has nothing left to describe.
|
|
411
|
+
const { removeFile } = await import('./fsx.mjs');
|
|
412
|
+
removeFile(lockfile.lockPath(ctx.root));
|
|
413
|
+
pruneDirs(['.sw-ai-sdk'], ctx);
|
|
414
|
+
} else {
|
|
415
|
+
// Keep a lock describing exactly what survived, so a re-run resumes.
|
|
416
|
+
lockfile.write(ctx.root, rebuild(lock, survivors, selected));
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
const body = head(ctx, {
|
|
420
|
+
summary: { removed, reverted, kept: kept.length, missing: missing.length, failed },
|
|
421
|
+
next_step: failed
|
|
422
|
+
? `${failed} item(s) could not be removed — see \`errors\`.`
|
|
423
|
+
: `Restart your hosts.` + (kept.length ? ` ${kept.length} file(s) were left in place because you edited them — they are listed under \`kept\`.` : ''),
|
|
424
|
+
help: [...ctx.hosts.map((h) => RESTART_HELP[h]).filter(Boolean), `${RUN} status`],
|
|
425
|
+
kept, missing, pruned_dirs: prunedDirs, errors,
|
|
426
|
+
});
|
|
427
|
+
return failed ? out.failure('uninstall', `${failed} item(s) could not be removed`, body) : out.ok('uninstall', body);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function rebuild(lock, survivors, selected) {
|
|
431
|
+
const files = {};
|
|
432
|
+
const json_pointers = [];
|
|
433
|
+
const rules = [];
|
|
434
|
+
const fences = [];
|
|
435
|
+
for (const entry of survivors) {
|
|
436
|
+
if (entry.kind === 'file-copy') files[entry.target] = { host: entry.host, source: entry.source, hash: entry.hash, bytes: entry.bytes, created_file: entry.created_file };
|
|
437
|
+
else if (entry.kind === 'json-merge' && entry.op === 'array-union') rules.push(entry);
|
|
438
|
+
else if (entry.kind === 'json-merge') json_pointers.push(entry);
|
|
439
|
+
else fences.push(entry);
|
|
440
|
+
}
|
|
441
|
+
const hosts = { ...lock.hosts };
|
|
442
|
+
for (const host of selected) delete hosts[host];
|
|
443
|
+
return { ...lock, files, json_pointers, rules, fences, hosts };
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export { UsageError };
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// The two optional companion packages, probed rather than installed by us.
|
|
2
|
+
//
|
|
3
|
+
// Read-then-copy, not `install-skill --target`: the engine's whole safety
|
|
4
|
+
// story is "hash what we intend to write, compare, record". A foreign binary
|
|
5
|
+
// that writes on its own cannot be previewed, so `plan` would lie and
|
|
6
|
+
// `uninstall` would have to guess. Reading the body with `--print` and
|
|
7
|
+
// copying it ourselves through `writeAtomic` keeps every companion skill on
|
|
8
|
+
// the same adopt/conflict/drift/update ladder as everything else this CLI
|
|
9
|
+
// writes, and makes `uninstall` exact even after the user has removed the
|
|
10
|
+
// companion package — the common case, since it is optional.
|
|
11
|
+
//
|
|
12
|
+
// `--no-install` plus `npm_config_yes=false` are two independent guards that
|
|
13
|
+
// this can never reach the network: the first tells npx not to fetch a
|
|
14
|
+
// missing package, the second refuses the prompt that would offer to.
|
|
15
|
+
|
|
16
|
+
import { spawnSync } from 'node:child_process';
|
|
17
|
+
import { sha256 } from './fsx.mjs';
|
|
18
|
+
import { wrapCommand } from './content.mjs';
|
|
19
|
+
|
|
20
|
+
export const COMPANIONS = [
|
|
21
|
+
{
|
|
22
|
+
id: 'sw-specs-editor', pkg: '@execuro-sw-ecosystem/sw-specs-editor', version: '0.1.0',
|
|
23
|
+
label: 'Specs Editor',
|
|
24
|
+
},
|
|
25
|
+
{
|
|
26
|
+
id: 'sw-tender-discovery-tool', pkg: '@execuro-sw-ecosystem/sw-tender-discovery-tool', version: '0.1.0',
|
|
27
|
+
label: 'Tender Discovery Tool',
|
|
28
|
+
},
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/** The argv `probe` would spawn, without spawning it — for tests. */
|
|
32
|
+
export function probeArgs(companion, platform) {
|
|
33
|
+
const spec = `${companion.pkg}@${companion.version}`;
|
|
34
|
+
const wrapped = wrapCommand(
|
|
35
|
+
{ command: 'npx', args: ['--no-install', spec, 'install-skill', '--print'] },
|
|
36
|
+
platform,
|
|
37
|
+
);
|
|
38
|
+
return [wrapped.command, ...wrapped.args];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// One probe per `<pkg>@<version>` per process. `status`/`plan`/`apply` each
|
|
42
|
+
// ask this question once per host, and the answer never changes mid-run.
|
|
43
|
+
const cache = new Map();
|
|
44
|
+
|
|
45
|
+
export function resetProbeCache() {
|
|
46
|
+
cache.clear();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function lastLine(text) {
|
|
50
|
+
const lines = text.trim().split('\n').filter(Boolean);
|
|
51
|
+
return lines.length ? lines[lines.length - 1] : '';
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function doProbe(companion, platform) {
|
|
55
|
+
const spec = `${companion.pkg}@${companion.version}`;
|
|
56
|
+
const base = { id: companion.id, pkg: companion.pkg, version: companion.version, spec, label: companion.label };
|
|
57
|
+
const wrapped = wrapCommand(
|
|
58
|
+
{ command: 'npx', args: ['--no-install', spec, 'install-skill', '--print'] },
|
|
59
|
+
platform,
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
const res = spawnSync(wrapped.command, wrapped.args, {
|
|
63
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
64
|
+
timeout: 20000,
|
|
65
|
+
windowsHide: true,
|
|
66
|
+
env: { ...process.env, npm_config_yes: 'false', npm_config_audit: 'false' },
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (res.error) {
|
|
70
|
+
if (res.error.code === 'ENOENT') {
|
|
71
|
+
return { ...base, available: false, state: 'probe-failed', reason: 'npx was not found on PATH' };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
...base, available: false, state: 'probe-failed',
|
|
75
|
+
reason: `probing ${spec} failed: ${res.error.message}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
if (res.signal) {
|
|
79
|
+
return {
|
|
80
|
+
...base, available: false, state: 'probe-failed',
|
|
81
|
+
reason: `probing ${spec} was killed (${res.signal}), possibly a timeout`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
if (res.status !== 0) {
|
|
85
|
+
return {
|
|
86
|
+
...base, available: false, state: 'absent', reason: `${spec} is not installed locally`,
|
|
87
|
+
detail: lastLine((res.stderr ?? Buffer.alloc(0)).toString('utf8')),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
const stdout = res.stdout ?? Buffer.alloc(0);
|
|
91
|
+
if (stdout.length === 0) {
|
|
92
|
+
return {
|
|
93
|
+
...base, available: false, state: 'probe-failed',
|
|
94
|
+
reason: `${spec} install-skill --print returned nothing`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!stdout.toString('utf8').startsWith('---')) {
|
|
98
|
+
return {
|
|
99
|
+
...base, available: false, state: 'probe-failed',
|
|
100
|
+
reason: `${spec} install-skill --print did not return a skill document`,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
return { ...base, available: true, state: 'available', body: stdout, hash: sha256(stdout), bytes: stdout.length };
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Probe one companion. Never throws. */
|
|
107
|
+
export function probe(companion, { platform = process.platform } = {}) {
|
|
108
|
+
const key = `${companion.pkg}@${companion.version}`;
|
|
109
|
+
if (cache.has(key)) return cache.get(key);
|
|
110
|
+
const result = doProbe(companion, platform);
|
|
111
|
+
cache.set(key, result);
|
|
112
|
+
return result;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function probeAll(opts) {
|
|
116
|
+
return COMPANIONS.map((c) => probe(c, opts));
|
|
117
|
+
}
|
package/lib/content.mjs
ADDED
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// What this package ships, and the two things that can only be decided on the
|
|
2
|
+
// user's machine: an absolute path, and `process.platform`.
|
|
3
|
+
//
|
|
4
|
+
// Everything else — the Codex TOMLs, the Copilot adapters, AGENTS.md — is
|
|
5
|
+
// generated at build time by `scripts/gen-*.mjs`, committed and CI-diffed. A
|
|
6
|
+
// bad transform is then a red CI run on our own PR rather than a stranger's
|
|
7
|
+
// broken install.
|
|
8
|
+
|
|
9
|
+
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
10
|
+
import { dirname, join, resolve } from 'node:path';
|
|
11
|
+
import { fileURLToPath } from 'node:url';
|
|
12
|
+
import { walk } from './fsx.mjs';
|
|
13
|
+
|
|
14
|
+
export const PKG_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
|
15
|
+
|
|
16
|
+
const pkg = JSON.parse(readFileSync(join(PKG_ROOT, 'package.json'), 'utf8'));
|
|
17
|
+
export const PKG_NAME = pkg.name;
|
|
18
|
+
export const PKG_VERSION = pkg.version;
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The layout of what we install. Bumped only when an old CLI would write a
|
|
22
|
+
* layout a newer one cannot uninstall; the lock records it and `read()`
|
|
23
|
+
* refuses anything not in SUPPORTED_CONTENT.
|
|
24
|
+
*/
|
|
25
|
+
export const CONTENT_VERSION = 1;
|
|
26
|
+
export const SUPPORTED_CONTENT = [1];
|
|
27
|
+
|
|
28
|
+
export const LOCK_DIR = '.sw-ai-sdk';
|
|
29
|
+
export const LOCK_FILE = `${LOCK_DIR}/harness.lock.json`;
|
|
30
|
+
|
|
31
|
+
function dirsIn(abs) {
|
|
32
|
+
return readdirSync(abs)
|
|
33
|
+
.filter((entry) => statSync(join(abs, entry)).isDirectory())
|
|
34
|
+
.sort();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Every shipped skill, with its file list relative to the skill directory. */
|
|
38
|
+
export function skills() {
|
|
39
|
+
const root = join(PKG_ROOT, 'skills');
|
|
40
|
+
return dirsIn(root).map((name) => ({
|
|
41
|
+
name,
|
|
42
|
+
root: `skills/${name}`,
|
|
43
|
+
files: walk(join(root, name)),
|
|
44
|
+
}));
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Every shipped agent, with the path to each host's adapter. */
|
|
48
|
+
export function agents() {
|
|
49
|
+
const root = join(PKG_ROOT, 'agents');
|
|
50
|
+
return readdirSync(root)
|
|
51
|
+
.filter((f) => f.endsWith('.md'))
|
|
52
|
+
.sort()
|
|
53
|
+
.map((file) => {
|
|
54
|
+
const name = file.replace(/\.md$/, '');
|
|
55
|
+
return {
|
|
56
|
+
name,
|
|
57
|
+
claude: `agents/${name}.md`,
|
|
58
|
+
codex: `codex/agents/${name}.toml`,
|
|
59
|
+
copilot: `copilot/agents/${name}.agent.md`,
|
|
60
|
+
};
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export function agentsMd() {
|
|
65
|
+
return readFileSync(join(PKG_ROOT, 'content', 'AGENTS.md'), 'utf8');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The permission rules and settings keys the installer merges. */
|
|
69
|
+
export function claudeSettings() {
|
|
70
|
+
return JSON.parse(readFileSync(join(PKG_ROOT, 'content', 'claude-settings.json'), 'utf8'));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Wrap a command for the host platform.
|
|
75
|
+
*
|
|
76
|
+
* `npx` on Windows is a `.cmd` shim, and a bare `"command": "npx"` fails with
|
|
77
|
+
* `spawn ENOENT` in every host we target. `platform` is a parameter rather
|
|
78
|
+
* than a read of `process.platform` so the Windows behaviour is testable on
|
|
79
|
+
* Linux — which is the only way it ever actually gets tested.
|
|
80
|
+
*/
|
|
81
|
+
export function wrapCommand({ command, args }, platform) {
|
|
82
|
+
if (platform === 'win32') return { command: 'cmd', args: ['/c', command, ...args] };
|
|
83
|
+
return { command, args: [...args] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The MCP servers to register, fully resolved: no `${VAR}`, because Codex
|
|
88
|
+
* performs no expansion at all and the others disagree about the syntax.
|
|
89
|
+
*/
|
|
90
|
+
export function mcpServers({ platform, root, projectWiki } = {}) {
|
|
91
|
+
const spec = JSON.parse(readFileSync(join(PKG_ROOT, 'content', 'mcp-servers.json'), 'utf8'));
|
|
92
|
+
return spec.servers.map((server) => {
|
|
93
|
+
const args = [...server.args];
|
|
94
|
+
if (server.projectWikiFlag) {
|
|
95
|
+
const wiki = projectWiki ?? join(root ?? process.cwd(), ...server.projectWikiDefault.split('/'));
|
|
96
|
+
args.push(server.projectWikiFlag, wiki);
|
|
97
|
+
}
|
|
98
|
+
const wrapped = wrapCommand({ command: 'npx', args }, platform);
|
|
99
|
+
return { name: server.name, type: 'stdio', ...wrapped };
|
|
100
|
+
});
|
|
101
|
+
}
|