@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/hosts.mjs
CHANGED
|
@@ -4,23 +4,23 @@
|
|
|
4
4
|
// Detection is marker-directory existence only — no network, no process scan,
|
|
5
5
|
// no `which`. It feeds `status` reporting only; it never selects which hosts
|
|
6
6
|
// a write command touches. Which hosts get written to is decided by
|
|
7
|
-
// commands.mjs's `
|
|
7
|
+
// commands.mjs's `resolveAgents()` chain (`--agent`, then a recorded lock,
|
|
8
8
|
// then a TTY prompt, then a usage error) — detection has no vote there.
|
|
9
9
|
|
|
10
10
|
import { homedir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import {
|
|
13
|
-
agents, agentsMd, claudeSettings,
|
|
13
|
+
agents, agentsMd, claudeSettings, lockDir, lockNeedsGitignore, mcpServers, PKG_ROOT, skills,
|
|
14
14
|
} from './content.mjs';
|
|
15
15
|
import { abs, isDir, isWritable, walk } from './fsx.mjs';
|
|
16
16
|
import { renderTomlTable, tomlTableExists, findFence } from './merge.mjs';
|
|
17
17
|
import { readTextMaybe } from './fsx.mjs';
|
|
18
|
-
import { EXTRA_COMPONENTS } from './extra-components.mjs';
|
|
19
18
|
|
|
20
|
-
export const
|
|
19
|
+
export const AGENTS = ['claude-code', 'codex', 'copilot', 'cursor'];
|
|
21
20
|
|
|
22
|
-
/** Where each host reads its skills from — the base every skill
|
|
23
|
-
*
|
|
21
|
+
/** Where each host reads its skills from — the base every skill copy is
|
|
22
|
+
* rooted at, and the directory `sw-setup` hands to an editor package's own
|
|
23
|
+
* `install-skill --target`. */
|
|
24
24
|
export const SKILL_DIR = {
|
|
25
25
|
'claude-code': '.claude/skills',
|
|
26
26
|
codex: '.agents/skills',
|
|
@@ -45,7 +45,7 @@ export function hostRoot(scope) {
|
|
|
45
45
|
* but it matches the documented pattern and keeps a slow stat from serialising.
|
|
46
46
|
*/
|
|
47
47
|
export async function detectAll(ctx) {
|
|
48
|
-
return Promise.all(
|
|
48
|
+
return Promise.all(AGENTS.map((id) => Promise.resolve(detect(id, ctx))));
|
|
49
49
|
}
|
|
50
50
|
|
|
51
51
|
export function detect(id, ctx) {
|
|
@@ -67,10 +67,10 @@ export function detect(id, ctx) {
|
|
|
67
67
|
|
|
68
68
|
// --------------------------------------------------------------- plans
|
|
69
69
|
|
|
70
|
-
/** Build the full action list for the selected
|
|
70
|
+
/** Build the full action list for the selected agents. */
|
|
71
71
|
export function buildPlan(ctx) {
|
|
72
72
|
const actions = [];
|
|
73
|
-
for (const id of ctx.
|
|
73
|
+
for (const id of ctx.agents) actions.push(...planFor(id, ctx));
|
|
74
74
|
// Shared targets are emitted per host; the id dedupe collapses them.
|
|
75
75
|
return actions;
|
|
76
76
|
}
|
|
@@ -103,21 +103,6 @@ function skillActions(host, targetBase) {
|
|
|
103
103
|
return out;
|
|
104
104
|
}
|
|
105
105
|
|
|
106
|
-
/**
|
|
107
|
-
* One `skill-copy` action per optional extra component. The `skill:`
|
|
108
|
-
* segment (not `file:`) keeps these ids from colliding, or deduping,
|
|
109
|
-
* against a shipped `file-copy` action at the same path.
|
|
110
|
-
*/
|
|
111
|
-
function extraComponentActions(host, targetBase) {
|
|
112
|
-
return EXTRA_COMPONENTS.map((c) => {
|
|
113
|
-
const target = `${targetBase}/${c.id}/SKILL.md`;
|
|
114
|
-
return {
|
|
115
|
-
id: `${host}:skill:${target}`, host, kind: 'skill-copy',
|
|
116
|
-
component: c.id, package: `${c.pkg}@${c.version}`, target,
|
|
117
|
-
};
|
|
118
|
-
});
|
|
119
|
-
}
|
|
120
|
-
|
|
121
106
|
function agentActions(host, targetBase, pick, ext) {
|
|
122
107
|
return agents().map((agent) => {
|
|
123
108
|
const target = `${targetBase}/${agent.name}${ext}`;
|
|
@@ -147,11 +132,17 @@ function agentsMdAction(host) {
|
|
|
147
132
|
};
|
|
148
133
|
}
|
|
149
134
|
|
|
150
|
-
/**
|
|
151
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The lock describes one machine's install, so it must not be tracked. Under
|
|
137
|
+
* `var/` the `shopware/core` Flex recipe already ignores it (`/var/*`), so
|
|
138
|
+
* there is nothing to write and we do not touch the user's `.gitignore` at
|
|
139
|
+
* all. Only the fallback location needs the managed line.
|
|
140
|
+
*/
|
|
141
|
+
function gitignoreAction(host, ctx) {
|
|
142
|
+
if (!lockNeedsGitignore(ctx.root)) return null;
|
|
152
143
|
return {
|
|
153
144
|
id: 'shared:text:.gitignore#lock', host, kind: 'text-fence',
|
|
154
|
-
target: '.gitignore', block: 'lock', syntax: 'gitignore', body: `${
|
|
145
|
+
target: '.gitignore', block: 'lock', syntax: 'gitignore', body: `${lockDir(ctx.root)}/`,
|
|
155
146
|
};
|
|
156
147
|
}
|
|
157
148
|
|
|
@@ -159,7 +150,6 @@ function planClaude(ctx) {
|
|
|
159
150
|
const host = 'claude-code';
|
|
160
151
|
const out = [
|
|
161
152
|
...skillActions(host, SKILL_DIR[host]),
|
|
162
|
-
...(ctx.extraComponents === false ? [] : extraComponentActions(host, SKILL_DIR[host])),
|
|
163
153
|
...agentActions(host, '.claude/agents', 'claude', '.md'),
|
|
164
154
|
...mcpJsonActions(host, '.mcp.json', 'mcpServers', ctx),
|
|
165
155
|
];
|
|
@@ -176,8 +166,8 @@ function planClaude(ctx) {
|
|
|
176
166
|
target: '.claude/settings.json', pointer: setting.pointer, op: 'set', value: setting.value,
|
|
177
167
|
});
|
|
178
168
|
}
|
|
179
|
-
out.push(gitignoreAction(host));
|
|
180
|
-
return out;
|
|
169
|
+
out.push(gitignoreAction(host, ctx));
|
|
170
|
+
return out.filter(Boolean);
|
|
181
171
|
}
|
|
182
172
|
|
|
183
173
|
function planCodex(ctx) {
|
|
@@ -185,7 +175,6 @@ function planCodex(ctx) {
|
|
|
185
175
|
const out = [
|
|
186
176
|
// Codex discovers skills from `.agents/skills`, never `.claude/skills`.
|
|
187
177
|
...skillActions(host, SKILL_DIR[host]),
|
|
188
|
-
...(ctx.extraComponents === false ? [] : extraComponentActions(host, SKILL_DIR[host])),
|
|
189
178
|
...agentActions(host, '.codex/agents', 'codex', '.toml'),
|
|
190
179
|
];
|
|
191
180
|
|
|
@@ -222,15 +211,14 @@ function planCodex(ctx) {
|
|
|
222
211
|
},
|
|
223
212
|
});
|
|
224
213
|
|
|
225
|
-
out.push(agentsMdAction(host), gitignoreAction(host));
|
|
226
|
-
return out;
|
|
214
|
+
out.push(agentsMdAction(host), gitignoreAction(host, ctx));
|
|
215
|
+
return out.filter(Boolean);
|
|
227
216
|
}
|
|
228
217
|
|
|
229
218
|
function planCopilot(ctx) {
|
|
230
219
|
const host = 'copilot';
|
|
231
220
|
const out = [
|
|
232
221
|
...skillActions(host, SKILL_DIR[host]),
|
|
233
|
-
...(ctx.extraComponents === false ? [] : extraComponentActions(host, SKILL_DIR[host])),
|
|
234
222
|
...agentActions(host, '.github/agents', 'copilot', '.agent.md'),
|
|
235
223
|
// Copilot CLI reads `.mcp.json`; VS Code reads `.vscode/mcp.json` under the
|
|
236
224
|
// incompatible `servers` key. Both, or the two surfaces disagree.
|
|
@@ -239,9 +227,9 @@ function planCopilot(ctx) {
|
|
|
239
227
|
...a, id: a.id.replace('shared:', 'copilot:'),
|
|
240
228
|
})),
|
|
241
229
|
agentsMdAction(host),
|
|
242
|
-
gitignoreAction(host),
|
|
230
|
+
gitignoreAction(host, ctx),
|
|
243
231
|
];
|
|
244
|
-
return out;
|
|
232
|
+
return out.filter(Boolean);
|
|
245
233
|
}
|
|
246
234
|
|
|
247
235
|
function planCursor(ctx) {
|
|
@@ -249,20 +237,34 @@ function planCursor(ctx) {
|
|
|
249
237
|
const out = [];
|
|
250
238
|
// Cursor reads `.claude/skills/` and `.claude/agents/` directly. If Claude
|
|
251
239
|
// Code is also being installed, or already is, there is nothing to duplicate.
|
|
252
|
-
// `ctx.
|
|
240
|
+
// `ctx.agents` is narrowed to one agent while previewing, so the question
|
|
253
241
|
// "is Claude Code also being installed?" must be asked of the user's actual
|
|
254
|
-
// selection, which `
|
|
255
|
-
const selected = ctx.
|
|
242
|
+
// selection, which `selectedAgents` carries unchanged.
|
|
243
|
+
const selected = ctx.selectedAgents ?? ctx.agents;
|
|
256
244
|
const claudeTrees = selected.includes('claude-code')
|
|
257
245
|
|| (isDir(abs(ctx.root, '.claude/skills')) && isDir(abs(ctx.root, '.claude/agents')));
|
|
258
246
|
if (!claudeTrees) {
|
|
259
247
|
out.push(...skillActions(host, SKILL_DIR[host]));
|
|
260
|
-
if (ctx.extraComponents !== false) out.push(...extraComponentActions(host, SKILL_DIR[host]));
|
|
261
248
|
out.push(...agentActions(host, '.cursor/agents', 'claude', '.md'));
|
|
262
249
|
}
|
|
263
250
|
out.push(...mcpJsonActions(host, '.cursor/mcp.json', 'mcpServers', ctx));
|
|
264
|
-
out.push(agentsMdAction(host), gitignoreAction(host));
|
|
265
|
-
return out;
|
|
251
|
+
out.push(agentsMdAction(host), gitignoreAction(host, ctx));
|
|
252
|
+
return out.filter(Boolean);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* The skills directory a host actually reads, for `status` to report — the
|
|
257
|
+
* one `sw-setup` passes to an editor package's `install-skill --target`.
|
|
258
|
+
* Cursor is the exception: when the Claude trees are present it reads those,
|
|
259
|
+
* so naming `.cursor/skills` would put an editor skill where Cursor never
|
|
260
|
+
* looks.
|
|
261
|
+
*/
|
|
262
|
+
export function skillsDir(id, ctx) {
|
|
263
|
+
if (id === 'cursor'
|
|
264
|
+
&& isDir(abs(ctx.root, '.claude/skills')) && isDir(abs(ctx.root, '.claude/agents'))) {
|
|
265
|
+
return SKILL_DIR['claude-code'];
|
|
266
|
+
}
|
|
267
|
+
return SKILL_DIR[id];
|
|
266
268
|
}
|
|
267
269
|
|
|
268
270
|
/** What is already installed for a host, for `status`. */
|
package/lib/lock.mjs
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
|
-
// The lock file:
|
|
1
|
+
// The lock file: `var/sw-ai-sdk/harness.lock.json` in the host repository, or
|
|
2
|
+
// `.sw-ai-sdk/harness.lock.json` in a root that has no `var/` (see
|
|
3
|
+
// `lockDir()` in content.mjs).
|
|
2
4
|
//
|
|
3
5
|
// This is the ownership record. JSON config files have no comments, so nothing
|
|
4
6
|
// in `.mcp.json` can tell us who wrote an entry — the lock does, by content
|
|
@@ -6,15 +8,29 @@
|
|
|
6
8
|
// removal is always narrower than addition, and which rules the user declined
|
|
7
9
|
// so we never ask twice.
|
|
8
10
|
//
|
|
9
|
-
// It is
|
|
10
|
-
//
|
|
11
|
+
// It is never tracked: it describes one machine's install, not the project.
|
|
12
|
+
// Under `var/` the Shopware recipe already ignores it; in the fallback
|
|
13
|
+
// location the installer writes the managed `.gitignore` line itself.
|
|
14
|
+
//
|
|
15
|
+
// A lock left at the old `.sw-ai-sdk/` path is read and rewritten to the new
|
|
16
|
+
// one on the next write, so an existing install never looks fresh.
|
|
11
17
|
|
|
12
|
-
import {
|
|
13
|
-
|
|
18
|
+
import {
|
|
19
|
+
LOCK_FILE, SUPPORTED_CONTENT, CONTENT_VERSION, PKG_NAME, PKG_VERSION, lockFile,
|
|
20
|
+
} from './content.mjs';
|
|
21
|
+
import {
|
|
22
|
+
abs, ensureDir, exists, readTextMaybe, removeDirIfEmpty, removeFile, writeAtomic,
|
|
23
|
+
} from './fsx.mjs';
|
|
24
|
+
import { dirname } from 'node:path';
|
|
14
25
|
|
|
15
26
|
export class LockVersionError extends Error {}
|
|
16
27
|
|
|
17
28
|
export function lockPath(root) {
|
|
29
|
+
return abs(root, lockFile(root));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The pre-`var/` location, kept only so an existing install can be migrated. */
|
|
33
|
+
export function legacyLockPath(root) {
|
|
18
34
|
return abs(root, LOCK_FILE);
|
|
19
35
|
}
|
|
20
36
|
|
|
@@ -28,7 +44,7 @@ export function empty(scope) {
|
|
|
28
44
|
installed_at: null,
|
|
29
45
|
updated_at: null,
|
|
30
46
|
platform: process.platform,
|
|
31
|
-
|
|
47
|
+
agents: {},
|
|
32
48
|
files: {},
|
|
33
49
|
dirs: [],
|
|
34
50
|
json_pointers: [],
|
|
@@ -41,34 +57,55 @@ export function empty(scope) {
|
|
|
41
57
|
|
|
42
58
|
/** Read the lock, or null when absent. Throws on a layout we cannot undo. */
|
|
43
59
|
export function read(root, scope) {
|
|
44
|
-
|
|
60
|
+
// The new location first; a lock still at the old one is read here and
|
|
61
|
+
// rewritten by `write()`.
|
|
62
|
+
let where = lockFile(root);
|
|
63
|
+
let found = readTextMaybe(lockPath(root));
|
|
64
|
+
if (found === null) {
|
|
65
|
+
found = readTextMaybe(legacyLockPath(root));
|
|
66
|
+
where = LOCK_FILE;
|
|
67
|
+
}
|
|
45
68
|
if (found === null) return null;
|
|
46
69
|
let lock;
|
|
47
70
|
try {
|
|
48
71
|
lock = JSON.parse(found.text);
|
|
49
72
|
} catch {
|
|
50
73
|
throw new LockVersionError(
|
|
51
|
-
`${
|
|
74
|
+
`${where} is not valid JSON. Move it aside and re-run apply to rebuild it.`,
|
|
52
75
|
);
|
|
53
76
|
}
|
|
54
77
|
if (!SUPPORTED_CONTENT.includes(lock.content_version)) {
|
|
55
78
|
throw new LockVersionError(
|
|
56
|
-
`${
|
|
79
|
+
`${where} records content_version ${lock.content_version}, which this ` +
|
|
57
80
|
`version of ${PKG_NAME} (${PKG_VERSION}) does not understand.`,
|
|
58
81
|
);
|
|
59
82
|
}
|
|
60
|
-
|
|
83
|
+
// Locks written before the `--host` -> `--agent` rename record the
|
|
84
|
+
// selection under `hosts`. Read either, write `agents` — missing this
|
|
85
|
+
// would make every existing install look like it had never chosen an
|
|
86
|
+
// agent, and send it straight to the exit-2 "no --agent given" path.
|
|
87
|
+
const { hosts, ...rest } = lock;
|
|
88
|
+
return { ...empty(scope), ...rest, agents: lock.agents ?? hosts ?? {} };
|
|
61
89
|
}
|
|
62
90
|
|
|
63
91
|
export function write(root, lock) {
|
|
64
92
|
const next = { ...lock, updated_at: new Date().toISOString() };
|
|
65
93
|
if (!next.installed_at) next.installed_at = next.updated_at;
|
|
66
|
-
|
|
94
|
+
const target = lockPath(root);
|
|
95
|
+
ensureDir(dirname(target));
|
|
96
|
+
writeAtomic(target, `${JSON.stringify(next, null, 2)}\n`);
|
|
97
|
+
// Migration: one lock, at one path. Writing the new one and leaving the old
|
|
98
|
+
// would make an older CLI, or our own `read()`, resurrect a stale record.
|
|
99
|
+
const legacy = legacyLockPath(root);
|
|
100
|
+
if (legacy !== target && exists(legacy)) {
|
|
101
|
+
removeFile(legacy);
|
|
102
|
+
removeDirIfEmpty(dirname(legacy));
|
|
103
|
+
}
|
|
67
104
|
return next;
|
|
68
105
|
}
|
|
69
106
|
|
|
70
107
|
/** Fold a batch of executed entries into the lock. */
|
|
71
|
-
export function record(lock, entries, {
|
|
108
|
+
export function record(lock, entries, { agent, version = PKG_VERSION } = {}) {
|
|
72
109
|
const next = {
|
|
73
110
|
...lock,
|
|
74
111
|
files: { ...lock.files },
|
|
@@ -77,7 +114,7 @@ export function record(lock, entries, { host, version = PKG_VERSION } = {}) {
|
|
|
77
114
|
rules: [...lock.rules],
|
|
78
115
|
fences: [...lock.fences],
|
|
79
116
|
manual: [...lock.manual],
|
|
80
|
-
|
|
117
|
+
agents: { ...lock.agents },
|
|
81
118
|
};
|
|
82
119
|
for (const entry of entries) {
|
|
83
120
|
if (!entry) continue;
|
|
@@ -121,7 +158,7 @@ export function record(lock, entries, { host, version = PKG_VERSION } = {}) {
|
|
|
121
158
|
break;
|
|
122
159
|
}
|
|
123
160
|
}
|
|
124
|
-
if (
|
|
161
|
+
if (agent) next.agents[agent] = { installed_at: new Date().toISOString(), version };
|
|
125
162
|
return next;
|
|
126
163
|
}
|
|
127
164
|
|
package/lib/out.mjs
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
// Output contract shared by every sw-ecosystem-agentic-harness subcommand.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
3
|
+
// There is one result object per run. `--json` prints it verbatim: keys
|
|
4
4
|
// ordered small-first with the big payload LAST, so an agent that reads only
|
|
5
5
|
// the head of the output already knows whether it worked and what to run next.
|
|
6
|
+
// Without `--json` the same object is rendered for a human by lib/render.mjs —
|
|
7
|
+
// the object is the model, the renderer is only a view, and neither can drift
|
|
8
|
+
// from the other because there is only ever one body.
|
|
6
9
|
//
|
|
7
|
-
//
|
|
10
|
+
// Human text goes to stdout: it is the result, not progress. stderr carries
|
|
11
|
+
// progress, warnings and the interactive picker — never part of the result.
|
|
8
12
|
//
|
|
9
|
-
// The contract:
|
|
10
|
-
//
|
|
11
|
-
|
|
13
|
+
// The contract: a mandatory `next_step` on every success, `exit 2` on a usage
|
|
14
|
+
// error carrying a `help` array of runnable commands.
|
|
15
|
+
|
|
16
|
+
import { render } from './render.mjs';
|
|
12
17
|
|
|
13
18
|
export const EXIT_OK = 0;
|
|
14
19
|
export const EXIT_FAIL = 1;
|
|
@@ -19,8 +24,24 @@ export function json(value) {
|
|
|
19
24
|
return `${JSON.stringify(value, null, 2)}\n`;
|
|
20
25
|
}
|
|
21
26
|
|
|
27
|
+
// Read from argv rather than threaded through every call: `bin/cli.mjs`
|
|
28
|
+
// dispatches some usage errors before any flag parsing has happened, and those
|
|
29
|
+
// must honour `--json` too. `setFormat`/`setVerbose` exist so a unit test can
|
|
30
|
+
// drive this without re-spawning the process.
|
|
31
|
+
let format = process.argv.includes('--json') ? 'json' : 'human';
|
|
32
|
+
let verbose = process.argv.includes('--verbose');
|
|
33
|
+
|
|
34
|
+
export function setFormat(value) { format = value === 'json' ? 'json' : 'human'; }
|
|
35
|
+
export function setVerbose(value) { verbose = value === true; }
|
|
36
|
+
export function isJson() { return format === 'json'; }
|
|
37
|
+
|
|
22
38
|
function emit(body, code) {
|
|
23
|
-
|
|
39
|
+
// `guide` is already prose inside its own body; rendering it would only
|
|
40
|
+
// hide it. Everything else gets the human view unless `--json` asked.
|
|
41
|
+
const asJson = format === 'json' || body.command === 'guide';
|
|
42
|
+
process.stdout.write(asJson
|
|
43
|
+
? json(body)
|
|
44
|
+
: render(body, { verbose, color: process.stdout.isTTY === true }));
|
|
24
45
|
process.exitCode = code;
|
|
25
46
|
return code;
|
|
26
47
|
}
|
package/lib/prompt.mjs
CHANGED
|
@@ -1,42 +1,37 @@
|
|
|
1
|
-
// The interactive
|
|
2
|
-
// of the
|
|
1
|
+
// The interactive agent picker. Used only when `apply`/`install` reach step 3
|
|
2
|
+
// of the agent-resolution chain: no `--agent`, no lock to reuse, but a real
|
|
3
3
|
// terminal to ask on.
|
|
4
4
|
//
|
|
5
5
|
// node:readline only — the package has zero dependencies and that must not
|
|
6
|
-
// change. Every prompt and echo goes to STDERR, never stdout:
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
// `process.stdin.isTTY` itself — the caller decides
|
|
6
|
+
// change. Every prompt and echo goes to STDERR, never stdout: the picker is an
|
|
7
|
+
// interaction, not the result, and stdout carries the result alone — under
|
|
8
|
+
// `--json` a menu there would corrupt the object every skill parses.
|
|
9
|
+
// This module never inspects `process.stdin.isTTY` itself — the caller decides
|
|
10
|
+
// whether to invoke it.
|
|
10
11
|
|
|
11
12
|
import { createInterface } from 'node:readline';
|
|
12
|
-
|
|
13
|
-
const LABELS = {
|
|
14
|
-
'claude-code': 'Claude Code',
|
|
15
|
-
codex: 'Codex',
|
|
16
|
-
copilot: 'GitHub Copilot',
|
|
17
|
-
cursor: 'Cursor',
|
|
18
|
-
};
|
|
13
|
+
import { LABELS } from './render.mjs';
|
|
19
14
|
|
|
20
15
|
/**
|
|
21
16
|
* Pure parsing, no I/O — so it can be unit-tested without a TTY.
|
|
22
17
|
*
|
|
23
|
-
* Accepts comma-separated 1-based indexes into `
|
|
24
|
-
* word "all", or an empty string — both of the latter two mean every
|
|
18
|
+
* Accepts comma-separated 1-based indexes into `agents` (e.g. "1,3"), the
|
|
19
|
+
* word "all", or an empty string — both of the latter two mean every agent,
|
|
25
20
|
* made explicit rather than left as a silent default. Throws a short,
|
|
26
21
|
* human-readable `Error` on anything else (out of range, not a number,
|
|
27
22
|
* nothing at all).
|
|
28
23
|
*/
|
|
29
|
-
export function parseSelection(input,
|
|
24
|
+
export function parseSelection(input, agents) {
|
|
30
25
|
const trimmed = (input ?? '').trim();
|
|
31
|
-
if (trimmed === '' || trimmed.toLowerCase() === 'all') return [...
|
|
26
|
+
if (trimmed === '' || trimmed.toLowerCase() === 'all') return [...agents];
|
|
32
27
|
const parts = trimmed.split(',').map((p) => p.trim()).filter(Boolean);
|
|
33
28
|
if (!parts.length) throw new Error('no selection given');
|
|
34
29
|
const chosen = [];
|
|
35
30
|
for (const part of parts) {
|
|
36
31
|
if (!/^\d+$/.test(part)) throw new Error(`not a number: "${part}"`);
|
|
37
32
|
const n = Number(part);
|
|
38
|
-
if (n < 1 || n >
|
|
39
|
-
const id =
|
|
33
|
+
if (n < 1 || n > agents.length) throw new Error(`out of range 1-${agents.length}: ${part}`);
|
|
34
|
+
const id = agents[n - 1];
|
|
40
35
|
if (!chosen.includes(id)) chosen.push(id);
|
|
41
36
|
}
|
|
42
37
|
return chosen;
|
|
@@ -47,20 +42,20 @@ function question(rl, text) {
|
|
|
47
42
|
}
|
|
48
43
|
|
|
49
44
|
/**
|
|
50
|
-
* Ask which
|
|
45
|
+
* Ask which agent(s) to install into. Re-asks once on unparseable input, then
|
|
51
46
|
* gives up and returns `null` — the caller turns that into the same exit-2
|
|
52
47
|
* usage error the no-TTY path uses, so the output contract stays one shape.
|
|
53
48
|
*/
|
|
54
|
-
export async function
|
|
49
|
+
export async function askAgents(agents) {
|
|
55
50
|
const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
|
|
56
|
-
const menu =
|
|
51
|
+
const menu = agents.map((a, i) => ` ${i + 1}. ${LABELS[a] ?? a}`).join('\n');
|
|
57
52
|
const prompt = `Which coding agent(s) should this install into?\n${menu}\n`
|
|
58
53
|
+ 'Enter comma-separated numbers (e.g. "1,3"), "all", or press Enter for all: ';
|
|
59
54
|
try {
|
|
60
55
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
61
56
|
const answer = await question(rl, attempt === 0 ? prompt : 'Not understood — try again: ');
|
|
62
57
|
try {
|
|
63
|
-
return parseSelection(answer,
|
|
58
|
+
return parseSelection(answer, agents);
|
|
64
59
|
} catch {
|
|
65
60
|
// Re-ask once; the loop's second pass falls through to null below.
|
|
66
61
|
}
|