@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.
@@ -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) → step 5
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 { commandOnPath } from '../detect.mjs';
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
- /** Step 1's recommended tools: detect, print the install command, install only after a per-tool yes. */
48
- export const TOOLS = Object.freeze([
49
- { id: 'solo', command: null, install: null, hint: 'install the Solo app (https://soloterm.com) and add its MCP entry' },
50
- { id: 'codex', command: 'codex', install: ['npm', 'install', '-g', '@openai/codex'], hint: null },
51
- { id: 'grok', command: 'grok', install: null, hint: 'install the Grok CLI from its vendor page' },
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://… | --jev-env NAME | --no-jev] [--engine auto|solo|harness] [--solo-project N]',
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
- stdout.write(`${lines.join('\n')}\nlog: ${log}\n`);
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
- jevSource = jevRef.startsWith('env:') ? 'env' : jevRef.startsWith('op://') ? 'op' : 'keychain';
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
- ({ ref: jevRef, source: jevSource } = await askJevSource(ui, getStore));
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.command === null ? ctx.solo : await commandOnPath(tool.command, { pathEnv: env.PATH ?? '' });
353
+ const present = (tool.id === 'solo' && ctx.solo) || (await detectTool(tool, toolOpts));
294
354
  if (present) {
295
355
  say(`tool ${tool.id}: present`);
296
- } else if (tool.install && values.yes_tools.includes(tool.id)) {
297
- const res = await (deps.installTool ?? ((argv) => exec(argv, { timeoutMs: 600_000, env })))(tool.install);
298
- say(`tool ${tool.id}: ${res.result === 'ok' ? 'installed' : 'install failed'} (${tool.install.join(' ')})`);
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 — ${tool.install ? `install with: ${tool.install.join(' ')}` : tool.hint} (doctor WARNs)`);
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