@oscharko-dev/keiko-cli 0.2.0

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.
Files changed (77) hide show
  1. package/dist/.tsbuildinfo +1 -0
  2. package/dist/context.d.ts +3 -0
  3. package/dist/context.d.ts.map +1 -0
  4. package/dist/context.js +103 -0
  5. package/dist/doctor.d.ts +24 -0
  6. package/dist/doctor.d.ts.map +1 -0
  7. package/dist/doctor.js +108 -0
  8. package/dist/evaluate.d.ts +8 -0
  9. package/dist/evaluate.d.ts.map +1 -0
  10. package/dist/evaluate.js +270 -0
  11. package/dist/evidence.d.ts +9 -0
  12. package/dist/evidence.d.ts.map +1 -0
  13. package/dist/evidence.js +129 -0
  14. package/dist/gateway-config.d.ts +12 -0
  15. package/dist/gateway-config.d.ts.map +1 -0
  16. package/dist/gateway-config.js +19 -0
  17. package/dist/gen-tests.d.ts +8 -0
  18. package/dist/gen-tests.d.ts.map +1 -0
  19. package/dist/gen-tests.js +216 -0
  20. package/dist/index.d.ts +18 -0
  21. package/dist/index.d.ts.map +1 -0
  22. package/dist/index.js +26 -0
  23. package/dist/init.d.ts +9 -0
  24. package/dist/init.d.ts.map +1 -0
  25. package/dist/init.js +122 -0
  26. package/dist/install-layout.d.ts +19 -0
  27. package/dist/install-layout.d.ts.map +1 -0
  28. package/dist/install-layout.js +76 -0
  29. package/dist/investigate.d.ts +9 -0
  30. package/dist/investigate.d.ts.map +1 -0
  31. package/dist/investigate.js +249 -0
  32. package/dist/launcher-paths.d.ts +4 -0
  33. package/dist/launcher-paths.d.ts.map +1 -0
  34. package/dist/launcher-paths.js +69 -0
  35. package/dist/launcher-platforms.d.ts +25 -0
  36. package/dist/launcher-platforms.d.ts.map +1 -0
  37. package/dist/launcher-platforms.js +131 -0
  38. package/dist/launcher-state.d.ts +25 -0
  39. package/dist/launcher-state.d.ts.map +1 -0
  40. package/dist/launcher-state.js +228 -0
  41. package/dist/launcher.d.ts +21 -0
  42. package/dist/launcher.d.ts.map +1 -0
  43. package/dist/launcher.js +439 -0
  44. package/dist/lifecycle.d.ts +22 -0
  45. package/dist/lifecycle.d.ts.map +1 -0
  46. package/dist/lifecycle.js +425 -0
  47. package/dist/memory.d.ts +14 -0
  48. package/dist/memory.d.ts.map +1 -0
  49. package/dist/memory.js +290 -0
  50. package/dist/models.d.ts +4 -0
  51. package/dist/models.d.ts.map +1 -0
  52. package/dist/models.js +62 -0
  53. package/dist/prompt-enhancer.d.ts +13 -0
  54. package/dist/prompt-enhancer.d.ts.map +1 -0
  55. package/dist/prompt-enhancer.js +261 -0
  56. package/dist/repair.d.ts +10 -0
  57. package/dist/repair.d.ts.map +1 -0
  58. package/dist/repair.js +402 -0
  59. package/dist/run.d.ts +10 -0
  60. package/dist/run.d.ts.map +1 -0
  61. package/dist/run.js +269 -0
  62. package/dist/runner.d.ts +7 -0
  63. package/dist/runner.d.ts.map +1 -0
  64. package/dist/runner.js +108 -0
  65. package/dist/state-paths.d.ts +43 -0
  66. package/dist/state-paths.d.ts.map +1 -0
  67. package/dist/state-paths.js +396 -0
  68. package/dist/ui.d.ts +39 -0
  69. package/dist/ui.d.ts.map +1 -0
  70. package/dist/ui.js +450 -0
  71. package/dist/uninstall.d.ts +10 -0
  72. package/dist/uninstall.d.ts.map +1 -0
  73. package/dist/uninstall.js +345 -0
  74. package/dist/verify.d.ts +3 -0
  75. package/dist/verify.d.ts.map +1 -0
  76. package/dist/verify.js +108 -0
  77. package/package.json +42 -0
package/dist/repair.js ADDED
@@ -0,0 +1,402 @@
1
+ // `keiko repair` — an offline, deterministic remediation pass that fixes a broken or
2
+ // half-installed local Keiko state so a user gets a working install without a full
3
+ // reinstall. CLI-only surface; no model call, no network, no server change.
4
+ //
5
+ // Each check reports one of: `ok` (healthy), `would-fix`/`fixed` (a safe automatic
6
+ // remediation), or `action` (needs a user step the command will not take for them).
7
+ // Checks REUSE existing modules rather than reimplementing their logic:
8
+ // - stale UI pid + state-dir permissions via `state-paths.ts` / `lifecycle.ts` semantics
9
+ // - launcher record drift via `launcher-state.ts` (home-contained, content-hash verified)
10
+ // - build/install layout via `install-layout.ts`
11
+ // - stale global-vs-local launch path via `doctor.ts`
12
+ // - gateway config presence via `gateway-config.ts`
13
+ //
14
+ // Exit code: 0 when the system is healthy or every issue was repaired; 1 when an
15
+ // `action` item remains (so scripts can detect "manual step required"). `--dry-run`
16
+ // reports without changing anything and exits 1 if any issue (fixable or action) exists.
17
+ import { chmodSync, existsSync, readFileSync, rmSync, statSync } from "node:fs";
18
+ import { homedir as defaultHomedir } from "node:os";
19
+ import { isAbsolute, join } from "node:path";
20
+ import { collectDoctorReport } from "./doctor.js";
21
+ import { resolvePreferredInstallLayout } from "./install-layout.js";
22
+ import { resolveConfigPathFromArgs } from "./gateway-config.js";
23
+ import { hashContent, loadState, removeEntry, saveState, } from "./launcher-state.js";
24
+ import { RUNTIME_STATE_DIR_MODE, RUNTIME_STATE_FILE_MODE, classifyPid, defaultIsProcessAlive, inspectStateRoot, resolveStateDir, scanRuntimeState, } from "./state-paths.js";
25
+ import { credentialStorePath, hasPlaintextGatewayCredentials, } from "@oscharko-dev/keiko-server/credential-vault";
26
+ import { readLocalVaultReferences } from "@oscharko-dev/keiko-security/secret-vault";
27
+ const USAGE = `Usage:
28
+ keiko repair [--state-dir PATH] [--config PATH] [--dry-run]
29
+
30
+ Runs an offline diagnostic-and-repair pass over the local Keiko install:
31
+ - removes a stale UI pid file left by an unclean shutdown
32
+ - tightens the .keiko state directory permissions to 0o700
33
+ - tightens known Keiko-owned runtime artifacts (DBs, Evidence/QI, credential
34
+ vaults, sidecars) to owner-only 0o700/0o600 without touching customer files
35
+ - prunes launcher records whose shortcut files were deleted
36
+ - verifies the built CLI/UI assets and the launch path
37
+ - validates a configured model-gateway config file
38
+ - flags lingering plaintext credentials in the config
39
+
40
+ Options:
41
+ --state-dir PATH inspect this state directory instead of <cwd>/.keiko.
42
+ --config PATH validate this model-gateway config file (else KEIKO_CONFIG_FILE).
43
+ --dry-run report findings without changing anything.
44
+
45
+ Exit code: 0 when the install is healthy or every issue was repaired; 1 when an item
46
+ needs manual action (with --dry-run, also when a fixable item is found).
47
+ `;
48
+ function ok(name, detail) {
49
+ return { name, status: "ok", detail };
50
+ }
51
+ function fixed(name, detail) {
52
+ return { name, status: "fixed", detail };
53
+ }
54
+ function fixable(name, detail) {
55
+ return { name, status: "fixable", detail };
56
+ }
57
+ function action(name, detail) {
58
+ return { name, status: "action-required", detail };
59
+ }
60
+ function readFlagValue(args, index) {
61
+ const value = args[index + 1];
62
+ return value === undefined || value.startsWith("--") ? null : value;
63
+ }
64
+ function parseRepairArgs(args) {
65
+ let dryRun = false;
66
+ let stateDirArg;
67
+ for (let i = 0; i < args.length; i += 1) {
68
+ const arg = args[i];
69
+ if (arg === "--help" || arg === "-h")
70
+ return "help";
71
+ if (arg === "--dry-run")
72
+ dryRun = true;
73
+ else if (arg === "--state-dir" || arg === "--config") {
74
+ const value = readFlagValue(args, i);
75
+ if (value === null)
76
+ return null;
77
+ if (arg === "--state-dir")
78
+ stateDirArg = value;
79
+ i += 1;
80
+ }
81
+ else
82
+ return null;
83
+ }
84
+ return { dryRun, stateDirArg };
85
+ }
86
+ function checkStalePid(stateDir, isAlive, dryRun) {
87
+ const pidPath = join(stateDir, "ui.pid");
88
+ const probe = classifyPid(pidPath, isAlive);
89
+ if (probe.state === "absent")
90
+ return ok("UI process state", "no pid file recorded");
91
+ if (probe.state === "running")
92
+ return ok("UI process state", `running (pid ${String(probe.pid)})`);
93
+ if (dryRun)
94
+ return fixable("UI process state", `stale pid file (pid ${String(probe.pid)} not running)`);
95
+ rmSync(pidPath, { force: true });
96
+ return fixed("UI process state", `removed stale pid file (pid ${String(probe.pid)})`);
97
+ }
98
+ function checkStateDirPerms(stateDir, dryRun) {
99
+ if (!existsSync(stateDir))
100
+ return ok("State directory", "not present (created on next start)");
101
+ if (process.platform === "win32")
102
+ return ok("State directory", "permission check not applicable on Windows");
103
+ const mode = statSync(stateDir).mode & 0o777;
104
+ if (mode === 0o700)
105
+ return ok("State directory", "permissions are 0o700");
106
+ const observed = `0o${mode.toString(8)}`;
107
+ if (dryRun)
108
+ return fixable("State directory", `permissions ${observed} (expected 0o700)`);
109
+ chmodSync(stateDir, 0o700);
110
+ return fixed("State directory", `tightened permissions ${observed} -> 0o700`);
111
+ }
112
+ function stateRootRefusal(root) {
113
+ if (root.status === "symlink")
114
+ return action("State directory", `refusing to inspect symlinked state directory: ${root.absPath}`);
115
+ if (root.status === "not-directory")
116
+ return action("State directory", `refusing to inspect non-directory state path: ${root.absPath}`);
117
+ return undefined;
118
+ }
119
+ // Issue #1321: audit and tighten the permissions of EVERY Keiko-owned artifact under the
120
+ // state directory — UI/Memory/Knowledge DBs and their WAL/SHM sidecars, Evidence/QI records,
121
+ // the sealed credential vaults, config, and lifecycle/launcher files — not just the state
122
+ // directory itself. The set is the allowlisted manifest in `state-paths.ts`; an unrecognized
123
+ // customer file is never chmod-ed. Content-free: reports relative paths and octal modes only.
124
+ const RUNTIME_STATE_LABEL = {
125
+ lifecycle: "lifecycle files",
126
+ launcher: "launcher state",
127
+ "ui-database": "UI database",
128
+ "gateway-config": "gateway config",
129
+ "credential-vault": "credential vault",
130
+ "memory-vault": "memory vault",
131
+ "local-knowledge": "Local Knowledge store",
132
+ evidence: "Evidence store",
133
+ "quality-intelligence": "Quality Intelligence store",
134
+ };
135
+ // Records every node not already at `targetMode`, applying the fix unless this is a dry-run.
136
+ function tightenNodes(nodes, targetMode, dryRun, findings) {
137
+ for (const node of nodes) {
138
+ const mode = statSync(node.absPath).mode & 0o777;
139
+ if (mode === targetMode)
140
+ continue;
141
+ findings.push({
142
+ category: node.category,
143
+ relPath: node.relPath,
144
+ observed: `0o${mode.toString(8)}`,
145
+ });
146
+ if (!dryRun)
147
+ chmodSync(node.absPath, targetMode);
148
+ }
149
+ }
150
+ function summarizeLooseCategory(category, findings, dryRun) {
151
+ const matches = findings.filter((f) => f.category === category);
152
+ const example = matches[0];
153
+ const detail = `${String(matches.length)} ${RUNTIME_STATE_LABEL[category]} artifact(s) group/world-readable (e.g. ${example?.relPath ?? "?"} ${example?.observed ?? "?"})`;
154
+ const name = "Runtime state artifacts";
155
+ return dryRun ? fixable(name, detail) : fixed(name, detail);
156
+ }
157
+ function checkRuntimeStateArtifacts(stateDir, dryRun) {
158
+ if (process.platform === "win32") {
159
+ return [
160
+ ok("Runtime state artifacts", "POSIX permission normalization not applicable on Windows (NTFS ACLs govern access)"),
161
+ ];
162
+ }
163
+ const scan = scanRuntimeState(stateDir);
164
+ if (!scan.present)
165
+ return [ok("Runtime state artifacts", "state directory not present")];
166
+ const ownedCount = scan.files.length + scan.directories.length;
167
+ const refusedOwned = scan.retained.filter((r) => r.owned && (r.reason === "symlink" || r.reason === "hardlink"));
168
+ if (ownedCount === 0 && refusedOwned.length === 0) {
169
+ return [ok("Runtime state artifacts", "no Keiko-owned artifacts present")];
170
+ }
171
+ const findings = [];
172
+ tightenNodes(scan.directories, RUNTIME_STATE_DIR_MODE, dryRun, findings);
173
+ tightenNodes(scan.files, RUNTIME_STATE_FILE_MODE, dryRun, findings);
174
+ const results = [];
175
+ for (const category of new Set(findings.map((f) => f.category))) {
176
+ results.push(summarizeLooseCategory(category, findings, dryRun));
177
+ }
178
+ for (const entry of refusedOwned) {
179
+ const kind = entry.reason === "symlink" ? "symlink" : "hardlink";
180
+ results.push(action("Runtime state artifacts", `${kind} occupies a Keiko-owned path and was left untouched: ${entry.relPath}`));
181
+ }
182
+ if (results.length === 0) {
183
+ results.push(ok("Runtime state artifacts", `${String(ownedCount)} artifact(s) have owner-only permissions`));
184
+ }
185
+ return results;
186
+ }
187
+ function classifyLauncherEntry(entry) {
188
+ if (!existsSync(entry.path))
189
+ return "missing";
190
+ try {
191
+ return hashContent(readFileSync(entry.path, "utf8")) === entry.contentSha256
192
+ ? "ok"
193
+ : "modified";
194
+ }
195
+ catch {
196
+ return "modified";
197
+ }
198
+ }
199
+ function summarizeLauncher(state, stateDir, dryRun) {
200
+ let missing = 0;
201
+ let modified = 0;
202
+ let healthy = 0;
203
+ let next = state;
204
+ for (const entry of state.entries) {
205
+ const health = classifyLauncherEntry(entry);
206
+ if (health === "missing") {
207
+ missing += 1;
208
+ next = removeEntry(next, entry.path);
209
+ }
210
+ else if (health === "modified")
211
+ modified += 1;
212
+ else
213
+ healthy += 1;
214
+ }
215
+ if (modified > 0)
216
+ return action("Launcher records", `${String(modified)} shortcut(s) modified — run \`keiko launcher remove\` then re-install`);
217
+ if (missing === 0)
218
+ return ok("Launcher records", `${String(healthy)} shortcut(s) verified`);
219
+ if (dryRun)
220
+ return fixable("Launcher records", `${String(missing)} dangling record(s) for deleted shortcut(s)`);
221
+ saveState(stateDir, next);
222
+ return fixed("Launcher records", `pruned ${String(missing)} dangling record(s)`);
223
+ }
224
+ function checkLauncherRecords(stateDir, homedir, io, dryRun) {
225
+ const state = loadState(stateDir, {
226
+ homedir,
227
+ onWarn: (msg) => {
228
+ io.err(msg);
229
+ },
230
+ });
231
+ if (state.entries.length === 0)
232
+ return ok("Launcher records", "no shortcuts recorded");
233
+ return summarizeLauncher(state, stateDir, dryRun);
234
+ }
235
+ function checkInstallLayout(cwd, env) {
236
+ // The UI static export is what `keiko start` / `keiko ui` serve. The bin shim exports
237
+ // KEIKO_UI_STATIC_ROOT for the running install (so a global install resolves even when
238
+ // run outside a project), while a local checkout/install resolves via
239
+ // resolvePreferredInstallLayout. Either reachable -> ok.
240
+ const staticRoot = env.KEIKO_UI_STATIC_ROOT ?? process.env.KEIKO_UI_STATIC_ROOT;
241
+ if (typeof staticRoot === "string" &&
242
+ staticRoot.length > 0 &&
243
+ existsSync(join(staticRoot, "index.html")))
244
+ return ok("Install layout", "UI static export present");
245
+ if (resolvePreferredInstallLayout(cwd) !== undefined)
246
+ return ok("Install layout", "built CLI and UI assets present");
247
+ return action("Install layout", "UI assets not found — reinstall `npm install @oscharko-dev/keiko` or rebuild `npm run build`");
248
+ }
249
+ function checkLaunchPath(cwd, argv) {
250
+ const report = collectDoctorReport({ cwd, argv });
251
+ if (report.warning === undefined)
252
+ return ok("Launch path", "no stale-launch mismatch detected");
253
+ return action("Launch path", report.warning.split("\n")[0] ?? "stale launch path detected (see `keiko doctor`)");
254
+ }
255
+ function checkGatewayConfig(args, env) {
256
+ const resolution = resolveConfigPathFromArgs(args, env);
257
+ if (resolution.kind === "not-configured")
258
+ return ok("Gateway config", "no config file configured (set up on first run)");
259
+ if (resolution.kind === "missing-value")
260
+ return action("Gateway config", "--config flag is missing its value");
261
+ if (!existsSync(resolution.path))
262
+ return action("Gateway config", `configured file not found: ${resolution.path}`);
263
+ try {
264
+ JSON.parse(readFileSync(resolution.path, "utf8"));
265
+ }
266
+ catch {
267
+ return action("Gateway config", `configured file is not valid JSON: ${resolution.path}`);
268
+ }
269
+ return ok("Gateway config", `valid JSON at ${resolution.path}`);
270
+ }
271
+ // Issue #1320: detect an unmigrated or partially migrated config — one that still holds a plaintext
272
+ // provider apiKey or Figma accessToken. Credentials must live in encrypted local storage, with only
273
+ // non-secret references in the JSON file; `keiko ui` performs the one-time, crash-aware migration, so
274
+ // lingering plaintext here is the signal that a migration never ran or was interrupted.
275
+ function defaultLocalGatewayConfigPath(env, homedir) {
276
+ const dataDir = env.KEIKO_UI_DATA_DIR;
277
+ if (dataDir !== undefined && dataDir.length > 0 && isAbsolute(dataDir)) {
278
+ return join(dataDir, "keiko.config.json");
279
+ }
280
+ return join(homedir, ".keiko", "keiko.config.json");
281
+ }
282
+ function credentialConfigPath(args, env, defaultConfigPath) {
283
+ const resolution = resolveConfigPathFromArgs(args, env);
284
+ if (resolution.kind === "path") {
285
+ return resolution.path;
286
+ }
287
+ if (resolution.kind === "not-configured") {
288
+ return defaultConfigPath;
289
+ }
290
+ return undefined;
291
+ }
292
+ function checkCredentialStorage(args, env, defaultConfigPath) {
293
+ const configPath = credentialConfigPath(args, env, defaultConfigPath);
294
+ if (configPath === undefined || !existsSync(configPath)) {
295
+ return ok("Credential storage", "no config file to inspect");
296
+ }
297
+ let raw;
298
+ try {
299
+ raw = JSON.parse(readFileSync(configPath, "utf8"));
300
+ }
301
+ catch {
302
+ // Invalid JSON is already reported by the gateway-config check; avoid a duplicate action item.
303
+ return ok("Credential storage", "config not parseable (reported above)");
304
+ }
305
+ if (hasPlaintextGatewayCredentials(raw)) {
306
+ return action("Credential storage", "plaintext credentials present in config — start `keiko ui` to migrate them into encrypted storage");
307
+ }
308
+ const orphaned = orphanedSecretRefs(raw, configPath);
309
+ if (orphaned > 0) {
310
+ return action("Credential storage", `${String(orphaned)} credential reference(s) have no encrypted entry — incomplete or interrupted migration; start \`keiko ui\` to complete it`);
311
+ }
312
+ return ok("Credential storage", "no plaintext credentials in config");
313
+ }
314
+ // Counts provider `apiKeySecretRef` values in the config that have no matching entry in the encrypted
315
+ // credential vault — the signature of an interrupted migration or a deleted/corrupt vault store.
316
+ // Reads only the non-secret reference index (no vault key resolution, no decryption).
317
+ function orphanedSecretRefs(raw, configPath) {
318
+ if (typeof raw !== "object" || raw === null)
319
+ return 0;
320
+ const providers = raw.providers;
321
+ if (!Array.isArray(providers))
322
+ return 0;
323
+ const refs = providers
324
+ .map((provider) => typeof provider === "object" && provider !== null
325
+ ? provider.apiKeySecretRef
326
+ : undefined)
327
+ .filter((ref) => typeof ref === "string" && ref.length > 0);
328
+ if (refs.length === 0)
329
+ return 0;
330
+ const vaulted = new Set(readLocalVaultReferences(credentialStorePath(configPath)));
331
+ return refs.filter((ref) => !vaulted.has(ref)).length;
332
+ }
333
+ function resolveDeps(deps) {
334
+ return {
335
+ cwd: deps.cwd ?? process.cwd(),
336
+ argv: deps.argv ?? process.argv,
337
+ homedir: deps.homedir ?? defaultHomedir,
338
+ isProcessAlive: deps.isProcessAlive ?? defaultIsProcessAlive,
339
+ };
340
+ }
341
+ const TAG = {
342
+ ok: "ok",
343
+ fixed: "fixed",
344
+ fixable: "would-fix",
345
+ "action-required": "action",
346
+ };
347
+ function reportResults(io, results) {
348
+ io.out("Keiko repair\n");
349
+ for (const r of results) {
350
+ io.out(` [${TAG[r.status]}] ${r.name}: ${r.detail}\n`);
351
+ }
352
+ }
353
+ function exitCodeFor(results, dryRun) {
354
+ const hasAction = results.some((r) => r.status === "action-required");
355
+ const hasFixable = results.some((r) => r.status === "fixable");
356
+ if (dryRun)
357
+ return hasAction || hasFixable ? 1 : 0;
358
+ return hasAction ? 1 : 0;
359
+ }
360
+ export function runRepairCli(args, io, env, deps = {}) {
361
+ const parsed = parseRepairArgs(args);
362
+ if (parsed === "help") {
363
+ io.out(USAGE);
364
+ return 0;
365
+ }
366
+ if (parsed === null) {
367
+ io.err(USAGE);
368
+ return 2;
369
+ }
370
+ const resolved = resolveDeps(deps);
371
+ const stateDir = resolveStateDir(resolved.cwd, env, parsed.stateDirArg);
372
+ const defaultConfigPath = defaultLocalGatewayConfigPath(env, resolved.homedir());
373
+ const stateRoot = inspectStateRoot(stateDir);
374
+ const stateRootAction = stateRootRefusal(stateRoot);
375
+ const stateResults = stateRootAction === undefined
376
+ ? [
377
+ checkStalePid(stateDir, resolved.isProcessAlive, parsed.dryRun),
378
+ checkStateDirPerms(stateDir, parsed.dryRun),
379
+ ...checkRuntimeStateArtifacts(stateDir, parsed.dryRun),
380
+ checkLauncherRecords(stateDir, resolved.homedir(), io, parsed.dryRun),
381
+ ]
382
+ : [stateRootAction];
383
+ const results = [
384
+ ...stateResults,
385
+ checkInstallLayout(resolved.cwd, env),
386
+ checkLaunchPath(resolved.cwd, resolved.argv),
387
+ checkGatewayConfig(args, env),
388
+ checkCredentialStorage(args, env, defaultConfigPath),
389
+ ];
390
+ reportResults(io, results);
391
+ const code = exitCodeFor(results, parsed.dryRun);
392
+ io.out(summaryMessage(results, code));
393
+ return code;
394
+ }
395
+ function summaryMessage(results, code) {
396
+ if (code === 0)
397
+ return "\nKeiko repair: system is healthy.\n";
398
+ if (results.some((r) => r.status === "action-required"))
399
+ return "\nKeiko repair: review the items marked `action` above.\n";
400
+ // dry-run with only fixable items: nothing needs manual action, the fixes are pending.
401
+ return "\nKeiko repair: run `keiko repair` (without --dry-run) to apply the fixes above.\n";
402
+ }
package/dist/run.d.ts ADDED
@@ -0,0 +1,10 @@
1
+ import { type EnvSource } from "@oscharko-dev/keiko-model-gateway";
2
+ import { type ModelPort } from "@oscharko-dev/keiko-harness";
3
+ import { type EvidenceStore } from "@oscharko-dev/keiko-evidence";
4
+ import type { CliIo } from "./runner.js";
5
+ export interface RunDeps {
6
+ readonly store?: EvidenceStore | undefined;
7
+ readonly model?: ModelPort | undefined;
8
+ }
9
+ export declare function runAgentCli(args: readonly string[], io: CliIo, env?: EnvSource, deps?: RunDeps): Promise<number>;
10
+ //# sourceMappingURL=run.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run.d.ts","sourceRoot":"","sources":["../src/run.ts"],"names":[],"mappings":"AAUA,OAAO,EAQL,KAAK,SAAS,EACf,MAAM,mCAAmC,CAAC;AAC3C,OAAO,EAAoC,KAAK,SAAS,EAAE,MAAM,6BAA6B,CAAC;AAQ/F,OAAO,EAGL,KAAK,aAAa,EACnB,MAAM,8BAA8B,CAAC;AAGtC,OAAO,KAAK,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAyBzC,MAAM,WAAW,OAAO;IACtB,QAAQ,CAAC,KAAK,CAAC,EAAE,aAAa,GAAG,SAAS,CAAC;IAC3C,QAAQ,CAAC,KAAK,CAAC,EAAE,SAAS,GAAG,SAAS,CAAC;CACxC;AA4PD,wBAAsB,WAAW,CAC/B,IAAI,EAAE,SAAS,MAAM,EAAE,EACvB,EAAE,EAAE,KAAK,EACT,GAAG,GAAE,SAAc,EACnB,IAAI,GAAE,OAAY,GACjB,OAAO,CAAC,MAAM,CAAC,CAsCjB"}
package/dist/run.js ADDED
@@ -0,0 +1,269 @@
1
+ // `keiko run` — the dry-run task command. It builds an AgentSession with the configured model
2
+ // gateway and a dry-run tool port (provider call is real, tools are non-mutating) and renders the
3
+ // HarnessEvent stream to CliIo. Since ADR-0010 it ALSO writes a redacted evidence manifest by
4
+ // default (evidence is the product value): a tee EventSink forwards every event to BOTH a
5
+ // MemoryEventSink (which retains raw content to assemble the replay manifest) and the existing
6
+ // CliEventSink (whose summarisers never print sensitive fields). After the run resolves, the audit
7
+ // layer builds + redacts + persists the manifest and the EvidenceReport is printed. Writing is on by
8
+ // default; --no-evidence disables it, --evidence-dir relocates it. Tests inject an in-memory
9
+ // EvidenceStore via deps so no write ever touches the repository tree.
10
+ import { ConfigInvalidError, Gateway, GatewayError, assertConfiguredModel, redact, resolveCostClass, selectConfiguredModel, } from "@oscharko-dev/keiko-model-gateway";
11
+ import { DryRunToolPort, GatewayModelPort } from "@oscharko-dev/keiko-harness";
12
+ import { createSession, HARNESS_VERSION } from "@oscharko-dev/keiko-harness";
13
+ import { CliEventSink, MemoryEventSink } from "@oscharko-dev/keiko-harness";
14
+ import { DEFAULT_LIMITS } from "@oscharko-dev/keiko-harness";
15
+ import { persistEvidence } from "@oscharko-dev/keiko-evidence";
16
+ import { renderEvidenceReport } from "@oscharko-dev/keiko-evidence";
17
+ import { createNodeEvidenceStore, resolveEvidenceDir, } from "@oscharko-dev/keiko-evidence";
18
+ import { AuditError } from "@oscharko-dev/keiko-evidence";
19
+ import { loadGatewayConfigFromFile } from "./gateway-config.js";
20
+ const TASK_TYPES = new Set([
21
+ "generate-unit-tests",
22
+ "investigate-bug",
23
+ "explain-plan",
24
+ ]);
25
+ const USAGE = `Usage:
26
+ keiko run explain-plan --file PATH [--question TEXT]
27
+ keiko run generate-unit-tests --file PATH [--function NAME]
28
+ keiko run investigate-bug --description TEXT [--file PATH]
29
+
30
+ Evidence flags (a redacted manifest is written by default):
31
+ --no-evidence Do not write an evidence manifest.
32
+ --evidence-dir PATH Write evidence under PATH (default $KEIKO_EVIDENCE_DIR or ./.keiko/evidence).
33
+ --include-reasoning Include redacted reasoning entries in the manifest.
34
+ --include-diff Include the redacted proposed diff in the manifest.
35
+ --config PATH Gateway config file (or set KEIKO_CONFIG_FILE).
36
+ --model MODEL_ID Configured model id to use.
37
+
38
+ All tasks run in dry-run mode for tools/files: a patch is proposed as an event, never written to disk.
39
+ `;
40
+ function flag(args, name) {
41
+ const i = args.indexOf(name);
42
+ if (i === -1) {
43
+ return undefined;
44
+ }
45
+ const value = args[i + 1];
46
+ return value === undefined || value.startsWith("--") ? undefined : value;
47
+ }
48
+ function parseEvidenceFlags(args) {
49
+ return {
50
+ write: !args.includes("--no-evidence"),
51
+ evidenceDirFlag: flag(args, "--evidence-dir"),
52
+ includeReasoning: args.includes("--include-reasoning"),
53
+ includeDiff: args.includes("--include-diff"),
54
+ model: flag(args, "--model"),
55
+ config: flag(args, "--config"),
56
+ };
57
+ }
58
+ function parseTask(taskType, args) {
59
+ const file = flag(args, "--file");
60
+ if (taskType === "explain-plan") {
61
+ if (file === undefined) {
62
+ return null;
63
+ }
64
+ return { taskType, input: { filePath: file, question: flag(args, "--question") } };
65
+ }
66
+ if (taskType === "generate-unit-tests") {
67
+ if (file === undefined) {
68
+ return null;
69
+ }
70
+ return { taskType, input: { filePath: file, targetFunction: flag(args, "--function") } };
71
+ }
72
+ const description = flag(args, "--description");
73
+ if (description === undefined) {
74
+ return null;
75
+ }
76
+ return { taskType, input: { description, filePaths: file === undefined ? undefined : [file] } };
77
+ }
78
+ // Forwards each event to every wrapped sink. retainsRawContent is true so the harness emits raw
79
+ // SENSITIVE fields — required for the MemoryEventSink's faithful replay manifest. The CliEventSink
80
+ // summarisers never print those fields, and the audit layer redacts before anything is persisted.
81
+ function teeSink(sinks) {
82
+ return {
83
+ retainsRawContent: true,
84
+ emit: (event) => {
85
+ const redacted = redactEventForNonRetainingSink(event);
86
+ for (const sink of sinks) {
87
+ sink.emit(sink.retainsRawContent === true ? event : redacted);
88
+ }
89
+ },
90
+ };
91
+ }
92
+ function redactEventForNonRetainingSink(event) {
93
+ if (event.type === "run:failed") {
94
+ return redactRunFailedEvent(event);
95
+ }
96
+ if (event.type === "run:completed") {
97
+ return redactRunCompletedEvent(event);
98
+ }
99
+ if (event.type === "reasoning:trace") {
100
+ return redactReasoningTraceEvent(event);
101
+ }
102
+ if (event.type === "run:cancelled" && event.reason !== undefined) {
103
+ return { ...event, reason: redact(event.reason) };
104
+ }
105
+ if (event.type === "model:call:failed" || event.type === "tool:call:failed") {
106
+ return { ...event, message: redact(event.message) };
107
+ }
108
+ if (event.type === "patch:proposed")
109
+ return { ...event, diff: redact(event.diff) };
110
+ if (event.type === "verification:result")
111
+ return { ...event, detail: redact(event.detail) };
112
+ return event;
113
+ }
114
+ function redactRunFailedEvent(event) {
115
+ return {
116
+ ...event,
117
+ failure: {
118
+ ...event.failure,
119
+ message: redact(event.failure.message),
120
+ ...(event.failure.detail === undefined ? {} : { detail: redact(event.failure.detail) }),
121
+ },
122
+ };
123
+ }
124
+ function redactRunCompletedEvent(event) {
125
+ return {
126
+ ...event,
127
+ report: redact(event.report),
128
+ ...(event.patchDiff === undefined ? {} : { patchDiff: redact(event.patchDiff) }),
129
+ };
130
+ }
131
+ function redactReasoningTraceEvent(event) {
132
+ return {
133
+ ...event,
134
+ rationale: redact(event.rationale),
135
+ ...(event.modelResponse === undefined ? {} : { modelResponse: redact(event.modelResponse) }),
136
+ };
137
+ }
138
+ function seedFor(task, result, modelId) {
139
+ return {
140
+ runId: result.runId,
141
+ fingerprint: result.fingerprint,
142
+ harnessVersion: HARNESS_VERSION,
143
+ taskType: task.taskType,
144
+ taskInput: task,
145
+ limits: DEFAULT_LIMITS,
146
+ modelId,
147
+ workingDirectory: ".",
148
+ dryRun: true,
149
+ startedAt: new Date(result.startedAt).toISOString(),
150
+ };
151
+ }
152
+ // Persists the evidence manifest. This is a system boundary (filesystem write), so try/catch is
153
+ // correct here (CLAUDE.md): on any failure — typed AuditError or otherwise — print a REDACTED
154
+ // message and return exit 1 rather than rejecting out of runAgentCli as an unhandled rejection (C3).
155
+ // Returns undefined on success so the caller falls through to the run-outcome exit code.
156
+ function writeEvidence(result, memory, task, ctx, io, modelId) {
157
+ try {
158
+ const manifest = memory.collectManifest(seedFor(task, result, modelId));
159
+ const store = ctx.deps.store ??
160
+ createNodeEvidenceStore(resolveEvidenceDir(ctx.flags.evidenceDirFlag, ctx.env));
161
+ const out = persistEvidence({
162
+ result,
163
+ manifest,
164
+ options: {
165
+ includeReasoning: ctx.flags.includeReasoning,
166
+ includeDiff: ctx.flags.includeDiff,
167
+ },
168
+ }, { store, env: ctx.env, costClassResolver: resolveCostClass });
169
+ io.out(renderEvidenceReport(out.report));
170
+ return undefined;
171
+ }
172
+ catch (error) {
173
+ const detail = error instanceof AuditError ? error.message : redact(String(error));
174
+ io.err(`keiko run: failed to write evidence: ${detail}\n`);
175
+ return 1;
176
+ }
177
+ }
178
+ function configuredModelId(flags, env) {
179
+ const path = flags.config ?? env.KEIKO_CONFIG_FILE;
180
+ if (path === undefined) {
181
+ return flags.model;
182
+ }
183
+ const config = loadGatewayConfigFromFile(path, env);
184
+ if (flags.model !== undefined) {
185
+ assertConfiguredModel(config, flags.model);
186
+ return flags.model;
187
+ }
188
+ return selectConfiguredModel(config, { kind: "chat" });
189
+ }
190
+ function resolveModel(flags, io, env, deps) {
191
+ try {
192
+ if (deps.model !== undefined) {
193
+ const modelId = configuredModelId(flags, env);
194
+ if (modelId === undefined) {
195
+ io.err("Error: no model id available; pass --model MODEL_ID for injected test runs.\n");
196
+ return 1;
197
+ }
198
+ return { port: deps.model, modelId };
199
+ }
200
+ const path = flags.config ?? env.KEIKO_CONFIG_FILE;
201
+ if (path === undefined) {
202
+ throw new ConfigInvalidError("no config source; pass --config PATH or set KEIKO_CONFIG_FILE");
203
+ }
204
+ const config = loadGatewayConfigFromFile(path, env);
205
+ if (flags.model !== undefined) {
206
+ assertConfiguredModel(config, flags.model);
207
+ }
208
+ const modelId = flags.model ?? selectConfiguredModel(config, { kind: "chat" });
209
+ if (modelId === undefined) {
210
+ io.err("Error: no configured chat model is available.\n");
211
+ return 1;
212
+ }
213
+ return { port: new GatewayModelPort(new Gateway(config)), modelId };
214
+ }
215
+ catch (error) {
216
+ if (error instanceof GatewayError) {
217
+ io.err(`Error: model gateway configuration problem — ${redact(error.message)}\n` +
218
+ `Provide a gateway config with --config PATH or KEIKO_CONFIG_FILE.\n`);
219
+ return 1;
220
+ }
221
+ throw error;
222
+ }
223
+ }
224
+ function outcomeToExitCode(result, io) {
225
+ if (result.outcome === "completed") {
226
+ io.out(`run ${result.runId} completed (fingerprint ${result.fingerprint})\n`);
227
+ return 0;
228
+ }
229
+ if (result.outcome === "cancelled") {
230
+ io.err(`run ${result.runId} cancelled\n`);
231
+ return 1;
232
+ }
233
+ const category = result.failure?.category ?? "HARNESS_INTERNAL";
234
+ const message = redact(result.failure?.message ?? "");
235
+ io.err(`run ${result.runId} ${result.outcome} [${category}]: ${message}\n`);
236
+ return 1;
237
+ }
238
+ export async function runAgentCli(args, io, env = {}, deps = {}) {
239
+ const taskType = args[0];
240
+ if (taskType === undefined || !TASK_TYPES.has(taskType)) {
241
+ io.err(taskType === undefined ? USAGE : `keiko run: unknown task type: ${taskType}\n${USAGE}`);
242
+ return 2;
243
+ }
244
+ const task = parseTask(taskType, args.slice(1));
245
+ if (task === null) {
246
+ io.err(`keiko run: missing required argument for ${taskType}.\n${USAGE}`);
247
+ return 2;
248
+ }
249
+ const flags = parseEvidenceFlags(args);
250
+ const model = resolveModel(flags, io, env, deps);
251
+ if (typeof model === "number") {
252
+ return model;
253
+ }
254
+ const memory = new MemoryEventSink();
255
+ const config = { model: model.modelId, workingDirectory: ".", dryRun: true };
256
+ const session = createSession(task, config, {
257
+ model: model.port,
258
+ tools: new DryRunToolPort(),
259
+ sink: teeSink([memory, new CliEventSink(io)]),
260
+ });
261
+ const result = await session.result;
262
+ if (flags.write) {
263
+ const evidenceFailure = writeEvidence(result, memory, task, { flags, env, deps }, io, model.modelId);
264
+ if (evidenceFailure !== undefined) {
265
+ return evidenceFailure;
266
+ }
267
+ }
268
+ return outcomeToExitCode(result, io);
269
+ }