@klhapp/skillmux 1.3.3 → 1.4.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.
@@ -10,8 +10,67 @@ skillmux config validate
10
10
 
11
11
  Add `--json` when you need machine-readable diagnostics.
12
12
 
13
+ ## Startup without configuration
14
+
15
+ `skillmux serve` and `skillmux serve --transport http` do not require a config
16
+ file or an existing `~/.config/skillmux` directory. If you need a specific
17
+ vault, remote inference, or HTTP policy, create a config with `skillmux config
18
+ init --vault ~/skills --yes`; otherwise the server uses its defaults. A missing
19
+ optional config parent disables live reload until the next server start. For a
20
+ malformed watched config, check the reported reload error—the running server
21
+ continues with its last known good configuration.
22
+
23
+ ## Docker rejected a CLI command
24
+
25
+ The server image can inspect and serve a mounted vault, but it cannot manage
26
+ agent directories or mutate host configuration. Its help lists the supported
27
+ server commands. For `init`, `sync`, install or pin management, model
28
+ downloads, contexts, calibration, evaluation, project/target/local-vault
29
+ management, or `config init`/`config set`, install and use the host CLI:
30
+
31
+ ```sh
32
+ bun add -g @klhapp/skillmux
33
+ skillmux <the-command Docker rejected>
34
+ ```
35
+
36
+ The rejection exits 2. With `--json`, check
37
+ `error.code == "CONTAINER_COMMAND_UNSUPPORTED"`, then use
38
+ `error.details.recommended_host_command`; see the
39
+ [container command contract](deployment.md#container-command-contract).
40
+
41
+ ## Installation failures
42
+
43
+ ### Standalone executable checksum verification fails
44
+
45
+ Do not install the downloaded file. Confirm that the command still uses the
46
+ pinned release and that the detected architecture is correct:
47
+
48
+ ```sh
49
+ uname -m
50
+ sha256sum skillmux-linux-amd64
51
+ ```
52
+
53
+ Download the file again with the [standalone installation procedure](getting-started.md#install-the-cli).
54
+ For GitHub build-provenance verification instead of the published SHA-256
55
+ digest, use the [GitHub CLI attestation procedure](getting-started.md#install-with-github-cli-attestation).
56
+
57
+ ### `skillmux: command not found` after installation
58
+
59
+ The standalone installer defaults to `~/.local/bin`. Add it to the shell's
60
+ `PATH`, restart the shell, then run `skillmux --version`. Alternatively,
61
+ install to a user-writable directory already on `PATH` with
62
+ `SKILLMUX_BIN_DIR=/path/on/PATH`; use `sudo install` only when you explicitly
63
+ want a system-wide installation.
64
+
13
65
  ## Vault failures
14
66
 
67
+ ### Checkouts are out of date
68
+
69
+ Skillmux reads the configured vault checkout; it does not pull, push,
70
+ replicate, or determine freshness between checkouts. Update the Git-backed
71
+ vault source of truth and use your Git or deployment process to update the
72
+ affected checkout, then rerun `skillmux index` or `skillmux sync` as needed.
73
+
15
74
  ### Vault path does not exist
16
75
 
17
76
  Check the effective path and its source:
@@ -84,8 +143,9 @@ target for the current machine instead of reusing the other machine's path.
84
143
 
85
144
  ### A local-overlay skill cannot be pinned
86
145
 
87
- Core and project pins must exist in the canonical `vault_path`. Copy or commit
88
- the skill there before pinning it. Routed lookup can still serve the overlay.
146
+ Core and project pins must exist in the configured `vault_path` checkout. Copy
147
+ or commit the skill there before pinning it. Routed lookup can still serve the
148
+ overlay.
89
149
 
90
150
  ## Retrieval failures
91
151
 
@@ -112,8 +172,8 @@ step depends on the installation:
112
172
  | Installation | Expected action |
113
173
  | --- | --- |
114
174
  | Skillmux CLI with local inference | Download the local model and rebuild the index |
115
- | Full Docker image | Confirm the running tag is the full image and inspect `doctor` output |
116
- | Slim Docker image | Configure remote embeddings or keep lexical fallback |
175
+ | Skillmux server (full image) | Inspect `doctor`, `/health/ready`, or `skill_router_deployment_info` for `image_variant=full`; do not infer it from the tag |
176
+ | Skillmux server (slim image) | Configure remote embeddings or keep lexical fallback |
117
177
 
118
178
  For a Skillmux CLI installation, run:
119
179
 
@@ -158,7 +218,7 @@ Add the browser origin to `server.allowed_origins`. The value must match the
158
218
  request's `Origin` header. Curl and server-to-server clients omit this header
159
219
  and do not use the CORS list.
160
220
 
161
- ### Client receives `401`
221
+ ### MCP client receives `401`
162
222
 
163
223
  Confirm `server.auth_enabled = true`, export the environment variable named by
164
224
  `auth_token_env`, and send `Authorization: Bearer <token>`.
@@ -166,6 +226,26 @@ Confirm `server.auth_enabled = true`, export the environment variable named by
166
226
  An enabled server with an empty token environment variable returns a server
167
227
  configuration error rather than accepting an empty token.
168
228
 
229
+ ### Operator receives `401` from `/admin/v1/*`
230
+
231
+ Administrative authentication is separate from MCP authentication. Confirm
232
+ `server.admin.enabled = true`, export the environment variable named by
233
+ `server.admin.token_env`, and configure the named CLI context with that same
234
+ environment-variable name:
235
+
236
+ ```sh
237
+ export SKILLMUX_PROD_ADMIN_TOKEN="replace-with-admin-token"
238
+ skillmux context add prod \
239
+ --server https://skillmux.example.com \
240
+ --token-env SKILLMUX_PROD_ADMIN_TOKEN
241
+ skillmux --context prod config status
242
+ ```
243
+
244
+ An MCP token for `/mcp` cannot authenticate this request, and the administrative
245
+ token cannot authenticate an MCP client. A named context administers the
246
+ deployed server configuration only; use Skillmux CLI on the machine that owns
247
+ client directories for `install`, pinning, or `sync`.
248
+
169
249
  ### Client receives `429`
170
250
 
171
251
  The rate limiter rejected the request. Read `Retry-After` and the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.3.3",
3
+ "version": "1.4.0",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts CHANGED
@@ -97,6 +97,7 @@ import {
97
97
  import { createTargetAdapter, type TargetAdapter } from "./adapters";
98
98
  import {
99
99
  emitSuccess,
100
+ CliError,
100
101
  formatJsonEnvelope,
101
102
  isInteractive,
102
103
  mapExitCode,
@@ -133,9 +134,6 @@ const KNOWN_COMMANDS = [
133
134
  "local-vault",
134
135
  ];
135
136
 
136
- const DOCKER_HOST_MANAGEMENT_GUIDANCE =
137
- "This command manages local Skillmux or agent directories and is not supported inside the Docker image. Install the Skillmux CLI on the host using the Bun package or standalone Linux executable.";
138
-
139
137
  function isDockerHostManagementCommand(command: string, subCommand: string): boolean {
140
138
  if (
141
139
  [
@@ -158,6 +156,33 @@ function isDockerHostManagementCommand(command: string, subCommand: string): boo
158
156
  return command === "config" && ["init", "set"].includes(subCommand);
159
157
  }
160
158
 
159
+ function containerCommandUnsupported(command: string, subCommand: string): CliError {
160
+ const rejectedCommand = [command, subCommand].filter(Boolean).join(" ");
161
+ const recommendedHostCommand = `skillmux ${rejectedCommand}`;
162
+ const guide = "docs/deployment.md";
163
+ const documentation =
164
+ "https://github.com/klhq/skillmux/blob/main/docs/deployment.md#container-command-contract";
165
+ return new CliError(
166
+ `\`skillmux ${rejectedCommand}\` manages host agent directories and cannot run in the Skillmux server image.\n\n` +
167
+ "Install the host CLI:\n" +
168
+ " bun add -g @klhapp/skillmux\n\n" +
169
+ "Then run:\n" +
170
+ ` ${recommendedHostCommand}\n\n` +
171
+ `See ${guide} for server deployment examples.`,
172
+ 2,
173
+ "CONTAINER_COMMAND_UNSUPPORTED",
174
+ {
175
+ // `command` remains for automation written against the first Docker
176
+ // boundary release. `rejected_command` is the explicit contract name.
177
+ command: rejectedCommand,
178
+ rejected_command: rejectedCommand,
179
+ recommended_host_command: recommendedHostCommand,
180
+ guide,
181
+ documentation,
182
+ },
183
+ );
184
+ }
185
+
161
186
  async function main() {
162
187
  const rawArgv = Bun.argv.slice(2);
163
188
 
@@ -167,7 +192,10 @@ async function main() {
167
192
  let flagContext: string | undefined;
168
193
  let flagServer: string | undefined;
169
194
  let isDryRun = false;
170
- const subCommand = rawArgv[1] ?? "";
195
+ // Global flags do not form part of the command identity reported to users.
196
+ // In particular, `init --json` should recommend `skillmux init`, not a
197
+ // redundant JSON-only host command.
198
+ const subCommand = rawArgv[1]?.startsWith("-") ? "" : rawArgv[1] ?? "";
171
199
  const commandArgs = rawArgv.slice(2);
172
200
 
173
201
  const command = rawArgv[0];
@@ -198,7 +226,7 @@ async function main() {
198
226
  process.env.RUNNING_IN_DOCKER === "true" &&
199
227
  isDockerHostManagementCommand(command, subCommand)
200
228
  ) {
201
- handleError(new Error(DOCKER_HOST_MANAGEMENT_GUIDANCE), {
229
+ handleError(containerCommandUnsupported(command, subCommand), {
202
230
  target: resolvedTarget,
203
231
  isJson,
204
232
  isVerbose,
@@ -572,7 +600,11 @@ function handleError(
572
600
  const env = formatJsonEnvelope({
573
601
  ok: false,
574
602
  target: opts.target,
575
- error: { code: `EXIT_${code}`, message: msg },
603
+ error: {
604
+ code: err instanceof CliError ? err.code : `EXIT_${code}`,
605
+ message: msg,
606
+ details: err instanceof CliError ? err.details : undefined,
607
+ },
576
608
  });
577
609
  console.log(JSON.stringify(env));
578
610
  } else {
@@ -590,6 +622,23 @@ function handleError(
590
622
  }
591
623
 
592
624
  function printHelp(): void {
625
+ if (process.env.RUNNING_IN_DOCKER === "true") {
626
+ console.log(`Skillmux server image
627
+
628
+ Default:
629
+ serve --transport http
630
+
631
+ Supported commands:
632
+ serve, index, doctor, report, scan, skill which
633
+ config show|get|validate|diff|status
634
+
635
+ Native skill management:
636
+ Install the Skillmux CLI on the host for init, install, pinning, and sync.
637
+
638
+ See docs/deployment.md for server deployment examples.`);
639
+ return;
640
+ }
641
+
593
642
  console.log(`usage: skillmux <command> [options]
594
643
 
595
644
  Setup:
@@ -698,8 +747,14 @@ async function runEval(options: { isJson: boolean }): Promise<void> {
698
747
  async function runDoctor(options: { isJson: boolean }): Promise<void> {
699
748
  const report = await diagnose(await loadConfig());
700
749
  emitSuccess({ isJson: options.isJson }, report, () => {
750
+ console.log(`version: ${report.version}`);
751
+ console.log(`runtime: ${report.runtime}`);
752
+ console.log(`image variant: ${report.image_variant ?? "none"}`);
753
+ console.log(`vault path: ${report.vault_path}`);
754
+ console.log(`state directory: ${report.state_dir}`);
701
755
  console.log(`inference mode: ${report.mode}`);
702
756
  console.log(`routing capability: ${report.capability}`);
757
+ console.log(`retrieval capability: ${report.retrieval_capability}`);
703
758
  for (const check of report.checks)
704
759
  console.log(
705
760
  `${check.ok ? "ok" : "fail"}: ${check.name} - ${check.detail}`,
@@ -192,6 +192,8 @@ export async function handleConfigCommand(
192
192
  emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
193
193
  renderTargetBanner(ctx.target);
194
194
  console.log(`Runtime: ${res.runtime}`);
195
+ console.log(`Deployment runtime: ${res.deployment_runtime}`);
196
+ console.log(`Image variant: ${res.image_variant ?? "none"}`);
195
197
  console.log(`Active revision: ${res.active_revision}`);
196
198
  console.log(`Readiness: ${res.readiness.status}`);
197
199
  });
@@ -2,6 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, mkdirSync, renameSync, statSync, writeFileSync } from "node:fs";
3
3
  import { dirname, join } from "node:path";
4
4
  import { DEFAULT_CONFIG_PATH, expandHome, loadConfig } from "./config";
5
+ import { describeDeployment } from "./deployment";
5
6
  import type { Config } from "./types";
6
7
 
7
8
  export type ConfigSource = "default" | "toml" | "environment";
@@ -32,6 +33,9 @@ export interface ConfigStatusResponse {
32
33
  readiness: { status: "ready" | "degraded" | "not_ready" | "stopping"; capability: string };
33
34
  restart_required_keys: string[];
34
35
  runtime: "running" | "not_running";
36
+ version: string;
37
+ deployment_runtime: "host" | "docker";
38
+ image_variant: "full" | "slim" | null;
35
39
  }
36
40
 
37
41
  export const RESTART_REQUIRED_KEYS = [
@@ -409,6 +413,7 @@ function formatTomlVal(v: unknown): string {
409
413
  export async function getLocalConfigStatus(configPath?: string): Promise<ConfigStatusResponse> {
410
414
  const { effective } = await getEffectiveConfig(configPath);
411
415
  const hash = computeHash(effective);
416
+ const deployment = describeDeployment(effective);
412
417
 
413
418
  return {
414
419
  target: "local",
@@ -421,5 +426,8 @@ export async function getLocalConfigStatus(configPath?: string): Promise<ConfigS
421
426
  readiness: { status: "ready", capability: "hybrid" },
422
427
  restart_required_keys: [],
423
428
  runtime: "not_running",
429
+ version: deployment.version,
430
+ deployment_runtime: deployment.runtime,
431
+ image_variant: deployment.image_variant,
424
432
  };
425
433
  }
@@ -1,4 +1,4 @@
1
- import { mkdirSync, watch } from "node:fs";
1
+ import { existsSync, watch } from "node:fs";
2
2
  import { dirname } from "node:path";
3
3
  import { loadConfig } from "./config";
4
4
  import type { Config } from "./types";
@@ -123,7 +123,7 @@ export class ConfigWatcher {
123
123
  };
124
124
  private stopped = false;
125
125
  private debounceTimer: ReturnType<typeof setTimeout> | null = null;
126
- private watcher: ReturnType<typeof watch>;
126
+ private watcher: ReturnType<typeof watch> | undefined;
127
127
 
128
128
  private constructor(
129
129
  private readonly tomlPath: string,
@@ -133,11 +133,13 @@ export class ConfigWatcher {
133
133
  const dir = dirname(tomlPath);
134
134
  const filename = tomlPath.split(/[/\\]/).pop()!;
135
135
 
136
- // A config file is optional. Ensure its parent exists so zero-config
137
- // startup is safe and later config writes are still observed.
138
- mkdirSync(dir, { recursive: true });
136
+ // A config file is optional. Do not create its parent just to enable
137
+ // reloads: a native zero-config server must also work with a read-only or
138
+ // entirely absent config location. Reloads stay inactive until a future
139
+ // server start finds a watchable parent directory.
140
+ if (!existsSync(dir)) return;
139
141
 
140
- this.watcher = watch(dir, { recursive: false }, (_event, changedName) => {
142
+ const watcher = watch(dir, { recursive: false }, (_event, changedName) => {
141
143
  if (this.stopped) return;
142
144
  // Fire for: the config file itself, or any .tmp variant of it (handles
143
145
  // pid-numbered atomics: config.toml.12345.tmp → rename → config.toml).
@@ -151,8 +153,9 @@ export class ConfigWatcher {
151
153
  }
152
154
  this.scheduleReload();
153
155
  });
156
+ this.watcher = watcher;
154
157
 
155
- this.watcher.on("error", (err) => {
158
+ watcher.on("error", (err) => {
156
159
  if (!this.stopped) {
157
160
  this.status = { ...this.status, last_reload_error: String(err) };
158
161
  this.opts.onError(err);
@@ -243,7 +246,7 @@ export class ConfigWatcher {
243
246
  this.debounceTimer = null;
244
247
  }
245
248
  try {
246
- this.watcher.close();
249
+ this.watcher?.close();
247
250
  } catch {
248
251
  // already closed
249
252
  }
@@ -0,0 +1,39 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+ import { expandHome } from "./config";
3
+ import type { Config } from "./types";
4
+
5
+ export interface DeploymentIdentity {
6
+ version: string;
7
+ runtime: "host" | "docker";
8
+ image_variant: "full" | "slim" | null;
9
+ vault_path: string;
10
+ state_dir: string;
11
+ inference_mode: Config["inference"]["mode"];
12
+ local_embedding_bundle: string | null;
13
+ remote_embedding_configured: boolean;
14
+ remote_reranker_configured: boolean;
15
+ }
16
+
17
+ export function describeDeployment(
18
+ config: Config,
19
+ environment: Record<string, string | undefined> = process.env,
20
+ ): DeploymentIdentity {
21
+ const runtime = environment.RUNNING_IN_DOCKER === "true" ? "docker" : "host";
22
+ const variant = environment.SKILLMUX_IMAGE_VARIANT;
23
+ const imageVariant = runtime === "docker" && (variant === "full" || variant === "slim")
24
+ ? variant
25
+ : null;
26
+
27
+ return {
28
+ version: packageJson.version,
29
+ runtime,
30
+ image_variant: imageVariant,
31
+ vault_path: expandHome(config.vault_path),
32
+ state_dir: expandHome(config.state_dir),
33
+ inference_mode: config.inference.mode,
34
+ local_embedding_bundle: config.inference.mode === "local" ? config.inference.bundle : null,
35
+ remote_embedding_configured: config.inference.mode === "remote",
36
+ remote_reranker_configured:
37
+ config.inference.mode === "remote" && !!config.inference.reranker,
38
+ };
39
+ }
package/src/doctor.ts CHANGED
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync } from "node:fs";
2
2
  import { computeCorpusFingerprint, getCalibrationRun, openCalibrateDb } from "./calibrate";
3
3
  import { createClients, RemoteInferenceError } from "./clients";
4
4
  import { embeddingDimension, embeddingFingerprint, expandHome, rerankerFingerprint } from "./config";
5
+ import { describeDeployment, type DeploymentIdentity } from "./deployment";
5
6
  import { openIndex } from "./db";
6
7
  import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
7
8
  import { readSkillmuxMarker } from "./sync";
@@ -18,6 +19,16 @@ export interface DoctorCheck {
18
19
  export interface DoctorReport {
19
20
  mode: Config["inference"]["mode"];
20
21
  capability: "hybrid" | "lexical-only" | "unavailable";
22
+ retrieval_capability: "lexical" | "hybrid" | "reranked";
23
+ version: DeploymentIdentity["version"];
24
+ runtime: DeploymentIdentity["runtime"];
25
+ image_variant: DeploymentIdentity["image_variant"];
26
+ vault_path: DeploymentIdentity["vault_path"];
27
+ state_dir: DeploymentIdentity["state_dir"];
28
+ inference_mode: DeploymentIdentity["inference_mode"];
29
+ local_embedding_bundle: DeploymentIdentity["local_embedding_bundle"];
30
+ remote_embedding_configured: DeploymentIdentity["remote_embedding_configured"];
31
+ remote_reranker_configured: DeploymentIdentity["remote_reranker_configured"];
21
32
  checks: DoctorCheck[];
22
33
  }
23
34
 
@@ -93,7 +104,12 @@ function checkCalibration(config: Config): DoctorCheck {
93
104
  return { name: "calibration", ok: true, detail: `thresholds from applied calibration run "${runId}"` };
94
105
  }
95
106
 
96
- export async function diagnose(config: Config): Promise<DoctorReport> {
107
+ export { describeDeployment };
108
+
109
+ export async function diagnose(
110
+ config: Config,
111
+ environment: Record<string, string | undefined> = process.env,
112
+ ): Promise<DoctorReport> {
97
113
  const checks: DoctorCheck[] = [];
98
114
  checks.push({ name: "vault", ok: existsSync(expandHome(config.vault_path)), detail: expandHome(config.vault_path) });
99
115
 
@@ -179,27 +195,37 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
179
195
  ? { detail: error.message, failure_kind: error.kind }
180
196
  : { detail: "unexpected inference failure", failure_kind: "unexpected" };
181
197
 
182
- const clients = createClients(config);
183
- try {
184
- const vectors = await clients.embed(["skill router diagnostic"]);
185
- const actualDimension = vectors[0]?.length ?? 0;
198
+ const deployment = describeDeployment(config, environment);
199
+ const lexicalOnlySlim = deployment.image_variant === "slim" && config.inference.mode === "local";
200
+ if (lexicalOnlySlim) {
186
201
  checks.push({
187
- name: "embedding",
188
- ok: actualDimension === embeddingDimension(config),
189
- detail: `dimension ${actualDimension}`,
202
+ name: "retrieval",
203
+ ok: true,
204
+ detail: "lexical retrieval; Configure remote embeddings for hybrid retrieval",
190
205
  });
191
- } catch (error) {
192
- checks.push({ name: "embedding", ok: false, ...inferenceFailure(error) });
193
- }
194
-
195
- if (clients.rerank) {
206
+ } else {
207
+ const clients = createClients(config);
196
208
  try {
197
- const scores = await clients.rerank("skill router diagnostic", [
198
- { skill_id: "doctor", text: "Routes a task to an appropriate skill." },
199
- ]);
200
- checks.push({ name: "reranker", ok: scores.length === 1 && Number.isFinite(scores[0]), detail: "one finite score" });
209
+ const vectors = await clients.embed(["skill router diagnostic"]);
210
+ const actualDimension = vectors[0]?.length ?? 0;
211
+ checks.push({
212
+ name: "embedding",
213
+ ok: actualDimension === embeddingDimension(config),
214
+ detail: `dimension ${actualDimension}`,
215
+ });
201
216
  } catch (error) {
202
- checks.push({ name: "reranker", ok: false, ...inferenceFailure(error) });
217
+ checks.push({ name: "embedding", ok: false, ...inferenceFailure(error) });
218
+ }
219
+
220
+ if (clients.rerank) {
221
+ try {
222
+ const scores = await clients.rerank("skill router diagnostic", [
223
+ { skill_id: "doctor", text: "Routes a task to an appropriate skill." },
224
+ ]);
225
+ checks.push({ name: "reranker", ok: scores.length === 1 && Number.isFinite(scores[0]), detail: "one finite score" });
226
+ } catch (error) {
227
+ checks.push({ name: "reranker", ok: false, ...inferenceFailure(error) });
228
+ }
203
229
  }
204
230
  }
205
231
 
@@ -208,11 +234,14 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
208
234
  }
209
235
 
210
236
  const inferenceReady = checks.some((check) => check.name === "embedding" && check.ok);
237
+ const rerankerReady = checks.some((check) => check.name === "reranker" && check.ok);
211
238
  const coreReady = checks.some((check) => check.name === "vault" && check.ok)
212
239
  && checks.some((check) => check.name === "state" && check.ok);
213
240
  return {
241
+ ...deployment,
214
242
  mode: config.inference.mode,
215
243
  capability: !coreReady ? "unavailable" : inferenceReady ? "hybrid" : "lexical-only",
244
+ retrieval_capability: rerankerReady ? "reranked" : inferenceReady ? "hybrid" : "lexical",
216
245
  checks,
217
246
  };
218
247
  }
package/src/metrics.ts CHANGED
@@ -1,4 +1,10 @@
1
1
  import type { ReadinessSnapshot } from "./readiness";
2
+ import type { DeploymentIdentity } from "./deployment";
3
+
4
+ type MetricsDeploymentIdentity = Pick<
5
+ DeploymentIdentity,
6
+ "version" | "runtime" | "image_variant"
7
+ >;
2
8
 
3
9
  export class MetricsRegistry {
4
10
  private requests = new Map<string, number>();
@@ -13,11 +19,16 @@ export class MetricsRegistry {
13
19
  private errors = 0;
14
20
  private rateLimitsExceeded = 0;
15
21
  private readiness: ReadinessSnapshot | null = null;
22
+ private deployment: MetricsDeploymentIdentity | null = null;
16
23
 
17
24
  setReadiness(readiness: ReadinessSnapshot) {
18
25
  this.readiness = readiness;
19
26
  }
20
27
 
28
+ setDeployment(deployment: MetricsDeploymentIdentity) {
29
+ this.deployment = deployment;
30
+ }
31
+
21
32
  recordRequest(method: string) {
22
33
  this.requests.set(method, (this.requests.get(method) || 0) + 1);
23
34
  }
@@ -96,6 +107,13 @@ export class MetricsRegistry {
96
107
  lines.push(`skill_router_retrieval_capability{capability="${capability}"} ${this.readiness?.retrieval === capability ? 1 : 0}`);
97
108
  }
98
109
 
110
+ if (this.deployment) {
111
+ const imageVariant = this.deployment.image_variant ?? "none";
112
+ lines.push("# HELP skill_router_deployment_info Immutable deployment identity for operator comparison.");
113
+ lines.push("# TYPE skill_router_deployment_info gauge");
114
+ lines.push(`skill_router_deployment_info{version="${this.deployment.version}",runtime="${this.deployment.runtime}",image_variant="${imageVariant}"} 1`);
115
+ }
116
+
99
117
  return lines.join("\n") + "\n";
100
118
  }
101
119
  }
package/src/output.ts CHANGED
@@ -38,11 +38,15 @@ export function formatJsonEnvelope<T>(opts: {
38
38
 
39
39
  export class CliError extends Error {
40
40
  exitCode: number;
41
+ code: string;
42
+ details?: unknown;
41
43
 
42
- constructor(message: string, exitCode: number) {
44
+ constructor(message: string, exitCode: number, code = `EXIT_${exitCode}`, details?: unknown) {
43
45
  super(message);
44
46
  this.name = "CliError";
45
47
  this.exitCode = exitCode;
48
+ this.code = code;
49
+ this.details = details;
46
50
  }
47
51
  }
48
52
 
@@ -1,5 +1,5 @@
1
1
  import type { Database } from "bun:sqlite";
2
- import { watch } from "node:fs";
2
+ import { existsSync, watch } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { buildAuditRow } from "./audit";
5
5
  import { embeddingDimension, embeddingFingerprint, expandHome, loadConfig } from "./config";
@@ -334,6 +334,11 @@ export async function startVaultWatcher(): Promise<() => void> {
334
334
  const vaultPath = expandHome(config.vault_path);
335
335
  const timers = new Map<string, ReturnType<typeof setTimeout>>();
336
336
 
337
+ // A fresh installation may not have a vault checkout yet. Serving an empty
338
+ // vault is valid; leave live indexing inactive until the next server start
339
+ // finds a checkout rather than failing startup with ENOENT.
340
+ if (!existsSync(vaultPath)) return () => {};
341
+
337
342
  const watcher = watch(vaultPath, { recursive: true }, (_event, filename) => {
338
343
  const skillId = filename?.split(/[\\/]/)[0];
339
344
  if (!skillId || !SKILL_ID_PATTERN.test(skillId)) return;