@klhapp/skillmux 1.9.3 → 1.11.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 (53) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/README.md +19 -19
  3. package/docs/README.md +4 -4
  4. package/docs/assets/architecture-dark.svg +39 -32
  5. package/docs/assets/architecture-light.svg +25 -18
  6. package/docs/cli.md +147 -36
  7. package/docs/concepts.md +11 -11
  8. package/docs/configuration.md +7 -5
  9. package/docs/deployment.md +10 -6
  10. package/docs/getting-started.md +18 -14
  11. package/docs/mcp-routing.md +1 -1
  12. package/docs/skill-management.md +17 -11
  13. package/docs/troubleshooting.md +4 -4
  14. package/package.json +1 -1
  15. package/src/adapters.ts +157 -11
  16. package/src/cli.ts +396 -1319
  17. package/src/commands/audit.ts +53 -56
  18. package/src/commands/config.ts +33 -26
  19. package/src/commands/context.ts +104 -0
  20. package/src/commands/core.ts +7 -3
  21. package/src/commands/doctor.ts +97 -0
  22. package/src/commands/eval.ts +22 -15
  23. package/src/commands/init.ts +672 -0
  24. package/src/commands/install.ts +132 -0
  25. package/src/commands/local-vault.ts +60 -0
  26. package/src/commands/models.ts +10 -0
  27. package/src/commands/outdated.ts +2 -1
  28. package/src/commands/project.ts +194 -51
  29. package/src/commands/report.ts +66 -0
  30. package/src/commands/scan.ts +61 -0
  31. package/src/commands/shared.ts +7 -14
  32. package/src/commands/skill.ts +33 -0
  33. package/src/commands/sync.ts +232 -0
  34. package/src/commands/target.ts +45 -15
  35. package/src/commands/update.ts +2 -1
  36. package/src/completions.ts +41 -15
  37. package/src/config-service.ts +4 -54
  38. package/src/context.ts +8 -3
  39. package/src/db-audit.ts +286 -0
  40. package/src/db-index.ts +238 -0
  41. package/src/db.ts +3 -521
  42. package/src/global-flags.ts +46 -0
  43. package/src/init-agents.ts +329 -0
  44. package/src/init-instructions.ts +47 -28
  45. package/src/logger.ts +26 -0
  46. package/src/mcp-registration.ts +89 -0
  47. package/src/output.ts +80 -18
  48. package/src/prompts.ts +75 -20
  49. package/src/router-core.ts +8 -27
  50. package/src/scan.ts +19 -19
  51. package/src/server.ts +161 -14
  52. package/src/toml-writer.ts +51 -0
  53. package/src/init-clients.ts +0 -220
@@ -1,13 +1,13 @@
1
- import { expandHome, loadConfig } from "../config";
2
- import { countPrunable, openAudit, pruneAuditBefore } from "../db";
3
- import { parseSince } from "../stats";
4
1
  import { emitSuccess } from "../output";
5
2
  import { confirmIfNeeded } from "./shared";
3
+ import type { ContextAdapter } from "../adapters";
4
+ import type { ResolvedContext } from "../context";
5
+ import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
6
6
 
7
7
  export async function runAudit(
8
8
  subCommand: string,
9
9
  args: string[],
10
- options: { isJson: boolean; dryRun: boolean },
10
+ options: { isJson: boolean; dryRun: boolean; context: ResolvedContext; adapter: ContextAdapter },
11
11
  ): Promise<void> {
12
12
  if (subCommand !== "prune") {
13
13
  throw new Error("usage: skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]");
@@ -21,68 +21,65 @@ export async function runAudit(
21
21
  if (arg === "--older-than") olderThan = args[++i];
22
22
  else if (arg === "--dry-run") dryRun = true;
23
23
  else if (arg === "--yes") yes = true;
24
- else if (arg === "--json") {
25
- // handled globally by main()'s isJson flag; recognized here so it isn't rejected
24
+ else if (isGlobalFlag(arg, "--json", "--allow-insecure", "--verbose")) {
25
+ // handled globally by main()'s flags; recognized here so it isn't rejected
26
+ } else if (isGlobalFlagWithValue(arg)) {
27
+ i++; // skip flag value
26
28
  } else if (arg?.startsWith("--")) {
27
29
  throw new Error(`unknown audit prune option: ${arg}`);
28
30
  }
29
31
  }
30
32
 
31
- const config = await loadConfig();
32
- const stateDir = expandHome(config.state_dir);
33
-
34
- let cutoff: Date;
35
- if (olderThan) {
36
- cutoff = parseSince(olderThan);
37
- } else {
38
- const retentionDays = config.audit?.retention_days ?? 90;
39
- if (retentionDays <= 0) {
40
- emitSuccess(
41
- { isJson: options.isJson },
42
- { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0, dry_run: dryRun, cutoff: null },
43
- () => console.log("prune: audit.retention_days is 0 (pruning disabled); nothing to do"),
44
- );
45
- return;
46
- }
47
- cutoff = new Date(Date.now() - retentionDays * 86_400_000);
48
- }
49
- const cutoffIso = cutoff.toISOString();
50
-
51
- const db = openAudit(stateDir);
52
- try {
53
- if (dryRun) {
54
- const counts = countPrunable(db, cutoffIso);
55
- emitSuccess(
56
- { isJson: options.isJson },
57
- { ...counts, dry_run: true, cutoff: cutoffIso },
58
- () =>
59
- console.log(
60
- `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted} (dry-run)`,
61
- ),
62
- );
63
- return;
64
- }
65
-
66
- if (
67
- !(await confirmIfNeeded({
68
- confirmed: yes,
69
- isJson: options.isJson,
70
- prompt: `prune audit rows older than ${cutoffIso}?`,
71
- nonInteractiveError: "skillmux audit prune requires --yes when run non-interactively",
72
- }))
73
- )
74
- return;
75
-
76
- const counts = pruneAuditBefore(db, cutoffIso);
33
+ if (dryRun) {
34
+ const counts = await options.adapter.auditCount(olderThan);
77
35
  emitSuccess(
78
36
  { isJson: options.isJson },
79
- { ...counts, dry_run: false, cutoff: cutoffIso },
37
+ counts,
80
38
  () =>
81
39
  console.log(
82
- `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted}`,
40
+ counts.cutoff === null
41
+ ? "prune: audit.retention_days is 0 (pruning disabled); nothing to do"
42
+ : `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted} (dry-run)`,
83
43
  ),
84
44
  );
85
- } finally {
86
- db.close();
45
+ return;
87
46
  }
47
+
48
+ // Pre-fetch count / cutoff info for confirmation prompt if local or remote
49
+ const countResult = await options.adapter.auditCount(olderThan);
50
+ if (countResult.cutoff === null) {
51
+ emitSuccess(
52
+ { isJson: options.isJson },
53
+ { audit_deleted: 0, fetch_deleted: 0, admin_audit_deleted: 0, dry_run: false, cutoff: null },
54
+ () => console.log("prune: audit.retention_days is 0 (pruning disabled); nothing to do"),
55
+ );
56
+ return;
57
+ }
58
+
59
+ if (
60
+ !(await confirmIfNeeded({
61
+ confirmed: yes,
62
+ isJson: options.isJson,
63
+ prompt: `prune audit rows older than ${countResult.cutoff}?`,
64
+ nonInteractiveError: "skillmux audit prune requires --yes when run non-interactively",
65
+ }))
66
+ )
67
+ return;
68
+
69
+ // Reuse the exact cutoff already shown in the confirmation prompt — recomputing
70
+ // it here (if olderThan was left unset) could drift from what was confirmed,
71
+ // e.g. if audit.retention_days changed between the two calls on a remote target.
72
+ const counts = await options.adapter.auditPrune({
73
+ older_than: countResult.cutoff,
74
+ dry_run: false,
75
+ confirm: true,
76
+ });
77
+ emitSuccess(
78
+ { isJson: options.isJson },
79
+ counts,
80
+ () =>
81
+ console.log(
82
+ `prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} admin_audit=${counts.admin_audit_deleted}`,
83
+ ),
84
+ );
88
85
  }
@@ -1,9 +1,10 @@
1
1
  import { expandHome, migrateLegacyPaths, resolveConfigPath } from "../config";
2
- import { type TargetAdapter } from "../adapters";
3
- import { type ResolvedTarget } from "../context";
2
+ import { type ContextAdapter } from "../adapters";
3
+ import { type ResolvedContext } from "../context";
4
4
  import { applyConfigInit, planConfigInit, type ConfigInitPlan } from "../setup";
5
- import { emitSuccess, isInteractive, renderTargetBanner } from "../output";
5
+ import { emitSuccess, isInteractive, renderContextBanner, unknownSubcommandError } from "../output";
6
6
  import { confirmAction } from "./shared";
7
+ import { isGlobalFlag } from "../global-flags";
7
8
  function emitConfigInitOutcome(
8
9
  ctx: { isJson: boolean },
9
10
  opts: {
@@ -37,10 +38,10 @@ function emitConfigInitOutcome(
37
38
  }
38
39
 
39
40
  export async function handleConfigCommand(
40
- adapter: TargetAdapter,
41
+ adapter: ContextAdapter,
41
42
  sub: string,
42
43
  args: string[],
43
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
44
+ ctx: { context: ResolvedContext; isJson: boolean; dryRun: boolean },
44
45
  ) {
45
46
  if (sub === "init") {
46
47
  let vaultPath: string | undefined;
@@ -53,7 +54,7 @@ export async function handleConfigCommand(
53
54
  throw new Error("usage: skillmux config init --vault <path> --yes");
54
55
  } else if (option === "--yes") {
55
56
  yes = true;
56
- } else if (option === "--dry-run" || option === "--json") {
57
+ } else if (isGlobalFlag(option, "--dry-run", "--json")) {
57
58
  continue;
58
59
  } else {
59
60
  throw new Error(`unknown config init option: ${option}`);
@@ -95,7 +96,7 @@ export async function handleConfigCommand(
95
96
  if (!ctx.isJson && isInteractive()) {
96
97
  if (
97
98
  !(await confirmAction(
98
- `Create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
99
+ `create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
99
100
  ))
100
101
  ) {
101
102
  console.log("config init cancelled; nothing written");
@@ -126,8 +127,8 @@ export async function handleConfigCommand(
126
127
  if (sub === "show") {
127
128
  const withSources = args.includes("--sources");
128
129
  const data = await adapter.getConfigShow();
129
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
130
- renderTargetBanner(ctx.target);
130
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, data, () => {
131
+ renderContextBanner(ctx.context);
131
132
  if (withSources) {
132
133
  const policy =
133
134
  data.effective.config?.environment_overrides === false
@@ -150,7 +151,7 @@ export async function handleConfigCommand(
150
151
  if (!key) throw new Error("usage: skillmux config get <key>");
151
152
  const val = await adapter.getConfigGet(key);
152
153
  emitSuccess(
153
- { isJson: ctx.isJson, target: ctx.target },
154
+ { isJson: ctx.isJson, context: ctx.context },
154
155
  { key, value: val },
155
156
  () => {
156
157
  console.log(
@@ -163,18 +164,16 @@ export async function handleConfigCommand(
163
164
 
164
165
  if (sub === "validate") {
165
166
  const res = await adapter.configValidate();
166
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
167
- console.log(
168
- res.valid ? "Configuration is valid." : "Configuration is invalid.",
169
- );
167
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
168
+ console.log(res.valid ? "configuration is valid" : "configuration is invalid");
170
169
  });
171
170
  return;
172
171
  }
173
172
 
174
173
  if (sub === "diff") {
175
174
  const res = await adapter.configDiff();
176
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
177
- renderTargetBanner(ctx.target);
175
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
176
+ renderContextBanner(ctx.context);
178
177
  console.log(JSON.stringify(res.diff, null, 2));
179
178
  });
180
179
  return;
@@ -187,8 +186,8 @@ export async function handleConfigCommand(
187
186
  throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
188
187
  }
189
188
  const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
190
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
191
- renderTargetBanner(ctx.target);
189
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
190
+ renderContextBanner(ctx.context);
192
191
  const prefix = ctx.dryRun ? "[dry-run] " : "";
193
192
  console.log(
194
193
  `${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
@@ -202,16 +201,24 @@ export async function handleConfigCommand(
202
201
 
203
202
  if (sub === "status") {
204
203
  const res = await adapter.configStatus();
205
- emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
206
- renderTargetBanner(ctx.target);
207
- console.log(`Runtime: ${res.runtime}`);
208
- console.log(`Deployment runtime: ${res.deployment_runtime}`);
209
- console.log(`Image variant: ${res.image_variant ?? "none"}`);
210
- console.log(`Active revision: ${res.active_revision}`);
211
- console.log(`Readiness: ${res.readiness.status}`);
204
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, res, () => {
205
+ renderContextBanner(ctx.context);
206
+ console.log(`runtime: ${res.runtime}`);
207
+ console.log(`deployment runtime: ${res.deployment_runtime}`);
208
+ console.log(`image variant: ${res.image_variant ?? "none"}`);
209
+ console.log(`active revision: ${res.active_revision}`);
210
+ console.log(`readiness: ${res.readiness.status}`);
212
211
  });
213
212
  return;
214
213
  }
215
214
 
216
- throw new Error("usage: skillmux config show");
215
+ throw unknownSubcommandError("config", sub, [
216
+ "init",
217
+ "show",
218
+ "get",
219
+ "set",
220
+ "validate",
221
+ "diff",
222
+ "status",
223
+ ]);
217
224
  }
@@ -0,0 +1,104 @@
1
+ import {
2
+ addContext,
3
+ getCurrentContext,
4
+ listContexts,
5
+ removeContext,
6
+ useContext,
7
+ type ResolvedContext,
8
+ } from "../context";
9
+ import {
10
+ emitSuccess,
11
+ renderTable,
12
+ renderContextBanner,
13
+ unknownSubcommandError,
14
+ } from "../output";
15
+
16
+ export async function handleContextCommand(
17
+ sub: string,
18
+ args: string[],
19
+ ctx: { context: ResolvedContext; isJson: boolean },
20
+ ) {
21
+ if (sub === "list") {
22
+ const contexts = await listContexts();
23
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, contexts, () => {
24
+ renderContextBanner(ctx.context);
25
+ renderTable(
26
+ [
27
+ { key: "name", header: "NAME" },
28
+ { key: "server", header: "SERVER" },
29
+ { key: "token_env", header: "TOKEN_ENV" },
30
+ { key: "isDefault", header: "DEFAULT" },
31
+ ],
32
+ contexts.map((c) => ({
33
+ ...c,
34
+ token_env: c.token_env ?? "-",
35
+ isDefault: c.isDefault ? "*" : "",
36
+ })),
37
+ );
38
+ });
39
+ return;
40
+ }
41
+
42
+ if (sub === "current") {
43
+ const current = await getCurrentContext();
44
+ emitSuccess({ isJson: ctx.isJson, context: ctx.context }, current, () => {
45
+ renderContextBanner(ctx.context);
46
+ console.log(`Current context: ${current.name} (${current.server})`);
47
+ });
48
+ return;
49
+ }
50
+
51
+ if (sub === "add") {
52
+ const name = args[0];
53
+ let server: string | undefined;
54
+ let tokenEnv: string | undefined;
55
+ for (let i = 1; i < args.length; i++) {
56
+ if (args[i] === "--server") server = args[++i];
57
+ else if (args[i] === "--token-env") tokenEnv = args[++i];
58
+ }
59
+ if (!name || !server) {
60
+ throw new Error(
61
+ "usage: skillmux context add <name> --server <url> [--token-env <env_name>]",
62
+ );
63
+ }
64
+ await addContext(name, { server, token_env: tokenEnv });
65
+ emitSuccess(
66
+ { isJson: ctx.isJson, context: ctx.context },
67
+ { name, server, token_env: tokenEnv },
68
+ () => {
69
+ console.log(`Added context "${name}" -> ${server}`);
70
+ },
71
+ );
72
+ return;
73
+ }
74
+
75
+ if (sub === "use") {
76
+ const name = args[0];
77
+ if (!name) throw new Error("usage: skillmux context use <name>");
78
+ await useContext(name);
79
+ emitSuccess(
80
+ { isJson: ctx.isJson, context: ctx.context },
81
+ { default_context: name },
82
+ () => {
83
+ console.log(`Switched default context to "${name}"`);
84
+ },
85
+ );
86
+ return;
87
+ }
88
+
89
+ if (sub === "remove") {
90
+ const name = args[0];
91
+ if (!name) throw new Error("usage: skillmux context remove <name>");
92
+ await removeContext(name);
93
+ emitSuccess(
94
+ { isJson: ctx.isJson, context: ctx.context },
95
+ { removed: name },
96
+ () => {
97
+ console.log(`Removed context "${name}"`);
98
+ },
99
+ );
100
+ return;
101
+ }
102
+
103
+ throw unknownSubcommandError("context", sub, ["add", "list", "current", "use", "remove"]);
104
+ }
@@ -1,6 +1,6 @@
1
1
  import { expandHome } from "../config";
2
2
  import { pinCore, unpinCore, validateManifest, writeManifestAtomic } from "../manifest";
3
- import { emitSuccess } from "../output";
3
+ import { emitSuccess, unknownSubcommandError } from "../output";
4
4
  import { confirmIfNeeded, loadManifestContext } from "./shared";
5
5
  export async function runCore(
6
6
  subCommand: string,
@@ -8,7 +8,7 @@ export async function runCore(
8
8
  options: { isJson: boolean; dryRun: boolean },
9
9
  ): Promise<void> {
10
10
  if (subCommand !== "pin" && subCommand !== "unpin") {
11
- throw new Error("usage: skillmux core <pin|unpin>");
11
+ throw unknownSubcommandError("core", subCommand, ["pin", "unpin"]);
12
12
  }
13
13
  const skillIds = args.filter((arg) => !arg.startsWith("-"));
14
14
  if (skillIds.length === 0) {
@@ -48,5 +48,9 @@ export async function runCore(
48
48
  )
49
49
  return;
50
50
  writeManifestAtomic(manifestPath, updated);
51
- console.log(`${subCommand}: [core] ${skillIds.join(", ")}`);
51
+ emitSuccess(
52
+ { isJson: options.isJson },
53
+ { subcommand: subCommand, skill_ids: skillIds },
54
+ () => console.log(`${subCommand}: [core] ${skillIds.join(", ")}`),
55
+ );
52
56
  }
@@ -0,0 +1,97 @@
1
+ import { resolveConfigPath } from "../config";
2
+ import { diagnose } from "../doctor";
3
+ import { getEffectiveConfig } from "../config-service";
4
+ import type { ContextAdapter } from "../adapters";
5
+ import type { ResolvedContext } from "../context";
6
+ import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
7
+ import {
8
+ emitSuccess,
9
+ green,
10
+ red,
11
+ renderContextBanner,
12
+ } from "../output";
13
+
14
+ /**
15
+ * doctor takes no options of its own, but it still has to reject unknown ones
16
+ * rather than silently ignoring them the way every other command does.
17
+ */
18
+ export function parseDoctorArgs(args: readonly string[]): void {
19
+ for (let i = 0; i < args.length; i++) {
20
+ const option = args[i];
21
+ if (isGlobalFlag(option, "--json", "--allow-insecure", "--verbose")) {
22
+ // handled globally by main(); recognized here so it isn't rejected
23
+ } else if (isGlobalFlagWithValue(option)) {
24
+ // handled globally by main()'s resolveContext(); skip its value too
25
+ i++;
26
+ } else {
27
+ throw new Error(`unknown doctor option: ${option}`);
28
+ }
29
+ }
30
+ }
31
+
32
+ export async function runDoctor(options: {
33
+ isJson: boolean;
34
+ context: ResolvedContext;
35
+ adapter: ContextAdapter;
36
+ args?: readonly string[];
37
+ }): Promise<void> {
38
+ parseDoctorArgs(options.args ?? []);
39
+ if (options.context.type === "remote") {
40
+ const context = options.context;
41
+ const [status, caps] = await Promise.all([
42
+ options.adapter.configStatus(),
43
+ options.adapter.getCapabilities(),
44
+ ]);
45
+ const remoteReport = {
46
+ target: context.name || context.server,
47
+ server: context.server,
48
+ version: status.version,
49
+ deployment_runtime: status.deployment_runtime,
50
+ image_variant: status.image_variant ?? null,
51
+ runtime: status.runtime,
52
+ readiness: status.readiness,
53
+ active_revision: status.active_revision,
54
+ capabilities: caps,
55
+ restart_required_keys: status.restart_required_keys,
56
+ last_reload_error: status.last_reload_error,
57
+ };
58
+ emitSuccess({ isJson: options.isJson, target: options.context }, remoteReport, () => {
59
+ renderContextBanner(options.context);
60
+ console.log(`server: ${remoteReport.server}`);
61
+ console.log(`version: ${remoteReport.version}`);
62
+ console.log(`deployment runtime: ${remoteReport.deployment_runtime}`);
63
+ console.log(`image variant: ${remoteReport.image_variant ?? "none"}`);
64
+ console.log(`runtime: ${remoteReport.runtime}`);
65
+ console.log(`readiness: ${remoteReport.readiness.status} (${remoteReport.readiness.capability})`);
66
+ console.log(`active revision: ${remoteReport.active_revision}`);
67
+ console.log(`persistence: ${caps.persistence}`);
68
+ console.log(`config read: ${caps.config_read}`);
69
+ console.log(`config write: ${caps.config_write}`);
70
+ if (status.last_reload_error) {
71
+ console.log(`last reload error: ${status.last_reload_error}`);
72
+ }
73
+ if (status.restart_required_keys.length > 0) {
74
+ console.log(`restart required for: ${status.restart_required_keys.join(", ")}`);
75
+ }
76
+ });
77
+ return;
78
+ }
79
+
80
+ const effective = await getEffectiveConfig(resolveConfigPath());
81
+ const report = await diagnose(effective.effective, process.env, effective.sources);
82
+ emitSuccess({ isJson: options.isJson }, report, () => {
83
+ console.log(`version: ${report.version}`);
84
+ console.log(`runtime: ${report.runtime}`);
85
+ console.log(`image variant: ${report.image_variant ?? "none"}`);
86
+ console.log(`vault path: ${report.vault_path}`);
87
+ console.log(`state directory: ${report.state_dir}`);
88
+ console.log(`inference mode: ${report.mode}`);
89
+ console.log(`routing capability: ${report.capability}`);
90
+ console.log(`retrieval capability: ${report.retrieval_capability}`);
91
+ for (const check of report.checks)
92
+ console.log(
93
+ `${check.ok ? green("ok") : red("fail")}: ${check.name} - ${check.detail}`,
94
+ );
95
+ });
96
+ if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
97
+ }
@@ -1,54 +1,61 @@
1
1
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
3
  import { expandHome, loadConfig } from "../config";
4
- import { openAudit } from "../db";
5
- import { buildPromotedCases, excludeExistingCases, parseEvalCases, queryPromotableFetches } from "../eval";
6
- import { emitSuccess } from "../output";
4
+ import { excludeExistingCases, parseEvalCases } from "../eval";
5
+ import { emitSuccess, warn } from "../output";
7
6
  import { parseSince } from "../stats";
8
7
  import { confirmIfNeeded } from "./shared";
8
+ import type { ContextAdapter } from "../adapters";
9
+ import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
9
10
 
10
11
  export async function runEvalPromote(
11
12
  args: string[],
12
- options: { isJson: boolean; dryRun: boolean },
13
+ options: { isJson: boolean; dryRun: boolean; adapter: ContextAdapter },
13
14
  ): Promise<void> {
14
15
  let since: string | undefined;
16
+ let out: string | undefined;
15
17
  let target: string | undefined;
16
18
  let dryRun = options.dryRun;
17
19
  let yes = false;
18
20
  for (let i = 0; i < args.length; i++) {
19
21
  const arg = args[i];
20
22
  if (arg === "--since") since = args[++i];
23
+ else if (arg === "--out") out = args[++i];
21
24
  else if (arg === "--target") target = args[++i];
22
25
  else if (arg === "--dry-run") dryRun = true;
23
26
  else if (arg === "--yes") yes = true;
24
- else if (arg === "--json") {
27
+ else if (isGlobalFlag(arg, "--json", "--allow-insecure", "--verbose")) {
25
28
  // handled globally by main()'s isJson flag; recognized here so it isn't rejected
29
+ } else if (isGlobalFlagWithValue(arg)) {
30
+ i++; // skip flag value
26
31
  } else if (arg?.startsWith("--")) {
27
32
  throw new Error(`unknown eval promote option: ${arg}`);
28
33
  }
29
34
  }
30
35
  if (!since) {
31
- throw new Error("usage: skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]");
36
+ throw new Error("usage: skillmux eval promote --since <window> [--out <path>] [--dry-run] [--yes] [--json]");
37
+ }
38
+
39
+ if (out !== undefined && target !== undefined && out !== target) {
40
+ throw new Error("cannot specify conflicting --out and --target");
41
+ }
42
+
43
+ if (target !== undefined) {
44
+ warn("--target is deprecated, use --out instead");
32
45
  }
33
46
 
34
47
  const config = await loadConfig();
35
48
  const stateDir = expandHome(config.state_dir);
36
- const targetPath = target ?? join(stateDir, "eval-observed.json");
49
+ const targetPath = out ?? target ?? join(stateDir, "eval-observed.json");
37
50
  const sinceDate = parseSince(since);
38
51
  const sinceIso = sinceDate.toISOString();
39
52
 
40
- const db = openAudit(stateDir);
41
- let candidates: ReturnType<typeof buildPromotedCases>;
42
- try {
43
- candidates = buildPromotedCases(queryPromotableFetches(db, sinceIso));
44
- } finally {
45
- db.close();
46
- }
53
+ const candidates = await options.adapter.evalPromote(since);
47
54
 
48
55
  const existing = existsSync(targetPath) ? parseEvalCases(JSON.parse(readFileSync(targetPath, "utf-8"))) : [];
49
56
  const { cases: newCases, skipped } = excludeExistingCases(candidates, existing);
50
57
 
51
- console.error("warning: promoted eval cases contain raw user queries");
58
+ warn("promoted eval cases contain raw user queries");
52
59
 
53
60
  if (dryRun) {
54
61
  emitSuccess(