@klhapp/skillmux 1.9.2 → 1.10.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 (47) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +1 -1
  3. package/docs/README.md +1 -1
  4. package/docs/cli.md +74 -3
  5. package/docs/concepts.md +1 -1
  6. package/docs/configuration.md +52 -1
  7. package/docs/deployment.md +10 -6
  8. package/docs/getting-started.md +1 -1
  9. package/docs/skill-management.md +49 -0
  10. package/package.json +1 -1
  11. package/src/adapters.ts +148 -2
  12. package/src/cli.ts +302 -1291
  13. package/src/clients.ts +17 -0
  14. package/src/commands/audit.ts +54 -51
  15. package/src/commands/config.ts +11 -12
  16. package/src/commands/context.ts +103 -0
  17. package/src/commands/core.ts +5 -1
  18. package/src/commands/doctor.ts +76 -0
  19. package/src/commands/eval.ts +10 -13
  20. package/src/commands/init.ts +621 -0
  21. package/src/commands/install.ts +132 -0
  22. package/src/commands/local-vault.ts +60 -0
  23. package/src/commands/models.ts +10 -0
  24. package/src/commands/outdated.ts +8 -5
  25. package/src/commands/project.ts +37 -11
  26. package/src/commands/report.ts +66 -0
  27. package/src/commands/scan.ts +61 -0
  28. package/src/commands/skill.ts +32 -0
  29. package/src/commands/sync.ts +232 -0
  30. package/src/commands/target.ts +18 -6
  31. package/src/commands/update.ts +11 -5
  32. package/src/concurrency-limiter.ts +61 -0
  33. package/src/config-service.ts +1 -51
  34. package/src/config.ts +5 -0
  35. package/src/context.ts +8 -3
  36. package/src/db-audit.ts +286 -0
  37. package/src/db-index.ts +238 -0
  38. package/src/db.ts +3 -413
  39. package/src/global-flags.ts +46 -0
  40. package/src/install.ts +15 -0
  41. package/src/logger.ts +26 -0
  42. package/src/output.ts +30 -5
  43. package/src/redact.ts +52 -0
  44. package/src/router-core.ts +8 -27
  45. package/src/server.ts +594 -267
  46. package/src/toml-writer.ts +51 -0
  47. package/src/types.ts +7 -0
package/src/clients.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { Clients, Config, RemoteRerankerConfig } from "./types";
2
2
  import { expandHome } from "./config";
3
+ import { assertHostAllowed } from "./install";
3
4
  import type { pipeline as createPipeline } from "@huggingface/transformers";
4
5
 
5
6
  export type RemoteErrorKind = "configuration" | "availability" | "protocol";
@@ -34,6 +35,17 @@ function authorizationHeaders(
34
35
  return { authorization: `Bearer ${apiKey}` };
35
36
  }
36
37
 
38
+ function assertInferenceHostAllowed(url: string, allowedHosts: string[] | undefined): void {
39
+ try {
40
+ assertHostAllowed(url, allowedHosts);
41
+ } catch (error) {
42
+ throw new RemoteInferenceError(
43
+ "configuration",
44
+ error instanceof Error ? error.message : String(error),
45
+ );
46
+ }
47
+ }
48
+
37
49
  function httpFailure(surface: string, status: number): RemoteInferenceError {
38
50
  const kind: RemoteErrorKind =
39
51
  status === 401 || status === 403
@@ -196,9 +208,12 @@ async function fetchRerankerScores(
196
208
  timeoutMs: number,
197
209
  query: string,
198
210
  docs: { skill_id: string; text: string }[],
211
+ allowedHosts: string[] | undefined,
199
212
  ): Promise<number[]> {
200
213
  if (docs.length === 0) return [];
201
214
 
215
+ assertInferenceHostAllowed(reranker.endpoint, allowedHosts);
216
+
202
217
  let response: Response;
203
218
  try {
204
219
  response = await fetch(reranker.endpoint, {
@@ -300,6 +315,7 @@ export function createClients(config: Config): Clients {
300
315
  }
301
316
 
302
317
  const embedding = config.inference.embedding;
318
+ assertInferenceHostAllowed(embedding.endpoint, config.egress?.allowed_hosts);
303
319
  let response: Response;
304
320
  try {
305
321
  response = await fetch(embedding.endpoint, {
@@ -344,6 +360,7 @@ export function createClients(config: Config): Clients {
344
360
  inference.timeout_ms,
345
361
  query,
346
362
  docs,
363
+ config.egress?.allowed_hosts,
347
364
  );
348
365
  };
349
366
  }
@@ -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 { TargetAdapter } 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; target: ResolvedContext; adapter: TargetAdapter },
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,62 +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, 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);
33
+ if (dryRun) {
34
+ const counts = await options.adapter.auditCount(olderThan);
35
+ emitSuccess(
36
+ { isJson: options.isJson },
37
+ counts,
38
+ () =>
39
+ console.log(
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)`,
43
+ ),
44
+ );
45
+ return;
48
46
  }
49
- const cutoffIso = cutoff.toISOString();
50
47
 
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
- () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} (dry-run)`),
59
- );
60
- return;
61
- }
62
-
63
- if (
64
- !(await confirmIfNeeded({
65
- confirmed: yes,
66
- isJson: options.isJson,
67
- prompt: `prune audit rows older than ${cutoffIso}?`,
68
- nonInteractiveError: "skillmux audit prune requires --yes when run non-interactively",
69
- }))
70
- )
71
- return;
72
-
73
- const counts = pruneAuditBefore(db, cutoffIso);
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) {
74
51
  emitSuccess(
75
52
  { isJson: options.isJson },
76
- { ...counts, dry_run: false, cutoff: cutoffIso },
77
- () => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted}`),
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"),
78
55
  );
79
- } finally {
80
- db.close();
56
+ return;
81
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
+ );
82
85
  }
@@ -1,9 +1,10 @@
1
1
  import { expandHome, migrateLegacyPaths, resolveConfigPath } from "../config";
2
2
  import { type TargetAdapter } from "../adapters";
3
- import { type ResolvedTarget } from "../context";
3
+ import { type ResolvedContext } from "../context";
4
4
  import { applyConfigInit, planConfigInit, type ConfigInitPlan } from "../setup";
5
5
  import { emitSuccess, isInteractive, renderTargetBanner } 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: {
@@ -40,7 +41,7 @@ export async function handleConfigCommand(
40
41
  adapter: TargetAdapter,
41
42
  sub: string,
42
43
  args: string[],
43
- ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
44
+ ctx: { target: 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");
@@ -164,9 +165,7 @@ export async function handleConfigCommand(
164
165
  if (sub === "validate") {
165
166
  const res = await adapter.configValidate();
166
167
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
167
- console.log(
168
- res.valid ? "Configuration is valid." : "Configuration is invalid.",
169
- );
168
+ console.log(res.valid ? "configuration is valid" : "configuration is invalid");
170
169
  });
171
170
  return;
172
171
  }
@@ -204,11 +203,11 @@ export async function handleConfigCommand(
204
203
  const res = await adapter.configStatus();
205
204
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
206
205
  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}`);
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
  }
@@ -0,0 +1,103 @@
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
+ renderTargetBanner,
13
+ } from "../output";
14
+
15
+ export async function handleContextCommand(
16
+ sub: string,
17
+ args: string[],
18
+ ctx: { target: ResolvedContext; isJson: boolean },
19
+ ) {
20
+ if (sub === "list") {
21
+ const contexts = await listContexts();
22
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, contexts, () => {
23
+ renderTargetBanner(ctx.target);
24
+ renderTable(
25
+ [
26
+ { key: "name", header: "NAME" },
27
+ { key: "server", header: "SERVER" },
28
+ { key: "token_env", header: "TOKEN_ENV" },
29
+ { key: "isDefault", header: "DEFAULT" },
30
+ ],
31
+ contexts.map((c) => ({
32
+ ...c,
33
+ token_env: c.token_env ?? "-",
34
+ isDefault: c.isDefault ? "*" : "",
35
+ })),
36
+ );
37
+ });
38
+ return;
39
+ }
40
+
41
+ if (sub === "current") {
42
+ const current = await getCurrentContext();
43
+ emitSuccess({ isJson: ctx.isJson, target: ctx.target }, current, () => {
44
+ renderTargetBanner(ctx.target);
45
+ console.log(`Current context: ${current.name} (${current.server})`);
46
+ });
47
+ return;
48
+ }
49
+
50
+ if (sub === "add") {
51
+ const name = args[0];
52
+ let server: string | undefined;
53
+ let tokenEnv: string | undefined;
54
+ for (let i = 1; i < args.length; i++) {
55
+ if (args[i] === "--server") server = args[++i];
56
+ else if (args[i] === "--token-env") tokenEnv = args[++i];
57
+ }
58
+ if (!name || !server) {
59
+ throw new Error(
60
+ "usage: skillmux context add <name> --server <url> [--token-env <env_name>]",
61
+ );
62
+ }
63
+ await addContext(name, { server, token_env: tokenEnv });
64
+ emitSuccess(
65
+ { isJson: ctx.isJson, target: ctx.target },
66
+ { name, server, token_env: tokenEnv },
67
+ () => {
68
+ console.log(`Added context "${name}" -> ${server}`);
69
+ },
70
+ );
71
+ return;
72
+ }
73
+
74
+ if (sub === "use") {
75
+ const name = args[0];
76
+ if (!name) throw new Error("usage: skillmux context use <name>");
77
+ await useContext(name);
78
+ emitSuccess(
79
+ { isJson: ctx.isJson, target: ctx.target },
80
+ { default_context: name },
81
+ () => {
82
+ console.log(`Switched default context to "${name}"`);
83
+ },
84
+ );
85
+ return;
86
+ }
87
+
88
+ if (sub === "remove") {
89
+ const name = args[0];
90
+ if (!name) throw new Error("usage: skillmux context remove <name>");
91
+ await removeContext(name);
92
+ emitSuccess(
93
+ { isJson: ctx.isJson, target: ctx.target },
94
+ { removed: name },
95
+ () => {
96
+ console.log(`Removed context "${name}"`);
97
+ },
98
+ );
99
+ return;
100
+ }
101
+
102
+ throw new Error("usage: skillmux context <add|list|current|use|remove>");
103
+ }
@@ -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,76 @@
1
+ import { resolveConfigPath } from "../config";
2
+ import { diagnose } from "../doctor";
3
+ import { getEffectiveConfig } from "../config-service";
4
+ import type { TargetAdapter } from "../adapters";
5
+ import type { ResolvedContext } from "../context";
6
+ import {
7
+ emitSuccess,
8
+ green,
9
+ red,
10
+ renderTargetBanner,
11
+ } from "../output";
12
+
13
+ export async function runDoctor(options: {
14
+ isJson: boolean;
15
+ target: ResolvedContext;
16
+ adapter: TargetAdapter;
17
+ }): Promise<void> {
18
+ if (options.target.type === "remote") {
19
+ const target = options.target;
20
+ const [status, caps] = await Promise.all([
21
+ options.adapter.configStatus(),
22
+ options.adapter.getCapabilities(),
23
+ ]);
24
+ const remoteReport = {
25
+ target: target.name || target.server,
26
+ server: target.server,
27
+ version: status.version,
28
+ deployment_runtime: status.deployment_runtime,
29
+ image_variant: status.image_variant ?? null,
30
+ runtime: status.runtime,
31
+ readiness: status.readiness,
32
+ active_revision: status.active_revision,
33
+ capabilities: caps,
34
+ restart_required_keys: status.restart_required_keys,
35
+ last_reload_error: status.last_reload_error,
36
+ };
37
+ emitSuccess({ isJson: options.isJson, target: options.target }, remoteReport, () => {
38
+ renderTargetBanner(options.target);
39
+ console.log(`server: ${remoteReport.server}`);
40
+ console.log(`version: ${remoteReport.version}`);
41
+ console.log(`deployment runtime: ${remoteReport.deployment_runtime}`);
42
+ console.log(`image variant: ${remoteReport.image_variant ?? "none"}`);
43
+ console.log(`runtime: ${remoteReport.runtime}`);
44
+ console.log(`readiness: ${remoteReport.readiness.status} (${remoteReport.readiness.capability})`);
45
+ console.log(`active revision: ${remoteReport.active_revision}`);
46
+ console.log(`persistence: ${caps.persistence}`);
47
+ console.log(`config read: ${caps.config_read}`);
48
+ console.log(`config write: ${caps.config_write}`);
49
+ if (status.last_reload_error) {
50
+ console.log(`last reload error: ${status.last_reload_error}`);
51
+ }
52
+ if (status.restart_required_keys.length > 0) {
53
+ console.log(`restart required for: ${status.restart_required_keys.join(", ")}`);
54
+ }
55
+ });
56
+ return;
57
+ }
58
+
59
+ const effective = await getEffectiveConfig(resolveConfigPath());
60
+ const report = await diagnose(effective.effective, process.env, effective.sources);
61
+ emitSuccess({ isJson: options.isJson }, report, () => {
62
+ console.log(`version: ${report.version}`);
63
+ console.log(`runtime: ${report.runtime}`);
64
+ console.log(`image variant: ${report.image_variant ?? "none"}`);
65
+ console.log(`vault path: ${report.vault_path}`);
66
+ console.log(`state directory: ${report.state_dir}`);
67
+ console.log(`inference mode: ${report.mode}`);
68
+ console.log(`routing capability: ${report.capability}`);
69
+ console.log(`retrieval capability: ${report.retrieval_capability}`);
70
+ for (const check of report.checks)
71
+ console.log(
72
+ `${check.ok ? green("ok") : red("fail")}: ${check.name} - ${check.detail}`,
73
+ );
74
+ });
75
+ if (report.checks.some((check) => !check.ok)) process.exitCode = 1;
76
+ }
@@ -1,15 +1,16 @@
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 { TargetAdapter } 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: TargetAdapter },
13
14
  ): Promise<void> {
14
15
  let since: string | undefined;
15
16
  let target: string | undefined;
@@ -21,8 +22,10 @@ export async function runEvalPromote(
21
22
  else if (arg === "--target") target = args[++i];
22
23
  else if (arg === "--dry-run") dryRun = true;
23
24
  else if (arg === "--yes") yes = true;
24
- else if (arg === "--json") {
25
+ else if (isGlobalFlag(arg, "--json", "--allow-insecure", "--verbose")) {
25
26
  // handled globally by main()'s isJson flag; recognized here so it isn't rejected
27
+ } else if (isGlobalFlagWithValue(arg)) {
28
+ i++; // skip flag value
26
29
  } else if (arg?.startsWith("--")) {
27
30
  throw new Error(`unknown eval promote option: ${arg}`);
28
31
  }
@@ -37,18 +40,12 @@ export async function runEvalPromote(
37
40
  const sinceDate = parseSince(since);
38
41
  const sinceIso = sinceDate.toISOString();
39
42
 
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
- }
43
+ const candidates = await options.adapter.evalPromote(since);
47
44
 
48
45
  const existing = existsSync(targetPath) ? parseEvalCases(JSON.parse(readFileSync(targetPath, "utf-8"))) : [];
49
46
  const { cases: newCases, skipped } = excludeExistingCases(candidates, existing);
50
47
 
51
- console.error("warning: promoted eval cases contain raw user queries");
48
+ warn("promoted eval cases contain raw user queries");
52
49
 
53
50
  if (dryRun) {
54
51
  emitSuccess(