@codedology/code-forge 0.2.1 → 0.2.2

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 CHANGED
@@ -6,6 +6,23 @@ waits on a second real consumer (plan §10.4, Q17).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.2.2] — 2026-10-01
10
+
11
+ ### Added
12
+
13
+ - 1Password item ID or item link accepted for the Jev key (init question, --jev-ref, keys set --op, keys test --ref):
14
+ code-forge finds the vault and key field and saves a full op:// reference of IDs; 1Password failures (CLI missing,
15
+ locked, timeout, not found, bad output, no key field) get one clear message, a retry/skip menu in init, and never
16
+ write a partial config
17
+ - code-forge tools: lists the recommended tools (claude, codex, gemini, grok, op, solo) as installed with their version
18
+ or missing with the install command; code-forge tools install [<id>...] [--yes] [--dry-run] installs the missing ones
19
+ after one yes. init step 1 and doctor use the same tool table; doctor adds an INFO line naming the missing tools.
20
+
21
+ ### Changed
22
+
23
+ - Releases: CI no longer publishes to npm. Publish with `npm publish` from your machine, then push the tag; the tag push
24
+ only creates the GitHub release (`.github/workflows/release.yml`).
25
+
9
26
  ## [0.2.1] — 2026-10-01
10
27
 
11
28
  ### Changed
package/README.md CHANGED
@@ -39,8 +39,12 @@ for later. Non-interactively:
39
39
  code-forge init --no-interaction --no-jev
40
40
  ```
41
41
 
42
- `--no-jev` skips the System-1 key for a first try; drop it and pass `--jev-ref op://vault/item/field`
43
- or `--jev-env MY_JEV_KEY` once you have one. A second run of `init` changes nothing it already wrote.
42
+ `--no-jev` skips the System-1 key for a first try; drop it and pass `--jev-ref <1Password item ID>`
43
+ (or the item link, or `op://vault/item/field`; code-forge finds the vault and the key field) or
44
+ `--jev-env MY_JEV_KEY` once you have one. A second run of `init` changes nothing it already wrote.
45
+
46
+ To upgrade: `npm install -g @codedology/code-forge@latest`, then `code-forge upgrade` (re-copies
47
+ skill installs made with `--copy`). See [docs/getting-started.md](docs/getting-started.md#keeping-it-up-to-date).
44
48
 
45
49
  ## Quickstart
46
50
 
@@ -79,8 +83,9 @@ usage line.
79
83
 
80
84
  | Verb | Usage |
81
85
  |---|---|
82
- | `init` | `code-forge init [--no-interaction] [--tools recommended\|current] [--yes-tool <tool>]… [--harness a,b] [-g\|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]… [--refresh-models] [--multimodel on\|off] [--second-provider P] [--jev-ref op://… \| --jev-env NAME \| --no-jev] [--engine auto\|solo\|harness] [--solo-project N] [--gate name=cmd]… [--proof isolation=export\|lock \| high=a,b \| link_dirs=a,b \| copy_untracked=a,b]… [--skip-doctor]` |
86
+ | `init` | `code-forge init [--no-interaction] [--tools recommended\|current] [--yes-tool <tool>]… [--harness a,b] [-g\|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]… [--refresh-models] [--multimodel on\|off] [--second-provider P] [--jev-ref <item-id\|link\|op://…> \| --jev-env NAME \| --no-jev] [--engine auto\|solo\|harness] [--solo-project N] [--gate name=cmd]… [--proof isolation=export\|lock \| high=a,b \| link_dirs=a,b \| copy_untracked=a,b]… [--skip-doctor]` |
83
87
  | `doctor` | `code-forge doctor [--quick] [--json] [--cwd <dir>]` |
88
+ | `tools` | `code-forge tools [--json]` · `code-forge tools install [<id>…] [--yes] [--dry-run]` — see and install the recommended tools (claude, codex, gemini, grok, op, solo) |
84
89
  | `validate` | `code-forge validate [--file <path>]` |
85
90
  | `resolve` | `code-forge resolve <L0\|L1\|L2\|L3>` |
86
91
  | `run` | `code-forge run start [--cwd <dir>] [--run <id>] [--engine <e>] [--worker-pid <pid>]` · `run start --reattach --run <id>` · `run status --run <id>` · `run end --run <id>` |
@@ -1,10 +1,10 @@
1
1
  {
2
- "$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table); plus B22 (the review-only verb), which the root added on 2026-09-26 after the night run. Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
2
+ "$comment": "hand-maintained; checked against plan §10.4 + NIGHT-LOG by test/docs-blocks.test.mjs. Ids = every `| **Bxx** |` row in plans/code-forge-plan-v1.3.md §10.4 Waves 2-6 (19 ids), plus the blocks the root orchestrator added during the night run of 2026-09-25 that are not in that table (plans/NIGHT-LOG.md): B1.2, B4.1, B11.1, B12c (B3.1 was already in the v1.3 §10.4 table); plus B22 (the review-only verb) and B23 (release tools), which the root added on 2026-09-26 after the night run, and B24 (init project settings) and B25 (1Password item IDs), added on 2026-10-01. Wave 0-1 blocks (B0-B8) are the pre-v1.3 baseline landed before this plan revision; they have no entry under blocks and are listed in baseline_ids so every depends_on id resolves; see plans/code-forge-plan-v1.3.md §10.2-§10.3 for their record. B10c is listed with deleted:true — the maintainer answered Q16 'cut' on 2026-09-25, so B10c (consumer-facing mutation-testing tools) never lands.",
3
3
  "checked_against": [
4
4
  "plans/code-forge-plan-v1.3.md §10.4",
5
5
  "plans/NIGHT-LOG.md"
6
6
  ],
7
- "block_count": 24,
7
+ "block_count": 28,
8
8
  "baseline_ids": ["B0", "B1", "B2", "B3", "B4", "B5", "B6", "B7", "B8"],
9
9
  "baseline_note": "Wave 0-1 blocks, landed before v1.3; depends_on may name them, but they have no entry under blocks.",
10
10
  "blocks": {
@@ -288,12 +288,64 @@
288
288
  "title": "Review-only verb: code-forge review (base/file discovery, run start -> block open -> review-file -> block stop -> run end) and its docs",
289
289
  "wave": 6,
290
290
  "level": "L2",
291
- "status": "in_review",
291
+ "status": "landed",
292
+ "commit": "1e9ea32",
292
293
  "owned": [
293
294
  "src/cli/review.mjs", "test/cli/review.test.mjs", "docs/review-only.md"
294
295
  ],
295
296
  "depends_on": ["B11", "B12a", "B12c"],
296
297
  "note": "root-added 2026-09-26 (after the night run); not in the v1.3 §10.4 table"
298
+ },
299
+ "B23": {
300
+ "title": "Release and changelog tools: npm run release (bump, CHANGELOG, checks, commit, tag), npm run changelog, GitHub Releases linked to the npm publish; both publish paths",
301
+ "wave": 6,
302
+ "level": "L2",
303
+ "status": "landed",
304
+ "commit": "805dca8",
305
+ "owned": [
306
+ "scripts/release.mjs", "scripts/changelog.mjs", ".github/workflows/release.yml",
307
+ "test/release.test.mjs", "docs/releasing.md"
308
+ ],
309
+ "depends_on": ["B16"],
310
+ "note": "root-added 2026-09-26; not in the v1.3 §10.4 table. Both publish paths (by hand or CI) added in d61623f; on 2026-10-01 CI publishing was removed (publish.yml became release.yml: GitHub release only, npm publish by hand)"
311
+ },
312
+ "B24": {
313
+ "title": "init project settings: detect gates and proof settings, print a summary that marks every blank, one choice (Use these / Customize now / Leave for later)",
314
+ "wave": 6,
315
+ "level": "L2",
316
+ "status": "landed",
317
+ "commit": "fa915c7",
318
+ "owned": [
319
+ "src/install/wizard/answers.mjs", "src/install/wizard/run.mjs", "src/install/wizard/steps.mjs",
320
+ "src/install/wizard/summary.mjs", "test/init/settings.test.mjs", "docs/getting-started.md"
321
+ ],
322
+ "depends_on": ["B13a"],
323
+ "note": "root-added 2026-10-01; not in the v1.3 §10.4 table. Released in 0.2.1"
324
+ },
325
+ "B25": {
326
+ "title": "1Password item ID or item link for the Jev key: resolve vault and key field with op item get, save an op:// reference of IDs; classified 1Password failures with a retry/skip menu",
327
+ "wave": 6,
328
+ "level": "L2",
329
+ "status": "landed",
330
+ "owned": [
331
+ "src/keys/onepassword.mjs", "src/cli/keys.mjs", "src/doctor/local.mjs",
332
+ "test/keys/onepassword.test.mjs", "test/init/onepassword.test.mjs"
333
+ ],
334
+ "depends_on": ["B24"],
335
+ "note": "root-added 2026-10-01; not in the v1.3 §10.4 table"
336
+ },
337
+ "B26": {
338
+ "title": "code-forge tools: see and install the recommended tools from one shared table (status, versions, install plan with one yes); init step 1 and doctor use the same table",
339
+ "wave": 6,
340
+ "level": "L2",
341
+ "status": "landed",
342
+ "owned": [
343
+ "src/install/tools.mjs", "src/cli/tools.mjs", "src/install/wizard/run.mjs", "src/install/wizard/flags.mjs",
344
+ "src/doctor/local.mjs", "src/doctor/index.mjs",
345
+ "test/cli/tools.test.mjs", "test/init/tools.test.mjs"
346
+ ],
347
+ "depends_on": ["B13a"],
348
+ "note": "root-added 2026-10-01; not in the v1.3 §10.4 table"
297
349
  }
298
350
  }
299
351
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@codedology/code-forge",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Plan/harden/code/review orchestration CLI and Agent Skill for cross-model, parallel, evidence-gated feature delivery.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -10,7 +10,7 @@
10
10
  # copied install with no package next to it); `test/skill/shim.test.mjs` asserts the pin
11
11
  # equals package.json's version.
12
12
  # The shim holds no logic of its own: every verb, flag and message is the CLI's.
13
- PINNED_VERSION="0.2.1"
13
+ PINNED_VERSION="0.2.2"
14
14
  set -eu
15
15
 
16
16
  self="$0"
package/src/cli/keys.mjs CHANGED
@@ -5,18 +5,21 @@
5
5
  * message would be masked once the chain registered it.
6
6
  *
7
7
  * keys list
8
- * keys set <name> [--op <op://ref>] hidden prompt, or read once from 1Password and cache 8 h
8
+ * keys set <name> [--op <ref>] hidden prompt, or read once from 1Password and cache 8 h
9
9
  * keys test <name> [--ref <ref>] resolve through the chain (no prompt)
10
10
  * keys remove <name>
11
11
  *
12
+ * An `--op`/`--ref` value may be a 1Password item ID or item link instead of `op://vault/item/field`:
13
+ * it is resolved to `op://<vaultId>/<itemId>/<fieldId>` first (`op item get`, B25).
14
+ *
12
15
  * Every name is validated before any store call, on every subcommand.
13
16
  */
14
17
 
15
18
  import { writeSafe } from '../util/redact.mjs';
16
- import { isOpRef } from '../keys/onepassword.mjs';
19
+ import { describeOpItem, isOpRef, OP_MESSAGES, opItemIdFromInput, toOpRef } from '../keys/onepassword.mjs';
17
20
  import { assertKeyName, createDefaultKeyStore, parseRef, readOpAndCache, resolveKey } from '../keys/store.mjs';
18
21
 
19
- const USAGE = 'usage: code-forge keys list | set <name> [--op <ref>] | test <name> [--ref <ref>] | remove <name>\n';
22
+ const USAGE = 'usage: code-forge keys list | set <name> [--op <item-id|link|op://ref>] | test <name> [--ref <ref>] | remove <name>\n';
20
23
 
21
24
  /** The one flag each subcommand accepts (list and remove take none). */
22
25
  const FLAGS = /** @type {Record<string, string|null>} */ ({ list: null, set: '--op', test: '--ref', remove: null });
@@ -76,11 +79,12 @@ async function askHidden(name) {
76
79
  * @param {{write: (s: string) => unknown}} [deps.stderr]
77
80
  * @param {(name: string) => Promise<string|null>} [deps.ask]
78
81
  * @param {typeof import('../keys/onepassword.mjs').opRead} [deps.opRead]
82
+ * @param {typeof import('../util/exec.mjs').exec} [deps.opExec] - runs `op item get` for an item ID or link.
79
83
  * @param {NodeJS.ProcessEnv} [deps.env]
80
84
  * @param {number} [deps.now]
81
85
  * @returns {Promise<number>}
82
86
  */
83
- export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, env = process.env, now = Date.now() }) {
87
+ export async function runKeys(args, { store, stdout = process.stdout, stderr = process.stderr, ask = askHidden, opRead, opExec, env = process.env, now = Date.now() }) {
84
88
  const out = (/** @type {string} */ s) => writeSafe(stdout, s);
85
89
  const err = (/** @type {string} */ s) => writeSafe(stderr, s);
86
90
 
@@ -89,11 +93,41 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
89
93
  err(USAGE);
90
94
  return 2;
91
95
  }
92
- const { sub, name, flagArg } = parsed;
96
+ const { sub, name } = parsed;
97
+ let { flagArg } = parsed;
93
98
  try {
94
99
  if (name !== undefined) {
95
100
  assertKeyName(name);
96
101
  }
102
+ } catch (e) {
103
+ err(`keys: ${e.message}\n`);
104
+ return 2;
105
+ }
106
+ const fullRef = flagArg !== undefined && isOpRef(flagArg);
107
+ const itemId = flagArg !== undefined && !fullRef ? opItemIdFromInput(flagArg) : null;
108
+ if (sub === 'set' && flagArg !== undefined && !fullRef && itemId === null) {
109
+ err(`${name}: --op must be a 1Password item ID, item link or op://vault/item/field reference\n`);
110
+ return 2;
111
+ }
112
+ // A 1Password item ID or link becomes a full op:// reference before any store call (B25).
113
+ if (flagArg !== undefined && itemId !== null) {
114
+ /** @type {Awaited<ReturnType<typeof toOpRef>>} */
115
+ let res;
116
+ try {
117
+ res = await toOpRef(flagArg, opExec ? { exec: opExec } : {});
118
+ } catch {
119
+ // never the thrown message: it could carry op output
120
+ res = { ref: null, kind: 'op_failed', error: `${OP_MESSAGES.op_failed} (unexpected error); run \`op item get ${itemId}\` yourself to see why` };
121
+ }
122
+ if (res.ref === null) {
123
+ err(`${name}: ${res.error ?? OP_MESSAGES.op_failed}\n`);
124
+ return 1;
125
+ }
126
+ const found = describeOpItem(res);
127
+ out(`${name}: ${found ? `${found} -> ` : ''}${res.ref}\n`);
128
+ flagArg = res.ref;
129
+ }
130
+ try {
97
131
  if (sub === 'test' && flagArg !== undefined) {
98
132
  parseRef(name, flagArg);
99
133
  }
@@ -101,10 +135,6 @@ export async function runKeys(args, { store, stdout = process.stdout, stderr = p
101
135
  err(`keys: ${e.message}\n`);
102
136
  return 2;
103
137
  }
104
- if (sub === 'set' && flagArg !== undefined && !isOpRef(flagArg)) {
105
- err(`${name}: --op must be an op://vault/item/field reference\n`);
106
- return 2;
107
- }
108
138
 
109
139
  if (store.warning) {
110
140
  err(`WARN ${store.warning}\n`);
@@ -0,0 +1,221 @@
1
+ /**
2
+ * `code-forge tools` (block B26): see and install the recommended tools of the shared table
3
+ * (`src/install/tools.mjs`).
4
+ *
5
+ * tools [--json] one line per tool; exit 0 always
6
+ * tools install [<id>...] [--yes] [--dry-run] install the missing (or the named) tools
7
+ *
8
+ * `install` shows the plan (each command as argv, joined for display only), asks ONE yes (default
9
+ * no) unless `--yes`; without a terminal and without `--yes` it prints the plan, installs nothing
10
+ * and exits 2. Each install runs through B0 `exec` (argv, never a shell); a failure does not stop
11
+ * the others; presence is re-checked afterwards. The final table lists the tools acted on (and,
12
+ * with named ids, "already installed"); a failure says why: `failed (exit n | signal S | timed out
13
+ * | did not start)`. Exit 0 when every requested install succeeded, 1 when any failed or the yes
14
+ * was refused ("cancelled — nothing installed", exit 1, as in init), 2 on a usage error. Manual tools are never "failed"
15
+ * and no browser is ever opened.
16
+ */
17
+
18
+ import { exec as realExec } from '../util/exec.mjs';
19
+ import { writeSafe } from '../util/redact.mjs';
20
+ import { detectTool, failureReason, installPlan, TOOL_IDS, TOOLS, toolById, toolVersion } from '../install/tools.mjs';
21
+ import { parseFlags } from '../state/cli-args.mjs';
22
+
23
+ const USAGE = 'usage: code-forge tools [--json] | tools install [<id>...] [--yes] [--dry-run]\n (install asks one yes, default no; answering no prints "cancelled — nothing installed" and exits 1)\n';
24
+
25
+ /** An install may take a while (npm, brew); a hung one is killed after this. */
26
+ const INSTALL_TIMEOUT_MS = 600_000;
27
+
28
+ /** @typedef {{write: (s: string) => unknown}} Out */
29
+
30
+ /**
31
+ * @typedef {object} ToolsDeps
32
+ * @property {NodeJS.ProcessEnv} [env]
33
+ * @property {Out} [stdout]
34
+ * @property {Out} [stderr]
35
+ * @property {boolean} [isTTY] - whether stdin is a terminal (default `process.stdin.isTTY`).
36
+ * @property {{confirm: (o: {message: string, initialValue?: boolean}) => Promise<any>, isCancel: (v: unknown) => boolean}} [ui] - default `@clack/prompts`.
37
+ * @property {typeof realExec} [exec] - runs `--version` and the installs (tests: a fake).
38
+ * @property {NodeJS.Platform} [platform]
39
+ * @property {(p: string) => boolean} [exists] - the Solo app bundle check (tests: a fake).
40
+ */
41
+
42
+ /**
43
+ * @typedef {object} ToolStatus
44
+ * @property {string} id
45
+ * @property {'installed'|'missing'} status
46
+ * @property {string|null} version
47
+ * @property {string[]|null} install - the argv that would install it (missing only).
48
+ * @property {string|null} hint - the manual hint (missing only, when there is no argv).
49
+ * @property {string|null} note
50
+ */
51
+
52
+ /** @param {ToolsDeps} deps */
53
+ function toolEnv(deps) {
54
+ const env = deps.env ?? process.env;
55
+ return { pathEnv: env.PATH ?? '', platform: deps.platform ?? process.platform, home: env.HOME ?? '', ...(deps.exists ? { exists: deps.exists } : {}) };
56
+ }
57
+
58
+ /**
59
+ * @param {ToolsDeps} deps
60
+ * @returns {Promise<ToolStatus[]>}
61
+ */
62
+ export async function toolStatuses(deps = {}) {
63
+ const opts = toolEnv(deps);
64
+ const exec = deps.exec ?? realExec;
65
+ const env = deps.env ?? process.env;
66
+ /** @type {ToolStatus[]} */
67
+ const out = [];
68
+ for (const tool of TOOLS) {
69
+ if (await detectTool(tool, opts)) {
70
+ out.push({ id: tool.id, status: 'installed', version: await toolVersion(tool, { exec, env }), install: null, hint: null, note: tool.note });
71
+ } else {
72
+ const plan = await installPlan(tool, opts);
73
+ out.push({ id: tool.id, status: 'missing', version: null, install: plan.kind === 'run' ? plan.argv : null, hint: plan.kind === 'manual' ? plan.hint : null, note: null });
74
+ }
75
+ }
76
+ return out;
77
+ }
78
+
79
+ /** @param {ToolStatus} s @returns {string} one line, no newline */
80
+ export function renderStatus(s) {
81
+ if (s.status === 'installed') return `${s.id.padEnd(7)} installed ${s.version ?? 'version unknown'}${s.note ? ` — ${s.note}` : ''}`;
82
+ return `${s.id.padEnd(7)} missing ${s.install ? `install: ${s.install.join(' ')}` : `manual: ${s.hint}`}`;
83
+ }
84
+
85
+ /**
86
+ * @param {string[]} args
87
+ * @param {ToolsDeps} [deps]
88
+ * @returns {Promise<number>}
89
+ */
90
+ export async function runTools(args, deps = {}) {
91
+ const stdout = deps.stdout ?? process.stdout;
92
+ const stderr = deps.stderr ?? process.stderr;
93
+ const out = (/** @type {string} */ s) => writeSafe(stdout, s);
94
+ const err = (/** @type {string} */ s) => writeSafe(stderr, s);
95
+
96
+ if (args[0] === 'install') return runInstall(args.slice(1), deps, out, err);
97
+
98
+ let parsed;
99
+ try {
100
+ parsed = parseFlags(args, { booleans: ['json'] });
101
+ } catch (e) {
102
+ err(`tools: ${e.message}\n${USAGE}`);
103
+ return 2;
104
+ }
105
+ if (parsed.positionals.length > 0) {
106
+ err(`tools: unknown subcommand ${JSON.stringify(parsed.positionals[0])}\n${USAGE}`);
107
+ return 2;
108
+ }
109
+ const statuses = await toolStatuses(deps);
110
+ if (parsed.flags.json) {
111
+ out(`${JSON.stringify({ tools: statuses })}\n`);
112
+ } else {
113
+ for (const s of statuses) out(`${renderStatus(s)}\n`);
114
+ }
115
+ return 0;
116
+ }
117
+
118
+ /**
119
+ * @param {string[]} args @param {ToolsDeps} deps
120
+ * @param {(s: string) => void} out @param {(s: string) => void} err
121
+ * @returns {Promise<number>}
122
+ */
123
+ async function runInstall(args, deps, out, err) {
124
+ let parsed;
125
+ try {
126
+ parsed = parseFlags(args, { booleans: ['yes', 'dry-run'] });
127
+ } catch (e) {
128
+ err(`tools: ${e.message}\n${USAGE}`);
129
+ return 2;
130
+ }
131
+ const ids = [...new Set(parsed.positionals)];
132
+ const unknown = ids.filter((id) => !TOOL_IDS.includes(id));
133
+ if (unknown.length > 0) {
134
+ err(`tools: unknown tool id(s): ${unknown.join(', ')} — valid ids: ${TOOL_IDS.join(', ')}\n${USAGE}`);
135
+ return 2;
136
+ }
137
+ const opts = toolEnv(deps);
138
+ const env = deps.env ?? process.env;
139
+ const exec = deps.exec ?? realExec;
140
+ const named = ids.length > 0;
141
+ const wanted = named ? TOOLS.filter((t) => ids.includes(t.id)) : TOOLS;
142
+
143
+ /** @type {Array<{id: string, result: string}>} */
144
+ const table = [];
145
+ /** @type {Array<{id: string, argv: string[]}>} */
146
+ const runs = [];
147
+ /** @type {Array<{id: string, hint: string}>} */
148
+ const manual = [];
149
+ for (const tool of wanted) {
150
+ if (await detectTool(tool, opts)) {
151
+ if (named) table.push({ id: tool.id, result: 'already installed' });
152
+ continue;
153
+ }
154
+ const plan = await installPlan(tool, opts);
155
+ if (plan.kind === 'run') runs.push({ id: tool.id, argv: plan.argv });
156
+ else manual.push({ id: tool.id, hint: plan.hint });
157
+ }
158
+
159
+ if (runs.length === 0 && manual.length === 0) {
160
+ out(named ? '' : 'all recommended tools are installed\n');
161
+ printTable(out, table);
162
+ return 0;
163
+ }
164
+
165
+ out('plan:\n');
166
+ for (const r of runs) out(` ${r.id}: ${r.argv.join(' ')}\n`);
167
+ for (const m of manual) out(` ${m.id}: manual — ${m.hint}\n`);
168
+
169
+ if (parsed.flags['dry-run']) {
170
+ out('dry run: nothing installed\n');
171
+ return 0;
172
+ }
173
+ if (runs.length > 0 && !parsed.flags.yes) {
174
+ if (!(deps.isTTY ?? process.stdin.isTTY === true)) {
175
+ err('tools: no terminal to confirm; nothing installed — pass --yes to install the plan above\n');
176
+ return 2;
177
+ }
178
+ const ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
179
+ const go = await ui.confirm({ message: `Run ${runs.length} install command(s)?`, initialValue: false });
180
+ if (ui.isCancel(go) || go !== true) {
181
+ out('cancelled — nothing installed\n');
182
+ return 1;
183
+ }
184
+ }
185
+
186
+ let failed = 0;
187
+ for (const r of runs) {
188
+ out(`\n$ ${r.argv.join(' ')}\n`);
189
+ let res;
190
+ try {
191
+ res = await exec(r.argv, { timeoutMs: INSTALL_TIMEOUT_MS, env });
192
+ } catch (e) {
193
+ // a spawn that throws reads "did not start"; its message is never printed
194
+ res = { result: 'failed', code: null, signal: null, stdout: '', stderr: '', timedOut: false };
195
+ }
196
+ if (res.stdout) out(res.stdout.endsWith('\n') ? res.stdout : `${res.stdout}\n`);
197
+ if (res.stderr) err(res.stderr.endsWith('\n') ? res.stderr : `${res.stderr}\n`);
198
+ if (res.result === 'ok') {
199
+ const tool = /** @type {NonNullable<ReturnType<typeof toolById>>} */ (toolById(r.id));
200
+ const present = await detectTool(tool, opts);
201
+ table.push({ id: r.id, result: present ? 'installed' : 'installed (not on PATH yet — open a new shell)' });
202
+ } else {
203
+ failed += 1;
204
+ table.push({ id: r.id, result: `failed (${failureReason(res)})` });
205
+ }
206
+ }
207
+ for (const m of manual) table.push({ id: m.id, result: `skipped: manual — ${m.hint}` });
208
+ out('\n');
209
+ printTable(out, table);
210
+ return failed > 0 ? 1 : 0;
211
+ }
212
+
213
+ /** @param {(s: string) => void} out @param {Array<{id: string, result: string}>} table */
214
+ function printTable(out, table) {
215
+ for (const t of table) out(`${t.id.padEnd(7)} ${t.result}\n`);
216
+ }
217
+
218
+ /** @param {string[]} args @returns {Promise<number>} */
219
+ export default async function tools(args) {
220
+ return runTools(args);
221
+ }
@@ -13,7 +13,7 @@ import path from 'node:path';
13
13
  import { cliNameForProvider } from '../engines/provider-cli.mjs';
14
14
  import { newRunId } from '../state/paths.mjs';
15
15
  import { runRoot } from '../util/tmp.mjs';
16
- import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp } from './local.mjs';
16
+ import { checkCommands, checkConfig, checkJev, checkKeys, checkLedger, checkLinks, checkTmp, checkTools } from './local.mjs';
17
17
  import { configuredProviders, pingRoles, probeCli, probeCodexRules, probeIsolation, probePathDeny } from './probes.mjs';
18
18
  import { row } from './rows.mjs';
19
19
  import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
@@ -28,6 +28,8 @@ import { probeWorkerAndSigner, SIGNER_ROW } from './worker-probe.mjs';
28
28
  * @property {any} [opRead]
29
29
  * @property {typeof import('../decide/jev-client.mjs').askJev} [askJev]
30
30
  * @property {string} [canary] - the path-deny canary (tests).
31
+ * @property {NodeJS.Platform} [platform] - the recommended-tools row (default `process.platform`).
32
+ * @property {(p: string) => boolean} [toolExists] - the recommended-tools row's Solo app check (tests).
31
33
  */
32
34
 
33
35
  /** @param {Record<string, any> | null} cfg @returns {string} a new, private run root for the probes. */
@@ -57,6 +59,13 @@ export async function runDoctor(opts = {}, deps = {}) {
57
59
  rows.push(...(await checkCommands(cfg, env.PATH ?? '')));
58
60
  rows.push(...(await checkJev(cfg, keys.key, { askJev: deps.askJev })));
59
61
  }
62
+ const toolOpts = {
63
+ pathEnv: env.PATH ?? '',
64
+ platform: deps.platform ?? process.platform,
65
+ home: env.HOME || os.homedir(),
66
+ ...(deps.toolExists ? { exists: deps.toolExists } : {}),
67
+ };
68
+ rows.push(...(await checkTools(toolOpts)));
60
69
  rows.push(...(await checkLedger()));
61
70
  rows.push(...checkTmp(cfg));
62
71
  if (!cfg) {
@@ -13,6 +13,8 @@ import { askJev as realAskJev } from '../decide/jev-client.mjs';
13
13
  import { buildQuestionPayload } from '../decide/questions.mjs';
14
14
  import { commandOnPath } from '../install/detect.mjs';
15
15
  import { installsPath, readInstalls, targetResolves } from '../install/link.mjs';
16
+ import { missingTools } from '../install/tools.mjs';
17
+ import { OP_MESSAGES } from '../keys/onepassword.mjs';
16
18
  import { createDefaultKeyStore, resolveKey } from '../keys/store.mjs';
17
19
  import { ledgerDir } from '../ledger/paths.mjs';
18
20
  import { listEntries } from '../util/reaper.mjs';
@@ -71,7 +73,16 @@ export async function checkKeys(cfg, deps) {
71
73
  const store = deps.store ?? (await createDefaultKeyStore(deps.env));
72
74
  const ref = cfg?.system1?.key ?? cfg?.keys?.jev;
73
75
  const res = await resolveKey('jev', { store, env: deps.env, ref: typeof ref === 'string' ? ref : undefined, ...(deps.opRead ? { opRead: deps.opRead } : {}) });
74
- if (res.value === null) return { rows: [row('keys', 'WARN', 'keys', 'jev key not resolved; System 1 falls back to rules')], key: null };
76
+ if (res.value === null) {
77
+ // only fixed texts are shown: a 1Password failure by its classified message (B25), any other
78
+ // reason as a fixed "key store error" — never a reference, a path or tool output
79
+ const known = Object.values(OP_MESSAGES);
80
+ const reasons = Array.isArray(res.errors)
81
+ ? [...new Set(res.errors.map((e) => known.find((m) => typeof e === 'string' && (e === m || e.startsWith(m))) ?? 'key store error'))]
82
+ : [];
83
+ const why = reasons.length > 0 ? ` (${reasons.join('; ')})` : '';
84
+ return { rows: [row('keys', 'WARN', 'keys', `jev key not resolved${why}; System 1 falls back to rules`)], key: null };
85
+ }
75
86
  const exp = res.exp === null ? 'no expiry' : `expires ${new Date(res.exp).toISOString()}`;
76
87
  return { rows: [row('keys', 'OK', 'keys', `jev key from ${res.source}, ${exp}`)], key: res.value };
77
88
  } catch (err) {
@@ -101,6 +112,23 @@ export async function checkCommands(cfg, pathEnv) {
101
112
  return rows;
102
113
  }
103
114
 
115
+ /**
116
+ * The recommended tools of the shared table (B26): one INFO row naming the missing ones, none
117
+ * when all are there. A missing optional tool never WARNs or FAILs.
118
+ * @param {import('../install/tools.mjs').ToolEnv} opts
119
+ * @returns {Promise<Row[]>}
120
+ */
121
+ export async function checkTools(opts) {
122
+ let missing;
123
+ try {
124
+ missing = await missingTools(opts);
125
+ } catch {
126
+ return [row('tools', 'INFO', 'tools', 'could not check recommended tools')];
127
+ }
128
+ if (missing.length === 0) return [];
129
+ return [row('tools', 'INFO', 'tools', `missing recommended tools: ${missing.join(', ')} — run code-forge tools install`)];
130
+ }
131
+
104
132
  /**
105
133
  * One live Jev call (a `risk` question on a two-line state). Without a key it is skipped (WARN);
106
134
  * a bad key or a malformed answer FAILs; a transient failure WARNs.