@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
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* `code-forge init` — the nine-step setup wizard (plan §2; block B13a).
|
|
3
3
|
*
|
|
4
|
-
* Order: answers (defaults ← existing config ← flags; then questions when interactive) →
|
|
4
|
+
* Order: answers (defaults ← existing config ← flags; then questions when interactive) → the
|
|
5
|
+
* "Project settings (detected)" summary of gates and proof (B24; interactive: one "Use these /
|
|
6
|
+
* Customize now / Leave for later" choice, and the eight pre-filled questions on Customize) → step 5
|
|
5
7
|
* key source → build + merge + validate the config (nothing is written when it is invalid or a
|
|
6
8
|
* required answer is missing) → write `.code-forge.yml` only when it changes → tools (step 1),
|
|
7
9
|
* model refresh (3), harness links (2), user config → the §5.1 stop line when the harness has no
|
|
@@ -9,7 +11,7 @@
|
|
|
9
11
|
*
|
|
10
12
|
* Output: an agent harness (`CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AGENT`, `CODEX_SANDBOX*`, `AI_AGENT`) or
|
|
11
13
|
* a non-TTY stdin gets exactly ONE JSON line on stdout — `{ok, wrote, harnesses, engine_stop,
|
|
12
|
-
* doctor, log, log_tail}` on success, `{ok: false, error, wrote, log, log_tail}` on any failure
|
|
14
|
+
* doctor, settings, blank, log, log_tail}` on success, `{ok: false, error, wrote, log, log_tail}` on any failure
|
|
13
15
|
* after the flags parsed; a person gets the lines (and `init: <error>` on stderr). Either way the
|
|
14
16
|
* lines go to `~/.code-forge/logs/init-<ts>.log`, on failure too. Every line passes through
|
|
15
17
|
* `redact`; a key typed at the prompt goes to the key store and nowhere else — the config holds its
|
|
@@ -24,17 +26,19 @@ import { parse as parseYAML } from 'yaml';
|
|
|
24
26
|
import { DEFAULT_CONFIG_FILENAME, loadConfigFile } from '../../config/load.mjs';
|
|
25
27
|
import { refreshFromCliCaches } from '../../config/refresh.mjs';
|
|
26
28
|
import { validateConfig } from '../../config/validate.mjs';
|
|
29
|
+
import { describeOpItem, isOpRef, OP_MESSAGES, toOpRef } from '../../keys/onepassword.mjs';
|
|
27
30
|
import { createDefaultKeyStore, resolveKey } from '../../keys/store.mjs';
|
|
28
31
|
import { exec } from '../../util/exec.mjs';
|
|
29
32
|
import { redact } from '../../util/redact.mjs';
|
|
30
33
|
import { writeAgentJson } from '../agent-env.mjs';
|
|
31
|
-
import {
|
|
34
|
+
import { detectTool, failureReason, installPlan, TOOLS } from '../tools.mjs';
|
|
32
35
|
import { getHarness, globalSkillPath, projectSkillPath } from '../harnesses.mjs';
|
|
33
36
|
import { install, installsPath } from '../link.mjs';
|
|
34
37
|
import { currentHarness, effectiveProvider, gatherContext, judgeCollision, proposeJudge, resolveAnswers, SUBAGENT_HARNESSES } from './answers.mjs';
|
|
35
38
|
import { changedPaths, generatedConfig, mergeConfig, mergeInto, serializeConfig } from './project-file.mjs';
|
|
36
39
|
import { parseInitArgs, UsageError } from './flags.mjs';
|
|
37
|
-
import { askAnswers, askJevSource, CancelledError } from './steps.mjs';
|
|
40
|
+
import { askAnswers, askJevSource, askProjectSettings, askSettingsChoice, CancelledError, HINTS, JEV_LATER_TEXT, withHint } from './steps.mjs';
|
|
41
|
+
import { summarizeSettings } from './summary.mjs';
|
|
38
42
|
|
|
39
43
|
/** §5.1's stop text, printed at setup (not at the first run) when there is no engine to use. */
|
|
40
44
|
export const NO_ENGINE_STOP_TEXT = [
|
|
@@ -44,20 +48,17 @@ export const NO_ENGINE_STOP_TEXT = [
|
|
|
44
48
|
' Then re-run. (R2: this engine is never selected automatically.)',
|
|
45
49
|
].join('\n');
|
|
46
50
|
|
|
47
|
-
/**
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
{ id: 'gemini', command: 'gemini', install: ['npm', 'install', '-g', '@google/gemini-cli'], hint: null },
|
|
53
|
-
{ id: 'op', command: 'op', install: ['brew', 'install', '1password-cli'], hint: null },
|
|
54
|
-
]);
|
|
51
|
+
/**
|
|
52
|
+
* Step 1's recommended tools: the shared table (B26, `src/install/tools.mjs`) — detect, print the
|
|
53
|
+
* install command, install only after a per-tool yes.
|
|
54
|
+
*/
|
|
55
|
+
export { TOOLS };
|
|
55
56
|
|
|
56
57
|
export const USAGE = [
|
|
57
58
|
'usage: code-forge init [--no-interaction] [--tools recommended|current] [--yes-tool <tool>]…',
|
|
58
59
|
' [--harness a,b] [-g|-p] [--copy] [--provider P] [--level Ln=model[:effort][@provider]]…',
|
|
59
60
|
' [--refresh-models] [--multimodel on|off] [--second-provider P]',
|
|
60
|
-
' [--jev-ref op
|
|
61
|
+
' [--jev-ref <item-id|link|op://…> | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
|
|
61
62
|
' [--gate name=cmd]… [--proof isolation=export|lock | high=a,b | link_dirs=a,b | copy_untracked=a,b]… [--skip-doctor]',
|
|
62
63
|
].join('\n');
|
|
63
64
|
|
|
@@ -77,10 +78,11 @@ const JEV_ENV_NAME = 'CODE_FORGE_KEY_JEV';
|
|
|
77
78
|
|
|
78
79
|
/** A failure after the flags parsed: carries the exit code; the message names key paths, never values. */
|
|
79
80
|
class InitFailure extends Error {
|
|
80
|
-
/** @param {number} code @param {string} message */
|
|
81
|
-
constructor(code, message) {
|
|
81
|
+
/** @param {number} code @param {string} message @param {string|null} [kind] - a 1Password failure kind (B25) */
|
|
82
|
+
constructor(code, message, kind = null) {
|
|
82
83
|
super(message);
|
|
83
84
|
this.code = code;
|
|
85
|
+
this.kind = kind;
|
|
84
86
|
}
|
|
85
87
|
}
|
|
86
88
|
|
|
@@ -97,9 +99,12 @@ class InitFailure extends Error {
|
|
|
97
99
|
* @property {import('./steps.mjs').Ui} [ui] - default `@clack/prompts`.
|
|
98
100
|
* @property {() => Promise<any>} [getStore] - the B2 key store (default: the production chain).
|
|
99
101
|
* @property {(opts: {cwd: string, env: NodeJS.ProcessEnv}) => Promise<Array<{status: string, label: string, detail: string}>>} [doctor]
|
|
100
|
-
* @property {(argv: string[]) => Promise<{result: string}>} [installTool]
|
|
102
|
+
* @property {(argv: string[]) => Promise<{result: string, code?: number|null, signal?: string|null, timedOut?: boolean}>} [installTool]
|
|
101
103
|
* @property {string} [skillSource]
|
|
102
104
|
* @property {() => Date} [now]
|
|
105
|
+
* @property {typeof exec} [opExec] - runs `op` to resolve a 1Password item ID (default B0 `exec`).
|
|
106
|
+
* @property {NodeJS.Platform} [platform] - step 1's install plan (brew on macOS only; default `process.platform`).
|
|
107
|
+
* @property {(p: string) => boolean} [toolExists] - step 1's Solo app check (tests: a fake).
|
|
103
108
|
*/
|
|
104
109
|
|
|
105
110
|
/** @param {string} file @returns {Promise<Record<string, any>|null>} a missing or unreadable user config reads as null */
|
|
@@ -164,13 +169,17 @@ export async function runInit(args, deps = {}) {
|
|
|
164
169
|
let engineStop = false;
|
|
165
170
|
/** @type {{ok: boolean, counts: Record<string, number>} | null} */
|
|
166
171
|
let doctor = null;
|
|
172
|
+
/** @type {import('./summary.mjs').SettingsSummary | null} */
|
|
173
|
+
let summary = null;
|
|
174
|
+
// lines already on stdout (the settings summary): `finish` prints only the rest
|
|
175
|
+
let printed = 0;
|
|
167
176
|
|
|
168
177
|
/**
|
|
169
178
|
* The one exit: writes the log, then either the success shape or `{ok: false, error, wrote, …}`.
|
|
170
|
-
* @param {number} code @param {string|null} error
|
|
179
|
+
* @param {number} code @param {string|null} error @param {string|null} [kind] - a 1Password failure kind
|
|
171
180
|
* @returns {Promise<number>}
|
|
172
181
|
*/
|
|
173
|
-
const finish = async (code, error) => {
|
|
182
|
+
const finish = async (code, error, kind = null) => {
|
|
174
183
|
if (error !== null) {
|
|
175
184
|
say(`error: ${error}`);
|
|
176
185
|
stderr.write(redact(`init: ${error}\n`));
|
|
@@ -186,11 +195,12 @@ export async function runInit(args, deps = {}) {
|
|
|
186
195
|
const tail = lines.slice(-10);
|
|
187
196
|
if (jsonMode) {
|
|
188
197
|
const body = error === null
|
|
189
|
-
? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, log, log_tail: tail }
|
|
190
|
-
: { ok: false, error, wrote, log, log_tail: tail };
|
|
198
|
+
? { ok: code === 0, wrote, harnesses: linked, engine_stop: engineStop, doctor, settings: summary?.settings ?? null, blank: summary?.blank ?? [], log, log_tail: tail }
|
|
199
|
+
: { ok: false, error, ...(kind ? { kind } : {}), wrote, log, log_tail: tail };
|
|
191
200
|
writeAgentJson(JSON.parse(redact(JSON.stringify(body))), { stdout });
|
|
192
201
|
} else if (error === null) {
|
|
193
|
-
|
|
202
|
+
const rest = lines.slice(printed);
|
|
203
|
+
stdout.write(`${rest.length > 0 ? `${rest.join('\n')}\n` : ''}log: ${log}\n`);
|
|
194
204
|
}
|
|
195
205
|
return code;
|
|
196
206
|
};
|
|
@@ -215,12 +225,34 @@ export async function runInit(args, deps = {}) {
|
|
|
215
225
|
const userFile = path.join(cfDir, USER_CONFIG_FILE);
|
|
216
226
|
const userCfg = await readUserConfig(userFile);
|
|
217
227
|
|
|
218
|
-
const { values, sources } = resolveAnswers(ctx, existing, userCfg, parsed.given);
|
|
228
|
+
const { values, sources, origins } = resolveAnswers(ctx, existing, userCfg, parsed.given);
|
|
219
229
|
if (interactive) {
|
|
220
230
|
ui = deps.ui ?? /** @type {any} */ (await import('@clack/prompts'));
|
|
221
231
|
await askAnswers(values, sources, ctx, ui);
|
|
222
232
|
}
|
|
223
233
|
|
|
234
|
+
// Gates and proof are never asked (B24): the summary says what was found and what is blank.
|
|
235
|
+
// A person sees it now, before anything is written; agent mode gets it in the JSON line.
|
|
236
|
+
summary = summarizeSettings(values, origins, ctx);
|
|
237
|
+
/** @param {string[]} shown - logged, and printed at once for a person */
|
|
238
|
+
const showNow = (shown) => {
|
|
239
|
+
for (const line of shown) say(line);
|
|
240
|
+
if (jsonMode) return;
|
|
241
|
+
stdout.write(`${lines.slice(printed).join('\n')}\n`);
|
|
242
|
+
printed = lines.length;
|
|
243
|
+
};
|
|
244
|
+
showNow(summary.lines);
|
|
245
|
+
if (interactive) {
|
|
246
|
+
const choice = await askSettingsChoice(ui);
|
|
247
|
+
if (choice === 'customize') {
|
|
248
|
+
const changedKeys = await askProjectSettings(values, origins, ui);
|
|
249
|
+
say(`settings: customized (${changedKeys.length > 0 ? changedKeys.join(', ') : 'no change'})`);
|
|
250
|
+
} else if (choice === 'later') {
|
|
251
|
+
const keys = summary.blank.length > 0 ? summary.blank : Object.keys(summary.settings);
|
|
252
|
+
showNow([`settings: edit .code-forge.yml later: ${keys.join(', ')} — then run \`code-forge validate\``]);
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
224
256
|
// Step 5 — where the Jev key comes from. Only a reference is ever kept: `env:NAME` when the
|
|
225
257
|
// environment holds it, else `user` (the key store holds it, whatever backend the store
|
|
226
258
|
// reports — keychain, file, a cached 1Password read…); the backend name is recorded as the
|
|
@@ -229,16 +261,43 @@ export async function runInit(args, deps = {}) {
|
|
|
229
261
|
const missing = [];
|
|
230
262
|
let jevRef = null;
|
|
231
263
|
let jevSource = 'none';
|
|
264
|
+
const resolveOp = (/** @type {string} */ input) => toOpRef(input, deps.opExec ? { exec: deps.opExec } : {});
|
|
265
|
+
/** @param {import('./steps.mjs').JevAnswer} a */
|
|
266
|
+
const takeAnswer = (a) => {
|
|
267
|
+
jevRef = a.ref;
|
|
268
|
+
jevSource = a.source;
|
|
269
|
+
const found = a.found ? describeOpItem(a.found) : null;
|
|
270
|
+
if (found) say(`keys: jev from ${found}`);
|
|
271
|
+
if (a.ref === null) say(JEV_LATER_TEXT);
|
|
272
|
+
};
|
|
232
273
|
if (values.jev.mode === 'ref') {
|
|
233
274
|
jevRef = values.jev.ref;
|
|
234
|
-
|
|
275
|
+
// B25: a --jev-ref item ID or link becomes a full op:// reference before anything is written
|
|
276
|
+
if (!jevRef.startsWith('env:') && !isOpRef(jevRef) && jevRef !== 'user' && !jevRef.startsWith('keychain:')) {
|
|
277
|
+
/** @type {Awaited<ReturnType<typeof toOpRef>>} */
|
|
278
|
+
let res;
|
|
279
|
+
try {
|
|
280
|
+
res = await resolveOp(jevRef);
|
|
281
|
+
} catch {
|
|
282
|
+
// never the thrown message: it could carry op output
|
|
283
|
+
res = { ref: null, kind: 'op_failed', error: `${OP_MESSAGES.op_failed} (unexpected error)` };
|
|
284
|
+
}
|
|
285
|
+
if (res.ref !== null) {
|
|
286
|
+
takeAnswer({ ref: res.ref, source: 'op', found: { title: res.title, vault: res.vault, field: res.field } });
|
|
287
|
+
} else {
|
|
288
|
+
const error = res.error ?? OP_MESSAGES.op_failed;
|
|
289
|
+
if (interactive) takeAnswer(await askJevSource(ui, getStore, { resolve: resolveOp, pending: { input: jevRef, error } }));
|
|
290
|
+
else throw new InitFailure(2, `--jev-ref: ${error}. Nothing written.`, res.kind ?? 'op_failed');
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
if (jevRef !== null) jevSource = jevRef.startsWith('env:') ? 'env' : jevRef.startsWith('op://') ? 'op' : 'keychain';
|
|
235
294
|
} else if (values.jev.mode === 'auto') {
|
|
236
295
|
const res = await resolveKey('jev', { store: await getStore(), env });
|
|
237
296
|
if (res.value !== null && typeof res.source === 'string') {
|
|
238
297
|
jevRef = res.source === 'env' ? `env:${JEV_ENV_NAME}` : 'user';
|
|
239
298
|
jevSource = res.source;
|
|
240
299
|
} else if (interactive) {
|
|
241
|
-
(
|
|
300
|
+
takeAnswer(await askJevSource(ui, getStore, { resolve: resolveOp }));
|
|
242
301
|
} else {
|
|
243
302
|
missing.push('keys.jev (pass --jev-ref, --jev-env or --no-jev)');
|
|
244
303
|
}
|
|
@@ -278,7 +337,7 @@ export async function runInit(args, deps = {}) {
|
|
|
278
337
|
say(`${DEFAULT_CONFIG_FILENAME}: unchanged`);
|
|
279
338
|
} else {
|
|
280
339
|
if (existing) say(`${DEFAULT_CONFIG_FILENAME}: changes ${changed.join(', ')}`);
|
|
281
|
-
const go = existing && interactive ? await ui.confirm({ message: `Apply ${changed.length} change(s) to ${DEFAULT_CONFIG_FILENAME}?`, initialValue: true }) : true;
|
|
340
|
+
const go = existing && interactive ? await ui.confirm({ message: withHint(`Apply ${changed.length} change(s) to ${DEFAULT_CONFIG_FILENAME}?`, HINTS.apply), initialValue: true }) : true;
|
|
282
341
|
if (ui?.isCancel(go) || go !== true) throw new CancelledError('cancelled — nothing written');
|
|
283
342
|
await writeText(configFile, serializeConfig(merged));
|
|
284
343
|
wrote.push(configFile);
|
|
@@ -289,15 +348,19 @@ export async function runInit(args, deps = {}) {
|
|
|
289
348
|
|
|
290
349
|
// Step 1 — tools.
|
|
291
350
|
if (values.tools === 'recommended') {
|
|
351
|
+
const toolOpts = { pathEnv: env.PATH ?? '', platform: deps.platform ?? process.platform, home, ...(deps.toolExists ? { exists: deps.toolExists } : {}) };
|
|
292
352
|
for (const tool of TOOLS) {
|
|
293
|
-
const present = tool.
|
|
353
|
+
const present = (tool.id === 'solo' && ctx.solo) || (await detectTool(tool, toolOpts));
|
|
294
354
|
if (present) {
|
|
295
355
|
say(`tool ${tool.id}: present`);
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
356
|
+
continue;
|
|
357
|
+
}
|
|
358
|
+
const plan = await installPlan(tool, toolOpts);
|
|
359
|
+
if (plan.kind === 'run' && values.yes_tools.includes(tool.id)) {
|
|
360
|
+
const res = await (deps.installTool ?? ((argv) => exec(argv, { timeoutMs: 600_000, env })))(plan.argv);
|
|
361
|
+
say(`tool ${tool.id}: ${res.result === 'ok' ? 'installed' : `install failed (${failureReason(res)})`} (${plan.argv.join(' ')})`);
|
|
299
362
|
} else {
|
|
300
|
-
say(`tool ${tool.id}: missing — ${
|
|
363
|
+
say(`tool ${tool.id}: missing — ${plan.kind === 'run' ? `install with: ${plan.argv.join(' ')}` : plan.hint} (or: code-forge tools install)`);
|
|
301
364
|
}
|
|
302
365
|
}
|
|
303
366
|
}
|
|
@@ -355,7 +418,7 @@ export async function runInit(args, deps = {}) {
|
|
|
355
418
|
|
|
356
419
|
return await finish(doctor === null || doctor.ok ? 0 : 1, null);
|
|
357
420
|
} catch (err) {
|
|
358
|
-
if (err instanceof InitFailure) return finish(err.code, err.message);
|
|
421
|
+
if (err instanceof InitFailure) return finish(err.code, err.message, err.kind);
|
|
359
422
|
if (err instanceof UsageError) return finish(2, err.message);
|
|
360
423
|
if (err instanceof CancelledError) return finish(1, err.message);
|
|
361
424
|
// an unexpected throw (an installer, a link, the doctor…): the log keeps the stack, redacted
|