@codedology/code-forge 0.2.0 → 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.
@@ -0,0 +1,163 @@
1
+ /**
2
+ * The recommended tools (block B26): ONE table shared by `code-forge tools`, `init` step 1 and
3
+ * `doctor`. Each row says how to detect the tool and how to install it on this OS:
4
+ * - an npm package installs with `npm install -g <pkg>` when `npm` is on PATH, else it is manual;
5
+ * - a Homebrew formula/cask installs with `brew install …` on macOS when `brew` is on PATH
6
+ * ("install Homebrew first" when it is not), and is manual (a vendor link) on other systems;
7
+ * - Solo is always manual (a desktop app, then its MCP entry).
8
+ * Detection never spawns a process: `commandOnPath` scans PATH, and Solo's app bundle is a plain
9
+ * existence check. Installs are argv arrays only — the caller runs them through B0 `exec`.
10
+ */
11
+
12
+ import { existsSync } from 'node:fs';
13
+ import path from 'node:path';
14
+ import { commandOnPath } from './detect.mjs';
15
+
16
+ /**
17
+ * @typedef {object} ToolRow
18
+ * @property {string} id
19
+ * @property {string} command - the binary looked up on PATH (and asked for `--version`).
20
+ * @property {string|null} npm - the npm package, installed globally.
21
+ * @property {ReadonlyArray<string>|null} brew - the args after `brew install` (macOS only).
22
+ * @property {string|null} manual - the link (or text) when there is no automatic install.
23
+ * @property {string|null} app - a macOS app bundle name that also counts as installed.
24
+ * @property {string|null} note - printed next to the tool when it is installed.
25
+ */
26
+
27
+ /** @type {ReadonlyArray<Readonly<ToolRow>>} */
28
+ export const TOOLS = Object.freeze([
29
+ Object.freeze({ id: 'claude', command: 'claude', npm: '@anthropic-ai/claude-code', brew: null, manual: null, app: null, note: null }),
30
+ Object.freeze({ id: 'codex', command: 'codex', npm: '@openai/codex', brew: null, manual: null, app: null, note: null }),
31
+ Object.freeze({ id: 'gemini', command: 'gemini', npm: '@google/gemini-cli', brew: null, manual: null, app: null, note: null }),
32
+ Object.freeze({ id: 'grok', command: 'grok', npm: null, brew: Object.freeze(['--cask', 'grok-build']), manual: 'https://x.ai/build', app: null, note: null }),
33
+ Object.freeze({
34
+ id: 'op',
35
+ command: 'op',
36
+ npm: null,
37
+ brew: Object.freeze(['1password-cli']),
38
+ manual: 'https://developer.1password.com/docs/cli/get-started/',
39
+ app: null,
40
+ note: 'turn on 1Password app → Settings → Developer → "Integrate with 1Password CLI"',
41
+ }),
42
+ Object.freeze({
43
+ id: 'solo',
44
+ command: 'solo',
45
+ npm: null,
46
+ brew: null,
47
+ manual: 'https://soloterm.com (desktop app, then add its MCP entry)',
48
+ app: 'Solo.app',
49
+ note: null,
50
+ }),
51
+ ]);
52
+
53
+ /** Every tool id, in table order. */
54
+ export const TOOL_IDS = Object.freeze(TOOLS.map((t) => t.id));
55
+
56
+ /** The hint when a brew install is due but `brew` is not on PATH. */
57
+ export const NO_BREW_HINT = 'install Homebrew first: https://brew.sh';
58
+
59
+ /** The version timeout (`<cmd> --version`). */
60
+ export const VERSION_TIMEOUT_MS = 10_000;
61
+
62
+ /**
63
+ * @typedef {object} ToolEnv
64
+ * @property {string} [pathEnv] - a PATH-shaped string (default `process.env.PATH`).
65
+ * @property {NodeJS.Platform} [platform] - default `process.platform`.
66
+ * @property {string} [home] - for `~/Applications` (default `process.env.HOME`).
67
+ * @property {(p: string) => boolean} [exists] - default `existsSync` (tests: a fake).
68
+ */
69
+
70
+ /** @param {string} id @returns {Readonly<ToolRow>|undefined} */
71
+ export function toolById(id) {
72
+ return TOOLS.find((t) => t.id === id);
73
+ }
74
+
75
+ /**
76
+ * Whether a tool is installed: its command on PATH, or (macOS) its app bundle in `/Applications`
77
+ * or `~/Applications`.
78
+ * @param {Readonly<ToolRow>} tool @param {ToolEnv} [opts]
79
+ * @returns {Promise<boolean>}
80
+ */
81
+ export async function detectTool(tool, opts = {}) {
82
+ const platform = opts.platform ?? process.platform;
83
+ if (await commandOnPath(tool.command, { pathEnv: opts.pathEnv ?? process.env.PATH ?? '', platform })) return true;
84
+ if (tool.app === null || platform !== 'darwin') return false;
85
+ const exists = opts.exists ?? existsSync;
86
+ const home = opts.home ?? process.env.HOME ?? '';
87
+ const places = [path.join('/Applications', tool.app), ...(home ? [path.join(home, 'Applications', tool.app)] : [])];
88
+ return places.some((p) => exists(p));
89
+ }
90
+
91
+ /**
92
+ * @typedef {{kind: 'run', argv: string[]} | {kind: 'manual', hint: string}} InstallPlan
93
+ */
94
+
95
+ /**
96
+ * How to install a tool on this machine: an argv to run, or a manual hint.
97
+ * @param {Readonly<ToolRow>} tool @param {ToolEnv} [opts]
98
+ * @returns {Promise<InstallPlan>}
99
+ */
100
+ export async function installPlan(tool, opts = {}) {
101
+ const platform = opts.platform ?? process.platform;
102
+ const onPath = (/** @type {string} */ cmd) => commandOnPath(cmd, { pathEnv: opts.pathEnv ?? process.env.PATH ?? '', platform });
103
+ if (tool.npm !== null) {
104
+ const argv = ['npm', 'install', '-g', tool.npm];
105
+ // Windows: npm is `npm.cmd`, which only a shell runs — never spawned here, the user runs it
106
+ if (platform === 'win32') return { kind: 'manual', hint: `run: ${argv.join(' ')}` };
107
+ return (await onPath('npm')) ? { kind: 'run', argv } : { kind: 'manual', hint: `install Node.js/npm first, then: ${argv.join(' ')}` };
108
+ }
109
+ if (tool.brew !== null && platform === 'darwin') {
110
+ return (await onPath('brew')) ? { kind: 'run', argv: ['brew', 'install', ...tool.brew] } : { kind: 'manual', hint: NO_BREW_HINT };
111
+ }
112
+ return { kind: 'manual', hint: tool.manual ?? 'no install known' };
113
+ }
114
+
115
+ /**
116
+ * `<cmd> --version` with a 10 s timeout: the first non-empty line of stdout (else of stderr), cut
117
+ * to 120 chars. Any failure (missing, non-zero, timeout, empty) → null.
118
+ * @param {Readonly<ToolRow>} tool
119
+ * @param {{exec: typeof import('../util/exec.mjs').exec, env?: NodeJS.ProcessEnv}} deps
120
+ * @returns {Promise<string|null>}
121
+ */
122
+ export async function toolVersion(tool, { exec, env }) {
123
+ try {
124
+ const res = await exec([tool.command, '--version'], { timeoutMs: VERSION_TIMEOUT_MS, ...(env ? { env } : {}) });
125
+ if (res.result !== 'ok' || res.timedOut) return null;
126
+ const firstLine = (/** @type {unknown} */ text) => String(text ?? '').split('\n').map((l) => l.trim()).find((l) => l.length > 0);
127
+ const first = firstLine(res.stdout) ?? firstLine(res.stderr);
128
+ return first ? first.slice(0, 120) : null;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ /**
135
+ * Why an install failed, as a fixed phrase — never the child's error text.
136
+ * @param {{code?: number|null, signal?: string|null, timedOut?: boolean}} res
137
+ * @returns {string} `timed out`, `signal S`, `exit n` or `did not start`
138
+ */
139
+ export function failureReason(res) {
140
+ if (res.timedOut) return 'timed out';
141
+ if (typeof res.signal === 'string' && res.signal.length > 0) return `signal ${res.signal}`;
142
+ if (typeof res.code === 'number') return `exit ${res.code}`;
143
+ return 'did not start';
144
+ }
145
+
146
+ /**
147
+ * Never rejects: a tool whose check throws counts as missing.
148
+ * @param {ToolEnv} [opts]
149
+ * @returns {Promise<string[]>} the ids of the tools that are not installed, in table order.
150
+ */
151
+ export async function missingTools(opts = {}) {
152
+ const out = [];
153
+ for (const tool of TOOLS) {
154
+ let present = false;
155
+ try {
156
+ present = await detectTool(tool, opts);
157
+ } catch {
158
+ present = false;
159
+ }
160
+ if (!present) out.push(tool.id);
161
+ }
162
+ return out;
163
+ }
@@ -12,7 +12,7 @@ import { defaultsForProvider } from '../../config/defaults/index.mjs';
12
12
  import { detectGates } from '../../gates/detect.mjs';
13
13
  import { detectAgentEnv } from '../agent-env.mjs';
14
14
  import { detectAll } from '../detect.mjs';
15
- import { ANSWER_FLAGS, PROVIDERS } from './flags.mjs';
15
+ import { ANSWER_FLAGS, GATE_NAMES, PROOF_KEYS, PROVIDERS } from './flags.mjs';
16
16
 
17
17
  /** Harnesses that expose a subagent tool (§5.1: Claude Code's `Agent`, [A13]). */
18
18
  export const SUBAGENT_HARNESSES = Object.freeze(['claude']);
@@ -29,28 +29,33 @@ export const COPY_UNTRACKED = Object.freeze({
29
29
  python: ['.env'],
30
30
  });
31
31
 
32
- /** `link_dirs` per stack: the dependency directories an export links instead of copying. */
33
- const LINK_DIRS = Object.freeze({
34
- laravel: ['vendor', 'node_modules'],
35
- php: ['vendor'],
36
- node: ['node_modules'],
37
- python: ['.venv'],
38
- rust: ['target'],
39
- go: [],
40
- unknown: ['vendor', 'node_modules'],
41
- });
32
+ /** The proof profiles {@link proofProfile} can return. */
33
+ const PROFILES = Object.freeze(['laravel', 'php', 'node', 'python', 'rust', 'go', 'unknown']);
42
34
 
43
- /** High-risk paths per stack (v1 §2 step 8: migrations, policies, money, auth/middleware, webhooks). */
44
- const HIGH_PATHS = Object.freeze({
45
- laravel: ['database/migrations/**', 'app/Policies/**', 'app/Http/Middleware/**', '**/*Money*', '**/*Webhook*'],
46
- php: ['**/*Money*', '**/*Webhook*', '**/Auth/**'],
47
- node: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
48
- python: ['**/migrations/**', '**/auth/**', '**/*money*', '**/*webhook*'],
49
- rust: ['**/auth/**', '**/*money*'],
50
- go: ['**/auth/**', '**/*money*'],
51
- unknown: [],
35
+ /**
36
+ * The one source of truth for `link_dirs` (B24): each dependency folder an export links instead of
37
+ * copying, with the manifests that make it right to propose. A folder is proposed when one of its
38
+ * manifests exists, whether or not the dependencies are installed yet.
39
+ */
40
+ export const LINK_DIR_MANIFESTS = Object.freeze({
41
+ vendor: Object.freeze(['composer.json', 'go.mod']),
42
+ node_modules: Object.freeze(['package.json']),
43
+ '.venv': Object.freeze(['pyproject.toml', 'requirements.txt', 'setup.py', 'Pipfile']),
44
+ target: Object.freeze(['Cargo.toml']),
52
45
  });
53
46
 
47
+ /**
48
+ * The `link_dirs` entries whose manifest exists in `cwd`, in {@link LINK_DIR_MANIFESTS} order, each
49
+ * with the manifests found for it.
50
+ * @param {string} cwd
51
+ * @returns {Array<{dir: string, manifests: string[]}>}
52
+ */
53
+ export function linkDirsFromManifests(cwd) {
54
+ return Object.entries(LINK_DIR_MANIFESTS)
55
+ .map(([dir, manifests]) => ({ dir, manifests: manifests.filter((m) => existsSync(path.join(cwd, m))) }))
56
+ .filter((e) => e.manifests.length > 0);
57
+ }
58
+
54
59
  /**
55
60
  * The proof profile is decided from the project, not from the gate detector's stack name alone:
56
61
  * `detectGates` (B5) names only `php-pest`, so a Laravel app on PHPUnit reaches here as `node`
@@ -65,7 +70,7 @@ export function proofProfile(stack, cwd) {
65
70
  if (php && existsSync(path.join(cwd, 'artisan'))) return 'laravel';
66
71
  if (stack.startsWith('php')) return 'php';
67
72
  // a detected non-PHP stack keeps its word; only an undetected project falls back on composer.json
68
- if (stack !== 'unknown' && Object.hasOwn(LINK_DIRS, stack)) return stack;
73
+ if (stack !== 'unknown' && PROFILES.includes(stack)) return stack;
69
74
  return composer ? 'php' : 'unknown';
70
75
  }
71
76
 
@@ -109,16 +114,33 @@ export function proposeJudge(provider) {
109
114
  }
110
115
 
111
116
  /**
112
- * Step 8's proposal for a stack.
117
+ * Step 8's `copy_untracked` candidates for a profile, before the project is looked at.
118
+ * @param {string} profile
119
+ * @returns {string[]}
120
+ */
121
+ export function copyCandidates(profile) {
122
+ return [...(COPY_UNTRACKED[profile] ?? [])];
123
+ }
124
+
125
+ /**
126
+ * Step 8's proposal (B24): nothing is asked. High-risk paths are a deliberate blank (the project
127
+ * never says which paths are high-risk) and isolation is `export`. `link_dirs` is every folder whose
128
+ * manifest ({@link LINK_DIR_MANIFESTS}) exists in `cwd`, installed or not, whatever the profile (a
129
+ * PHP app with a `package.json` links `node_modules` too); `copy_untracked` keeps the profile's
130
+ * candidates that exist in `cwd` themselves. Nothing kept gives `[]`. Without `cwd` nothing can be
131
+ * looked at: `link_dirs` is `[]` and `copy_untracked` the unfiltered candidates.
113
132
  * @param {string} profile
133
+ * @param {string} [cwd]
114
134
  * @returns {{high: string[], isolation: 'export', link_dirs: string[], copy_untracked: string[]}}
115
135
  */
116
- export function proposeProof(profile) {
136
+ export function proposeProof(profile, cwd) {
137
+ const candidates = copyCandidates(profile);
138
+ if (cwd === undefined) return { high: [], isolation: 'export', link_dirs: [], copy_untracked: candidates };
117
139
  return {
118
- high: [...(HIGH_PATHS[profile] ?? [])],
140
+ high: [],
119
141
  isolation: 'export',
120
- link_dirs: [...(LINK_DIRS[profile] ?? LINK_DIRS.unknown)],
121
- copy_untracked: [...(COPY_UNTRACKED[profile] ?? [])],
142
+ link_dirs: linkDirsFromManifests(cwd).map((e) => e.dir),
143
+ copy_untracked: candidates.filter((rel) => existsSync(path.join(cwd, rel))),
122
144
  };
123
145
  }
124
146
 
@@ -272,6 +294,12 @@ function gateArgv(gate) {
272
294
  * @property {boolean} doctor
273
295
  */
274
296
 
297
+ /**
298
+ * Where each gate and each proof value came from (B24): `flag`, `existing` (the current
299
+ * `.code-forge.yml`), `detected` (the project on disk) or `default`.
300
+ * @typedef {{gates: Record<string, 'flag'|'existing'|'detected'>, proof: Record<string, 'flag'|'existing'|'detected'|'default'>}} Origins
301
+ */
302
+
275
303
  /** The answer keys in step order. */
276
304
  export const ANSWER_KEYS = Object.freeze([
277
305
  'tools', 'yes_tools', 'harnesses', 'scope', 'method', 'provider', 'levels', 'refresh_models',
@@ -283,11 +311,13 @@ export const ANSWER_KEYS = Object.freeze([
283
311
  * @param {Record<string, any>|null} existing - the current `.code-forge.yml`, when there is one.
284
312
  * @param {Record<string, any>|null} user - the current user-level config (`~/.code-forge/`).
285
313
  * @param {Array<{flag: string, value: any}>} given - parsed answer flags.
286
- * @returns {{values: Answers, sources: Record<string, 'default'|'existing'|'flag'>}}
314
+ * @returns {{values: Answers, sources: Record<string, 'default'|'existing'|'flag'>, origins: Origins}}
287
315
  */
288
316
  export function resolveAnswers(ctx, existing, user, given) {
289
317
  /** @type {Record<string, 'default'|'existing'|'flag'>} */
290
318
  const sources = {};
319
+ /** @type {Origins} */
320
+ const origins = { gates: {}, proof: {} };
291
321
  const ex = existing ?? {};
292
322
  /** @type {any} */
293
323
  const v = {};
@@ -352,38 +382,64 @@ export function resolveAnswers(ctx, existing, user, given) {
352
382
  set('solo_project', user?.solo?.project_id, () => null);
353
383
  if (lastFlag('solo_project')) override('solo_project', lastFlag('solo_project').value);
354
384
 
385
+ // Gates and proof are never asked (B24): each value comes from a flag, else the existing
386
+ // config, else the project. `origins` records which, per gate and per proof key. A gate the
387
+ // existing config left blank is filled when the project now shows one; a set gate is kept.
355
388
  const detectedGates = {
356
389
  test: gateArgv(ctx.detected.test),
357
390
  lint: gateArgv(ctx.detected.lint),
358
391
  types: gateArgv(ctx.detected.types),
359
392
  format: gateArgv(ctx.detected.format),
360
393
  };
361
- const exGates = ex.gates && typeof ex.gates === 'object' ? ex.gates : undefined;
362
- set('gates', exGates ? { ...detectedGates, ...exGates } : undefined, () => detectedGates);
394
+ const exGates = ex.gates && typeof ex.gates === 'object' ? ex.gates : {};
395
+ // keys beyond the four gates (`extra`, `full_suite_threshold_files`, …) are kept as they are
396
+ const gates = /** @type {Record<string, any>} */ (clone(exGates));
397
+ for (const name of GATE_NAMES) {
398
+ const kept = exGates[name];
399
+ const keep = kept !== undefined && kept !== null;
400
+ gates[name] = keep ? clone(kept) : detectedGates[/** @type {keyof typeof detectedGates} */ (name)];
401
+ origins.gates[name] = keep ? 'existing' : 'detected';
402
+ }
403
+ set('gates', Object.keys(exGates).length > 0 ? gates : undefined, () => gates);
363
404
  if (flagsFor('gates').length > 0) {
364
- const gates = clone(v.gates);
365
- for (const g of flagsFor('gates')) gates[g.value.name] = g.value.argv;
366
- override('gates', gates);
405
+ const flagged = clone(v.gates);
406
+ for (const g of flagsFor('gates')) {
407
+ flagged[g.value.name] = g.value.argv;
408
+ origins.gates[g.value.name] = 'flag';
409
+ }
410
+ override('gates', flagged);
367
411
  }
368
412
 
369
- const proposal = proposeProof(ctx.profile);
370
- const exProof = ex.proof && typeof ex.proof === 'object'
371
- ? {
372
- high: ex.proof.tiers?.high?.paths ?? proposal.high,
373
- isolation: ex.proof.isolation ?? proposal.isolation,
374
- link_dirs: ex.proof.export?.link_dirs ?? proposal.link_dirs,
375
- copy_untracked: ex.proof.export?.copy_untracked ?? proposal.copy_untracked,
376
- }
377
- : undefined;
378
- set('proof', exProof, () => proposal);
413
+ const proposal = proposeProof(ctx.profile, ctx.cwd);
414
+ const exProof = ex.proof && typeof ex.proof === 'object' ? ex.proof : {};
415
+ /** @type {Record<string, unknown>} */
416
+ const exProofValues = {
417
+ high: exProof.tiers?.high?.paths,
418
+ isolation: exProof.isolation,
419
+ link_dirs: exProof.export?.link_dirs,
420
+ copy_untracked: exProof.export?.copy_untracked,
421
+ };
422
+ const proof = /** @type {Record<string, any>} */ ({});
423
+ for (const key of PROOF_KEYS) {
424
+ const kept = exProofValues[key];
425
+ const keep = kept !== undefined && kept !== null;
426
+ proof[key] = keep ? clone(kept) : proposal[/** @type {keyof typeof proposal} */ (key)];
427
+ // without a cwd nothing was looked at, so the lists are defaults, not detections
428
+ const looked = ctx.cwd !== undefined && (key === 'link_dirs' || key === 'copy_untracked');
429
+ origins.proof[key] = keep ? 'existing' : looked ? 'detected' : 'default';
430
+ }
431
+ set('proof', ex.proof && typeof ex.proof === 'object' ? proof : undefined, () => proof);
379
432
  if (flagsFor('proof').length > 0) {
380
- const proof = clone(v.proof);
381
- for (const g of flagsFor('proof')) proof[g.value.key] = g.value.value;
382
- override('proof', proof);
433
+ const flagged = clone(v.proof);
434
+ for (const g of flagsFor('proof')) {
435
+ flagged[g.value.key] = g.value.value;
436
+ origins.proof[g.value.key] = 'flag';
437
+ }
438
+ override('proof', flagged);
383
439
  }
384
440
 
385
441
  set('doctor', undefined, () => true);
386
442
  if (lastFlag('doctor')) override('doctor', false);
387
443
 
388
- return { values: v, sources };
444
+ return { values: v, sources, origins };
389
445
  }
@@ -13,16 +13,17 @@
13
13
  * into any flag by mistake).
14
14
  */
15
15
 
16
- import { isOpRef } from '../../keys/onepassword.mjs';
16
+ import { isOpInput } from '../../keys/onepassword.mjs';
17
17
  import { HARNESSES } from '../harnesses.mjs';
18
+ import { TOOL_IDS } from '../tools.mjs';
18
19
 
19
20
  export const PROVIDERS = Object.freeze(['anthropic', 'openai', 'xai']);
20
21
  /** The engines the wizard offers. `subprocess` is never proposed (R2): a power user types it. */
21
22
  export const ENGINE_CHOICES = Object.freeze(['auto', 'solo', 'harness']);
22
23
  export const GATE_NAMES = Object.freeze(['test', 'lint', 'types', 'format']);
23
24
  export const PROOF_KEYS = Object.freeze(['isolation', 'high', 'link_dirs', 'copy_untracked']);
24
- /** Tools step 1 knows how to detect (and, for some, install after a per-tool yes). */
25
- export const TOOL_IDS = Object.freeze(['solo', 'codex', 'grok', 'gemini', 'op']);
25
+ /** Tools step 1 knows how to detect (and, for some, install after a per-tool yes): the shared table's ids (B26). */
26
+ export { TOOL_IDS };
26
27
 
27
28
  /** flag (as typed) -> the one answer key it overrides. */
28
29
  export const ANSWER_FLAGS = Object.freeze({
@@ -166,8 +167,9 @@ function convert(flag, value) {
166
167
  if (value !== 'on' && value !== 'off') throw new UsageError('--multimodel must be on or off');
167
168
  return value === 'on';
168
169
  case '--jev-ref':
169
- if (!isOpRef(value)) throw new UsageError('--jev-ref must be an op://vault/item/field reference');
170
- return value;
170
+ // shape only here (parsing is sync); an item ID or link is resolved to op:// in run.mjs (B25)
171
+ if (!isOpInput(value)) throw new UsageError('--jev-ref must be a 1Password item ID, item link or op://vault/item/field reference');
172
+ return value.trim();
171
173
  case '--jev-env':
172
174
  if (!ENV_NAME.test(value)) throw new UsageError('--jev-env must be an environment variable name');
173
175
  return value;