@execuro-sw-ecosystem/sw-ecosystem-agentic-harness 0.1.6 → 0.1.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/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 `resolveHosts()` chain (`--host`, then a recorded lock,
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, mcpServers, LOCK_DIR, PKG_ROOT, skills,
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 HOSTS = ['claude-code', 'codex', 'copilot', 'cursor'];
19
+ export const AGENTS = ['claude-code', 'codex', 'copilot', 'cursor'];
21
20
 
22
- /** Where each host reads its skills from — the base every skill-copy and
23
- * extra-component skill-copy action is rooted at. */
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(HOSTS.map((id) => Promise.resolve(detect(id, ctx))));
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 hosts. */
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.hosts) actions.push(...planFor(id, 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
- /** The lock file describes one machine's install, so it is gitignored. */
151
- function gitignoreAction(host) {
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: `${LOCK_DIR}/`,
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.hosts` is narrowed to one host while previewing, so the question
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 `selectedHosts` carries unchanged.
255
- const selected = ctx.selectedHosts ?? ctx.hosts;
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: `.sw-ai-sdk/harness.lock.json` in the host repository.
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 gitignored by default: it describes one machine's install, not the
10
- // project.
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 { LOCK_FILE, SUPPORTED_CONTENT, CONTENT_VERSION, PKG_NAME, PKG_VERSION } from './content.mjs';
13
- import { abs, readTextMaybe, writeAtomic } from './fsx.mjs';
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
- hosts: {},
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
- const found = readTextMaybe(lockPath(root));
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
- `${LOCK_FILE} is not valid JSON. Move it aside and re-run apply to rebuild it.`,
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
- `${LOCK_FILE} records content_version ${lock.content_version}, which this ` +
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
- return { ...empty(scope), ...lock };
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
- writeAtomic(lockPath(root), `${JSON.stringify(next, null, 2)}\n`);
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, { host, version = PKG_VERSION } = {}) {
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
- hosts: { ...lock.hosts },
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 (host) next.hosts[host] = { installed_at: new Date().toISOString(), version };
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
- // stdout carries exactly one JSON object and nothing else. Its keys are
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
- // stderr carries progress and warnings that are not part of the result.
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: structured JSON on stdout, a mandatory `next_step` on every
10
- // success, `exit 2` on a usage error carrying a `help` array of runnable
11
- // commands.
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
- process.stdout.write(json(body));
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 host picker. Used only when `apply`/`install` reach step 3
2
- // of the host-resolution chain: no `--host`, no lock to reuse, but a real
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: stdout carries
7
- // exactly one JSON object per run and every skill parses it, so a menu on
8
- // stdout would corrupt that contract. This module never inspects
9
- // `process.stdin.isTTY` itself — the caller decides whether to invoke it.
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 `hosts` (e.g. "1,3"), the
24
- * word "all", or an empty string — both of the latter two mean every host,
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, hosts) {
24
+ export function parseSelection(input, agents) {
30
25
  const trimmed = (input ?? '').trim();
31
- if (trimmed === '' || trimmed.toLowerCase() === 'all') return [...hosts];
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 > hosts.length) throw new Error(`out of range 1-${hosts.length}: ${part}`);
39
- const id = hosts[n - 1];
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 host(s) to install into. Re-asks once on unparseable input, then
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 askHosts(hosts) {
49
+ export async function askAgents(agents) {
55
50
  const rl = createInterface({ input: process.stdin, output: process.stderr, terminal: true });
56
- const menu = hosts.map((h, i) => ` ${i + 1}. ${LABELS[h] ?? h}`).join('\n');
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, hosts);
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
  }