@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/fsx.mjs
ADDED
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
// Filesystem primitives for the installer.
|
|
2
|
+
//
|
|
3
|
+
// Two rules hold everywhere in this file:
|
|
4
|
+
// 1. Every write is atomic — temp file in the *target* directory, then
|
|
5
|
+
// rename, so a crash mid-write can never truncate a user's config.
|
|
6
|
+
// 2. Every path that leaves this module towards output or the lock file is
|
|
7
|
+
// POSIX-separated and relative to the install root, so a lock written on
|
|
8
|
+
// Windows is readable on Linux and vice versa.
|
|
9
|
+
|
|
10
|
+
import { createHash } from 'node:crypto';
|
|
11
|
+
import {
|
|
12
|
+
accessSync, constants, copyFileSync, existsSync, mkdirSync, readFileSync,
|
|
13
|
+
readdirSync, renameSync, rmdirSync, statSync, unlinkSync, writeFileSync,
|
|
14
|
+
} from 'node:fs';
|
|
15
|
+
import { join, posix, relative, resolve, sep, dirname } from 'node:path';
|
|
16
|
+
|
|
17
|
+
export function sha256(data) {
|
|
18
|
+
return `sha256:${createHash('sha256').update(data).digest('hex')}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Read bytes, or null when the file is absent. Never throws on ENOENT. */
|
|
22
|
+
export function readMaybe(abs) {
|
|
23
|
+
try {
|
|
24
|
+
return readFileSync(abs);
|
|
25
|
+
} catch (e) {
|
|
26
|
+
if (e.code === 'ENOENT' || e.code === 'EISDIR') return null;
|
|
27
|
+
throw e;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Read text and remember how it was formatted, so a merge can write it back
|
|
33
|
+
* the way the user had it. A config we reformat is a diff the user did not ask
|
|
34
|
+
* for.
|
|
35
|
+
*/
|
|
36
|
+
export function readTextMaybe(abs) {
|
|
37
|
+
const buf = readMaybe(abs);
|
|
38
|
+
if (buf === null) return null;
|
|
39
|
+
let text = buf.toString('utf8');
|
|
40
|
+
const bom = text.startsWith('');
|
|
41
|
+
if (bom) text = text.slice(1);
|
|
42
|
+
const crlf = /\r\n/.test(text);
|
|
43
|
+
const indentMatch = /\n([ \t]+)\S/.exec(text);
|
|
44
|
+
return {
|
|
45
|
+
text,
|
|
46
|
+
bom,
|
|
47
|
+
eol: crlf ? '\r\n' : '\n',
|
|
48
|
+
indent: indentMatch ? indentMatch[1] : ' ',
|
|
49
|
+
trailingNewline: text.endsWith('\n'),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Restore the EOL style captured on read. */
|
|
54
|
+
export function restoreEol(text, eol) {
|
|
55
|
+
const lf = text.replace(/\r\n/g, '\n');
|
|
56
|
+
return eol === '\r\n' ? lf.replace(/\n/g, '\r\n') : lf;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function ensureDir(abs) {
|
|
60
|
+
mkdirSync(abs, { recursive: true });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Write atomically. The temp file goes in the target's own directory so the
|
|
65
|
+
* rename is same-volume; an EXDEV still falls back to copy+unlink.
|
|
66
|
+
*
|
|
67
|
+
* Windows throws EPERM/EBUSY when an editor or antivirus briefly holds the
|
|
68
|
+
* target, which is common rather than exceptional, so the rename is retried
|
|
69
|
+
* with backoff before the action is given up on.
|
|
70
|
+
*/
|
|
71
|
+
export function writeAtomic(abs, data, { eol = '\n', bom = false } = {}) {
|
|
72
|
+
const dir = dirname(abs);
|
|
73
|
+
ensureDir(dir);
|
|
74
|
+
let payload = typeof data === 'string' ? restoreEol(data, eol) : data;
|
|
75
|
+
if (typeof payload === 'string' && bom) payload = `${payload}`;
|
|
76
|
+
const tmp = join(dir, `.${posix.basename(abs.split(sep).join('/'))}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`);
|
|
77
|
+
writeFileSync(tmp, payload);
|
|
78
|
+
const delays = [0, 20, 40, 80, 160, 320];
|
|
79
|
+
for (let i = 0; i < delays.length; i += 1) {
|
|
80
|
+
try {
|
|
81
|
+
renameSync(tmp, abs);
|
|
82
|
+
return;
|
|
83
|
+
} catch (e) {
|
|
84
|
+
if (e.code === 'EXDEV') {
|
|
85
|
+
copyFileSync(tmp, abs);
|
|
86
|
+
try { unlinkSync(tmp); } catch { /* the copy landed; the temp is noise */ }
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
const retriable = e.code === 'EPERM' || e.code === 'EBUSY' || e.code === 'EACCES';
|
|
90
|
+
if (!retriable || i === delays.length - 1) {
|
|
91
|
+
try { unlinkSync(tmp); } catch { /* best effort */ }
|
|
92
|
+
throw e;
|
|
93
|
+
}
|
|
94
|
+
sleep(delays[i + 1]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Blocking sleep — the retry loop must not be async, callers are sync. */
|
|
100
|
+
function sleep(ms) {
|
|
101
|
+
if (ms <= 0) return;
|
|
102
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function removeFile(abs) {
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(abs);
|
|
108
|
+
return true;
|
|
109
|
+
} catch (e) {
|
|
110
|
+
if (e.code === 'ENOENT') return false;
|
|
111
|
+
throw e;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Remove a directory only when it is empty. Returns whether it went. */
|
|
116
|
+
export function removeDirIfEmpty(abs) {
|
|
117
|
+
try {
|
|
118
|
+
if (readdirSync(abs).length > 0) return false;
|
|
119
|
+
rmdirSync(abs);
|
|
120
|
+
return true;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
if (e.code === 'ENOENT') return false;
|
|
123
|
+
throw e;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** Every file under a root, as sorted POSIX paths relative to it. */
|
|
128
|
+
export function walk(absRoot) {
|
|
129
|
+
const out = [];
|
|
130
|
+
const visit = (dir, prefix) => {
|
|
131
|
+
let entries;
|
|
132
|
+
try {
|
|
133
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
134
|
+
} catch (e) {
|
|
135
|
+
if (e.code === 'ENOENT') return;
|
|
136
|
+
throw e;
|
|
137
|
+
}
|
|
138
|
+
for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
|
|
139
|
+
const child = join(dir, entry.name);
|
|
140
|
+
const rel = prefix ? `${prefix}/${entry.name}` : entry.name;
|
|
141
|
+
if (entry.isDirectory()) visit(child, rel);
|
|
142
|
+
else out.push(rel);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
visit(absRoot, '');
|
|
146
|
+
return out;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Root-relative, POSIX-separated — the form used in output and the lock. */
|
|
150
|
+
export function rel(root, abs) {
|
|
151
|
+
return relative(root, abs).split(sep).join('/');
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** The inverse: a POSIX lock key back to a platform path. */
|
|
155
|
+
export function abs(root, relPosix) {
|
|
156
|
+
return resolve(root, ...relPosix.split('/'));
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function isDir(abs_) {
|
|
160
|
+
try {
|
|
161
|
+
return statSync(abs_).isDirectory();
|
|
162
|
+
} catch {
|
|
163
|
+
return false;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function exists(abs_) {
|
|
168
|
+
return existsSync(abs_);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Whether we could write here — checked on the nearest existing ancestor. */
|
|
172
|
+
export function isWritable(abs_) {
|
|
173
|
+
let probe = abs_;
|
|
174
|
+
for (let i = 0; i < 64; i += 1) {
|
|
175
|
+
if (existsSync(probe)) {
|
|
176
|
+
try {
|
|
177
|
+
accessSync(probe, constants.W_OK);
|
|
178
|
+
return true;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
const parent = dirname(probe);
|
|
184
|
+
if (parent === probe) return false;
|
|
185
|
+
probe = parent;
|
|
186
|
+
}
|
|
187
|
+
return false;
|
|
188
|
+
}
|
package/lib/guide.mjs
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// The install protocol, in one place.
|
|
2
|
+
//
|
|
3
|
+
// The `sw-setup` skill is a stub that sends the agent here rather than
|
|
4
|
+
// restating any of this: an installed copy of a skill goes stale, this does
|
|
5
|
+
// not, because it ships with the CLI that implements it.
|
|
6
|
+
|
|
7
|
+
import * as out from './out.mjs';
|
|
8
|
+
import { PKG_NAME, PKG_VERSION } from './content.mjs';
|
|
9
|
+
|
|
10
|
+
const RUN = `npx -y ${PKG_NAME}@${PKG_VERSION}`;
|
|
11
|
+
|
|
12
|
+
export const GUIDE = `# ${PKG_NAME} — install protocol
|
|
13
|
+
|
|
14
|
+
This CLI installs the Shopware agentic harness — 11 \`sw-*\` skills and 7
|
|
15
|
+
\`sw-*\` sub-agents — into whichever coding agents this repository uses, and
|
|
16
|
+
registers the MCP servers and permission rules they depend on.
|
|
17
|
+
|
|
18
|
+
## The loop
|
|
19
|
+
|
|
20
|
+
1. \`${RUN} status\`
|
|
21
|
+
Read-only. Reports which hosts are present, what is already installed, and
|
|
22
|
+
anything that has drifted since it was installed. Always safe.
|
|
23
|
+
|
|
24
|
+
2. \`${RUN} plan\`
|
|
25
|
+
Prints the exact set of changes and writes nothing. Every entry says what it
|
|
26
|
+
would do and why. Show this to the user before applying.
|
|
27
|
+
|
|
28
|
+
3. \`${RUN} apply --yes\`
|
|
29
|
+
Performs the changes. It refuses without \`--yes\`; there is no prompt,
|
|
30
|
+
because Codex's question tool does not block outside Plan mode and CI has no
|
|
31
|
+
terminal.
|
|
32
|
+
|
|
33
|
+
4. \`${RUN} uninstall --yes\`
|
|
34
|
+
Removes what the lock file records and reverts the managed config blocks.
|
|
35
|
+
|
|
36
|
+
## Flags
|
|
37
|
+
|
|
38
|
+
--host claude-code|codex|copilot|cursor restrict to one host, repeatable
|
|
39
|
+
--scope project|user default: project
|
|
40
|
+
--root <path> default: the current directory
|
|
41
|
+
--no-companions skip the optional companion-package skills
|
|
42
|
+
|
|
43
|
+
## Optional companion packages
|
|
44
|
+
|
|
45
|
+
Two skills — Specs Editor and Tender Discovery Tool — ship inside their own
|
|
46
|
+
npm packages, not this one. \`plan\`/\`apply\` detect them offline
|
|
47
|
+
(\`npx --no-install\`) and never fetch them. A \`skipped\` action means the
|
|
48
|
+
package is not on this machine yet: install it with
|
|
49
|
+
\`npm i -D <package>@<version>\` (the exact command is in the action's
|
|
50
|
+
\`remedy\`, and in \`status\`'s \`companions\` array), then re-run
|
|
51
|
+
\`${RUN} apply --yes\`.
|
|
52
|
+
|
|
53
|
+
## What you can rely on
|
|
54
|
+
|
|
55
|
+
- **stdout is exactly one JSON object.** Every successful result carries a
|
|
56
|
+
\`next_step\` string: the literal command to run next. Errors carry a
|
|
57
|
+
\`help\` array of runnable commands.
|
|
58
|
+
- **Exit codes:** 0 success, 2 usage error (your invocation was wrong),
|
|
59
|
+
1 the work could not be done.
|
|
60
|
+
- **Nothing is silently overwritten.** A file we did not install, or one you
|
|
61
|
+
edited after we installed it, is reported as \`conflict\` or \`drift\` and
|
|
62
|
+
left exactly as it is. Its \`remedy\` field tells the user what to do.
|
|
63
|
+
- **Existing rules are never removed or loosened.** Permission lists are
|
|
64
|
+
merged. A rule you delete after install is recorded as declined and is never
|
|
65
|
+
offered again.
|
|
66
|
+
- **Re-running is free.** A second \`apply\` reports everything as
|
|
67
|
+
\`unchanged\` and does not rewrite a single byte.
|
|
68
|
+
|
|
69
|
+
## The one thing it cannot preserve
|
|
70
|
+
|
|
71
|
+
Rewriting a JSON config re-serialises it, so an array the user had written on
|
|
72
|
+
one line comes back expanded one element per line. Indentation, line endings,
|
|
73
|
+
a BOM and key order are all preserved; inline array style is not, because
|
|
74
|
+
preserving it would mean shipping a JSON parser that keeps a full syntax tree,
|
|
75
|
+
and this CLI has no dependencies by design. Nothing is lost — the value is
|
|
76
|
+
identical — but the diff is larger than the change. A file with comments or
|
|
77
|
+
trailing commas is never rewritten at all; it becomes a manual step instead.
|
|
78
|
+
|
|
79
|
+
## After applying
|
|
80
|
+
|
|
81
|
+
Every host reads its configuration at startup. The result's \`help\` array
|
|
82
|
+
names the restart or reload each one needs — pass it on to the user rather
|
|
83
|
+
than assuming the change is already live.
|
|
84
|
+
|
|
85
|
+
## If you are a skill
|
|
86
|
+
|
|
87
|
+
Call this CLI. Do not re-implement any of it, and do not copy this text into a
|
|
88
|
+
skill file — run \`${RUN} guide\` and follow what it says now.
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
export async function main() {
|
|
92
|
+
return out.ok('guide', {
|
|
93
|
+
version: PKG_VERSION,
|
|
94
|
+
next_step: `${RUN} status`,
|
|
95
|
+
help: [`${RUN} status`, `${RUN} plan`, `${RUN} apply --yes`],
|
|
96
|
+
guide: GUIDE,
|
|
97
|
+
});
|
|
98
|
+
}
|
package/lib/hosts.mjs
ADDED
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
// The host registry. Everything host-specific is declarative and lives here,
|
|
2
|
+
// so adding a host or following a schema change is one table to edit.
|
|
3
|
+
//
|
|
4
|
+
// Detection is marker-directory existence only — no network, no process scan,
|
|
5
|
+
// no `which`. It is allowed to be wrong in the harmless direction: a host that
|
|
6
|
+
// is installed but has no marker directory simply is not written to, and
|
|
7
|
+
// `--host` forces it.
|
|
8
|
+
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { join } from 'node:path';
|
|
11
|
+
import {
|
|
12
|
+
agents, agentsMd, claudeSettings, mcpServers, LOCK_DIR, PKG_ROOT, skills,
|
|
13
|
+
} from './content.mjs';
|
|
14
|
+
import { abs, isDir, isWritable, walk } from './fsx.mjs';
|
|
15
|
+
import { renderTomlTable, tomlTableExists, findFence } from './merge.mjs';
|
|
16
|
+
import { readTextMaybe } from './fsx.mjs';
|
|
17
|
+
import { COMPANIONS } from './companions.mjs';
|
|
18
|
+
|
|
19
|
+
export const HOSTS = ['claude-code', 'codex', 'copilot', 'cursor'];
|
|
20
|
+
|
|
21
|
+
/** Where each host reads its skills from — the base every skill-copy and
|
|
22
|
+
* companion skill-copy action is rooted at. */
|
|
23
|
+
export const SKILL_DIR = {
|
|
24
|
+
'claude-code': '.claude/skills',
|
|
25
|
+
codex: '.agents/skills',
|
|
26
|
+
copilot: '.github/skills',
|
|
27
|
+
cursor: '.cursor/skills',
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const MARKERS = {
|
|
31
|
+
'claude-code': { project: ['.claude'], user: ['.claude'] },
|
|
32
|
+
codex: { project: ['.codex', '.agents'], user: ['.codex'] },
|
|
33
|
+
copilot: { project: ['.github', '.vscode'], user: ['.copilot'] },
|
|
34
|
+
cursor: { project: ['.cursor'], user: ['.cursor'] },
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** Where a host's trees live, given the scope. User scope is rooted at $HOME. */
|
|
38
|
+
export function hostRoot(scope) {
|
|
39
|
+
return scope === 'user' ? homedir() : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Detect every host in parallel. Cheap enough that parallelism is cosmetic,
|
|
44
|
+
* but it matches the documented pattern and keeps a slow stat from serialising.
|
|
45
|
+
*/
|
|
46
|
+
export async function detectAll(ctx) {
|
|
47
|
+
return Promise.all(HOSTS.map((id) => Promise.resolve(detect(id, ctx))));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function detect(id, ctx) {
|
|
51
|
+
const markers = MARKERS[id][ctx.scope] ?? MARKERS[id].project;
|
|
52
|
+
const present = markers.filter((m) => isDir(abs(ctx.root, m)));
|
|
53
|
+
const writable = present.every((m) => isWritable(abs(ctx.root, m)));
|
|
54
|
+
const notes = [];
|
|
55
|
+
if (id === 'codex' && present.length && !writable) {
|
|
56
|
+
notes.push(
|
|
57
|
+
'`.codex/` and `.agents/` are read-only under Codex\'s default workspace-write ' +
|
|
58
|
+
'sandbox. Run this CLI from a plain shell rather than from inside a Codex session.',
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
if (id === 'cursor' && isDir(abs(ctx.root, '.claude/skills')) && isDir(abs(ctx.root, '.claude/agents'))) {
|
|
62
|
+
notes.push('Skills and agents are read from `.claude/` directly — nothing to install under `.cursor/`.');
|
|
63
|
+
}
|
|
64
|
+
return { id, detected: present.length > 0, markers, marker: present[0] ?? markers[0], writable, notes };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// --------------------------------------------------------------- plans
|
|
68
|
+
|
|
69
|
+
/** Build the full action list for the selected hosts. */
|
|
70
|
+
export function buildPlan(ctx) {
|
|
71
|
+
const actions = [];
|
|
72
|
+
for (const id of ctx.hosts) actions.push(...planFor(id, ctx));
|
|
73
|
+
// Shared targets are emitted per host; the id dedupe collapses them.
|
|
74
|
+
return actions;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function planFor(id, ctx) {
|
|
78
|
+
switch (id) {
|
|
79
|
+
case 'claude-code': return planClaude(ctx);
|
|
80
|
+
case 'codex': return planCodex(ctx);
|
|
81
|
+
case 'copilot': return planCopilot(ctx);
|
|
82
|
+
case 'cursor': return planCursor(ctx);
|
|
83
|
+
default: throw new Error(`unknown host: ${id}`);
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function copyTree(host, sourceRoot, targetRoot) {
|
|
88
|
+
const out = [];
|
|
89
|
+
for (const file of walk(join(PKG_ROOT, sourceRoot))) {
|
|
90
|
+
const target = `${targetRoot}/${file}`;
|
|
91
|
+
out.push({
|
|
92
|
+
id: `${host}:file:${target}`, host, kind: 'file-copy',
|
|
93
|
+
source: `${sourceRoot}/${file}`, target,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
return out;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function skillActions(host, targetBase) {
|
|
100
|
+
const out = [];
|
|
101
|
+
for (const skill of skills()) out.push(...copyTree(host, skill.root, `${targetBase}/${skill.name}`));
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One `skill-copy` action per optional companion package. The `skill:`
|
|
107
|
+
* segment (not `file:`) keeps these ids from colliding, or deduping,
|
|
108
|
+
* against a shipped `file-copy` action at the same path.
|
|
109
|
+
*/
|
|
110
|
+
function companionActions(host, targetBase) {
|
|
111
|
+
return COMPANIONS.map((c) => {
|
|
112
|
+
const target = `${targetBase}/${c.id}/SKILL.md`;
|
|
113
|
+
return {
|
|
114
|
+
id: `${host}:skill:${target}`, host, kind: 'skill-copy',
|
|
115
|
+
companion: c.id, package: `${c.pkg}@${c.version}`, target,
|
|
116
|
+
};
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function agentActions(host, targetBase, pick, ext) {
|
|
121
|
+
return agents().map((agent) => {
|
|
122
|
+
const target = `${targetBase}/${agent.name}${ext}`;
|
|
123
|
+
return {
|
|
124
|
+
id: `${host}:file:${target}`, host, kind: 'file-copy',
|
|
125
|
+
source: agent[pick], target,
|
|
126
|
+
};
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function mcpJsonActions(host, file, rootKey, ctx) {
|
|
131
|
+
return mcpServers({ platform: ctx.platform, root: ctx.root, projectWiki: ctx.projectWiki }).map((server) => {
|
|
132
|
+
const { name, ...value } = server;
|
|
133
|
+
const pointer = `/${rootKey}/${name}`;
|
|
134
|
+
return {
|
|
135
|
+
id: `shared:json:${file}#${pointer}`, host, kind: 'json-merge',
|
|
136
|
+
target: file, pointer, op: 'set', value,
|
|
137
|
+
};
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/** AGENTS.md — read by Codex, Copilot and Cursor; Claude Code reads CLAUDE.md. */
|
|
142
|
+
function agentsMdAction(host) {
|
|
143
|
+
return {
|
|
144
|
+
id: 'shared:text:AGENTS.md#harness', host, kind: 'text-fence',
|
|
145
|
+
target: 'AGENTS.md', block: 'harness', syntax: 'markdown', body: agentsMd().trimEnd(),
|
|
146
|
+
};
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** The lock file describes one machine's install, so it is gitignored. */
|
|
150
|
+
function gitignoreAction(host) {
|
|
151
|
+
return {
|
|
152
|
+
id: 'shared:text:.gitignore#lock', host, kind: 'text-fence',
|
|
153
|
+
target: '.gitignore', block: 'lock', syntax: 'gitignore', body: `${LOCK_DIR}/`,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function planClaude(ctx) {
|
|
158
|
+
const host = 'claude-code';
|
|
159
|
+
const out = [
|
|
160
|
+
...skillActions(host, SKILL_DIR[host]),
|
|
161
|
+
...(ctx.companions === false ? [] : companionActions(host, SKILL_DIR[host])),
|
|
162
|
+
...agentActions(host, '.claude/agents', 'claude', '.md'),
|
|
163
|
+
...mcpJsonActions(host, '.mcp.json', 'mcpServers', ctx),
|
|
164
|
+
];
|
|
165
|
+
const settings = claudeSettings();
|
|
166
|
+
for (const rule of settings.rules) {
|
|
167
|
+
out.push({
|
|
168
|
+
id: `${host}:json:.claude/settings.json#${rule.pointer}`, host, kind: 'json-merge',
|
|
169
|
+
target: '.claude/settings.json', pointer: rule.pointer, op: 'array-union', values: rule.values,
|
|
170
|
+
});
|
|
171
|
+
}
|
|
172
|
+
for (const setting of settings.settings) {
|
|
173
|
+
out.push({
|
|
174
|
+
id: `${host}:json:.claude/settings.json#${setting.pointer}`, host, kind: 'json-merge',
|
|
175
|
+
target: '.claude/settings.json', pointer: setting.pointer, op: 'set', value: setting.value,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
out.push(gitignoreAction(host));
|
|
179
|
+
return out;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function planCodex(ctx) {
|
|
183
|
+
const host = 'codex';
|
|
184
|
+
const out = [
|
|
185
|
+
// Codex discovers skills from `.agents/skills`, never `.claude/skills`.
|
|
186
|
+
...skillActions(host, SKILL_DIR[host]),
|
|
187
|
+
...(ctx.companions === false ? [] : companionActions(host, SKILL_DIR[host])),
|
|
188
|
+
...agentActions(host, '.codex/agents', 'codex', '.toml'),
|
|
189
|
+
];
|
|
190
|
+
|
|
191
|
+
const file = '.codex/config.toml';
|
|
192
|
+
const found = readTextMaybe(abs(ctx.root, file));
|
|
193
|
+
const text = found?.text ?? '';
|
|
194
|
+
|
|
195
|
+
// Codex is TOML-only and never reads `.mcp.json`, and performs no `${VAR}`
|
|
196
|
+
// expansion — so paths are resolved here, at install time.
|
|
197
|
+
const servers = mcpServers({ platform: ctx.platform, root: ctx.root, projectWiki: ctx.projectWiki });
|
|
198
|
+
const clash = servers.find((s) => tomlTableExists(text, `[mcp_servers.${s.name}]`, ['mcp_servers', 'agents']));
|
|
199
|
+
out.push({
|
|
200
|
+
id: `${host}:toml:${file}#mcp_servers`, host, kind: 'toml-fence',
|
|
201
|
+
target: file, block: 'mcp_servers', syntax: 'toml',
|
|
202
|
+
body: servers.map((s) => renderTomlTable(`[mcp_servers.${s.name}]`, {
|
|
203
|
+
command: s.command, args: s.args, type: undefined,
|
|
204
|
+
})).join('\n\n'),
|
|
205
|
+
conflict: clash && {
|
|
206
|
+
reason: `a \`[mcp_servers.${clash.name}]\` table already exists outside our managed block, and TOML forbids a duplicate table header`,
|
|
207
|
+
remedy: `remove or rename the existing [mcp_servers.${clash.name}] table in ${file}, then re-run apply`,
|
|
208
|
+
},
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
// `[agents]` may already be the user's own table. Merging would mean editing
|
|
212
|
+
// their lines, which this installer does not do — so it becomes a manual step.
|
|
213
|
+
const agentsClash = tomlTableExists(text, '[agents]', ['mcp_servers', 'agents']);
|
|
214
|
+
out.push({
|
|
215
|
+
id: `${host}:toml:${file}#agents`, host, kind: 'toml-fence',
|
|
216
|
+
target: file, block: 'agents', syntax: 'toml',
|
|
217
|
+
body: renderTomlTable('[agents]', { max_concurrent_threads_per_session: 4 }),
|
|
218
|
+
conflict: agentsClash && {
|
|
219
|
+
reason: `an unfenced [agents] table already exists in ${file}; TOML forbids a duplicate table header and the installer never edits your lines`,
|
|
220
|
+
remedy: `add \`max_concurrent_threads_per_session = 4\` to the existing [agents] table in ${file}`,
|
|
221
|
+
},
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
out.push(agentsMdAction(host), gitignoreAction(host));
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function planCopilot(ctx) {
|
|
229
|
+
const host = 'copilot';
|
|
230
|
+
const out = [
|
|
231
|
+
...skillActions(host, SKILL_DIR[host]),
|
|
232
|
+
...(ctx.companions === false ? [] : companionActions(host, SKILL_DIR[host])),
|
|
233
|
+
...agentActions(host, '.github/agents', 'copilot', '.agent.md'),
|
|
234
|
+
// Copilot CLI reads `.mcp.json`; VS Code reads `.vscode/mcp.json` under the
|
|
235
|
+
// incompatible `servers` key. Both, or the two surfaces disagree.
|
|
236
|
+
...mcpJsonActions(host, '.mcp.json', 'mcpServers', ctx),
|
|
237
|
+
...mcpJsonActions(host, '.vscode/mcp.json', 'servers', ctx).map((a) => ({
|
|
238
|
+
...a, id: a.id.replace('shared:', 'copilot:'),
|
|
239
|
+
})),
|
|
240
|
+
agentsMdAction(host),
|
|
241
|
+
gitignoreAction(host),
|
|
242
|
+
];
|
|
243
|
+
return out;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function planCursor(ctx) {
|
|
247
|
+
const host = 'cursor';
|
|
248
|
+
const out = [];
|
|
249
|
+
// Cursor reads `.claude/skills/` and `.claude/agents/` directly. If Claude
|
|
250
|
+
// Code is also being installed, or already is, there is nothing to duplicate.
|
|
251
|
+
// `ctx.hosts` is narrowed to one host while previewing, so the question
|
|
252
|
+
// "is Claude Code also being installed?" must be asked of the user's actual
|
|
253
|
+
// selection, which `selectedHosts` carries unchanged.
|
|
254
|
+
const selected = ctx.selectedHosts ?? ctx.hosts;
|
|
255
|
+
const claudeTrees = selected.includes('claude-code')
|
|
256
|
+
|| (isDir(abs(ctx.root, '.claude/skills')) && isDir(abs(ctx.root, '.claude/agents')));
|
|
257
|
+
if (!claudeTrees) {
|
|
258
|
+
out.push(...skillActions(host, SKILL_DIR[host]));
|
|
259
|
+
if (ctx.companions !== false) out.push(...companionActions(host, SKILL_DIR[host]));
|
|
260
|
+
out.push(...agentActions(host, '.cursor/agents', 'claude', '.md'));
|
|
261
|
+
}
|
|
262
|
+
out.push(...mcpJsonActions(host, '.cursor/mcp.json', 'mcpServers', ctx));
|
|
263
|
+
out.push(agentsMdAction(host), gitignoreAction(host));
|
|
264
|
+
return out;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** What is already installed for a host, for `status`. */
|
|
268
|
+
export function installedCounts(id, ctx) {
|
|
269
|
+
const lock = ctx.lock;
|
|
270
|
+
if (!lock) return {};
|
|
271
|
+
const mine = Object.entries(lock.files).filter(([, v]) => v.host === id);
|
|
272
|
+
const skillNames = new Set();
|
|
273
|
+
let agentCount = 0;
|
|
274
|
+
for (const [target] of mine) {
|
|
275
|
+
if (/\/skills\//.test(target)) skillNames.add(target.split('/skills/')[1].split('/')[0]);
|
|
276
|
+
else if (/\/agents\//.test(target)) agentCount += 1;
|
|
277
|
+
}
|
|
278
|
+
const mcp = lock.json_pointers.filter((p) => p.pointer.includes('erver') || p.pointer.startsWith('/mcpServers')).length;
|
|
279
|
+
const rules = lock.rules.reduce((n, r) => n + (r.values?.length ?? 0), 0);
|
|
280
|
+
return { skills: skillNames.size, agents: agentCount, mcp, rules };
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export { findFence };
|