@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.
- package/CHANGELOG.md +32 -0
- package/README.md +12 -5
- package/docs/reference/blocks.json +55 -3
- package/package.json +1 -1
- package/skill/scripts/forge +1 -1
- package/src/cli/keys.mjs +39 -9
- package/src/cli/tools.mjs +221 -0
- package/src/doctor/index.mjs +10 -1
- package/src/doctor/local.mjs +29 -1
- package/src/install/tools.mjs +163 -0
- package/src/install/wizard/answers.mjs +102 -46
- package/src/install/wizard/flags.mjs +7 -5
- package/src/install/wizard/run.mjs +94 -31
- package/src/install/wizard/steps.mjs +247 -61
- package/src/install/wizard/summary.mjs +168 -0
- package/src/keys/onepassword.mjs +258 -15
|
@@ -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
|
-
/**
|
|
33
|
-
const
|
|
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
|
-
/**
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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' &&
|
|
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
|
|
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: [
|
|
140
|
+
high: [],
|
|
119
141
|
isolation: 'export',
|
|
120
|
-
link_dirs:
|
|
121
|
-
copy_untracked:
|
|
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 :
|
|
362
|
-
|
|
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
|
|
365
|
-
for (const g of flagsFor('gates'))
|
|
366
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
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
|
|
381
|
-
for (const g of flagsFor('proof'))
|
|
382
|
-
|
|
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 {
|
|
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
|
|
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
|
-
|
|
170
|
-
|
|
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;
|