@klhapp/skillmux 0.6.0 → 1.0.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.
@@ -1,14 +1,37 @@
1
1
  export type ShellType = "bash" | "zsh" | "fish";
2
2
 
3
+ const TOP_LEVEL_COMMANDS: { name: string; description: string }[] = [
4
+ { name: "context", description: "Manage connection contexts" },
5
+ { name: "config", description: "Manage configuration" },
6
+ { name: "calibrate", description: "Manage policy calibration" },
7
+ { name: "serve", description: "Start MCP server" },
8
+ { name: "index", description: "Rebuild local search index" },
9
+ { name: "sync", description: "Synchronize vault skills" },
10
+ { name: "init", description: "Configure this machine and its clients" },
11
+ { name: "project", description: "Configure project-scoped skills" },
12
+ { name: "target", description: "Manage advanced skill-delivery targets" },
13
+ { name: "core", description: "Pin/unpin skills into [core]" },
14
+ { name: "report", description: "Generate usage stats" },
15
+ { name: "scan", description: "Audit skills for issues" },
16
+ { name: "install", description: "Install skills into vault" },
17
+ { name: "eval", description: "Evaluate search accuracy" },
18
+ { name: "doctor", description: "Check runtime health" },
19
+ { name: "skill", description: "Show which root resolves a skill_id" },
20
+ { name: "local-vault", description: "Manage local_vault_paths discoverability markers" },
21
+ { name: "models", description: "Manage local models" },
22
+ { name: "completions", description: "Generate shell completions" },
23
+ ];
24
+
3
25
  export function generateCompletions(shell: ShellType): string {
4
26
  if (shell === "bash") {
27
+ const opts = [...TOP_LEVEL_COMMANDS.map((c) => c.name), "--context", "--server", "--json", "--allow-insecure", "--verbose", "--dry-run", "--help"].join(" ");
5
28
  return `# bash completion for skillmux
6
29
  _skillmux_completions() {
7
30
  local cur prev opts
8
31
  COMPREPLY=()
9
32
  cur="\${COMP_WORDS[COMP_CWORD]}"
10
33
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
11
- opts="context config calibrate serve index sync init project target report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
34
+ opts="${opts}"
12
35
 
13
36
  if [ "$COMP_CWORD" -eq 1 ]; then
14
37
  COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
@@ -28,9 +51,12 @@ _skillmux_completions() {
28
51
  completions)
29
52
  COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
30
53
  ;;
31
- manifest)
54
+ core)
32
55
  COMPREPLY=( $(compgen -W "pin unpin" -- "$cur") )
33
56
  ;;
57
+ skill)
58
+ COMPREPLY=( $(compgen -W "which" -- "$cur") )
59
+ ;;
34
60
  project)
35
61
  COMPREPLY=( $(compgen -W "init list show add-path remove-path pin unpin attach detach" -- "$cur") )
36
62
  ;;
@@ -48,7 +74,7 @@ _skillmux_completions() {
48
74
  ;;
49
75
  esac
50
76
  if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
51
- COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
77
+ COMPREPLY=( $(compgen -W "--client --target --dir --vault --core --migrate-full-vault --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
52
78
  fi
53
79
  if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
54
80
  COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
@@ -59,62 +85,51 @@ complete -F _skillmux_completions skillmux
59
85
  }
60
86
 
61
87
  if (shell === "zsh") {
88
+ const commands = TOP_LEVEL_COMMANDS.map((c) => ` '${c.name}:${c.description}'`).join("\n");
62
89
  return `#compdef skillmux
63
90
  _skillmux() {
64
91
  local -a commands
65
92
  commands=(
66
- 'context:Manage connection contexts'
67
- 'config:Manage configuration'
68
- 'calibrate:Manage policy calibration'
69
- 'serve:Start MCP server'
70
- 'index:Rebuild local search index'
71
- 'sync:Synchronize vault skills'
72
- 'init:Initialize project targets'
73
- 'project:Configure project-scoped skills'
74
- 'target:Manage advanced skill-delivery targets'
75
- 'report:Generate usage stats'
76
- 'scan:Audit skills for issues'
77
- 'install:Install skills into vault'
78
- 'eval:Evaluate search accuracy'
79
- 'doctor:Check runtime health'
80
- 'which:Show which root resolves a skill_id'
81
- 'manifest:Pin/unpin skills into [core] or [project.*]'
82
- 'local-vault:Manage local_vault_paths discoverability markers'
83
- 'models:Manage local models'
84
- 'completions:Generate shell completions'
93
+ ${commands}
85
94
  )
86
95
  if (( CURRENT == 2 )); then
87
96
  _describe -t commands 'skillmux command' commands
88
97
  elif [[ "$words[2]" == "init" ]]; then
89
- _arguments \
90
- '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \
91
- '*--target[select a target]:target:(agent-skills claude-code codex custom)' \
92
- '--path[custom target directory]:directory:_directories' \
93
- '--vault[vault directory]:directory:_directories' \
94
- '*--core[seed a core skill]:skill id:' \
95
- '--migrate-full-vault[convert a full-vault symlink to managed pins]' \
96
- '--no-instructions[skip managed instruction files]' \
97
- '--no-sync[save setup without synchronizing targets]' \
98
- '--interactive[force guided setup]' \
99
- '--yes[apply without prompts]' \
100
- '--dry-run[print the plan without writing]' \
98
+ _arguments \\
99
+ '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)' \\
100
+ '*--target[select a delivery target]:target:(agent-skills claude-code codex custom)' \\
101
+ '--dir[custom target directory]:directory:_directories' \\
102
+ '--vault[vault directory]:directory:_directories' \\
103
+ '*--core[seed a core skill]:skill id:' \\
104
+ '--migrate-full-vault[convert a full-vault symlink to managed pins]' \\
105
+ '--no-instructions[skip managed instruction files]' \\
106
+ '--no-sync[save setup without synchronizing targets]' \\
107
+ '--interactive[force guided setup]' \\
108
+ '--yes[apply without prompts]' \\
109
+ '--dry-run[print the plan without writing]' \\
101
110
  '--json[emit a JSON envelope]'
102
111
  elif [[ "$words[2]" == "project" && "$words[3]" == "init" ]]; then
103
- _arguments \
104
- '1:project directory:_directories' \
105
- '--name[project group name]:group:' \
106
- '*--skill[project skill]:skill id:' \
107
- '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity)' \
108
- '*--target[select an advanced target]:target:' \
109
- '--no-sync[save setup without synchronizing targets]' \
110
- '--interactive[force guided setup]' \
111
- '--yes[apply without prompts]' \
112
- '--dry-run[print the plan without writing]' \
112
+ _arguments \\
113
+ '1:project directory:_directories' \\
114
+ '--name[project group name]:group:' \\
115
+ '*--skill[project skill]:skill id:' \\
116
+ '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity)' \\
117
+ '*--target[select an advanced target]:target:' \\
118
+ '--no-sync[save setup without synchronizing targets]' \\
119
+ '--interactive[force guided setup]' \\
120
+ '--yes[apply without prompts]' \\
121
+ '--dry-run[print the plan without writing]' \\
113
122
  '--json[emit a JSON envelope]'
114
123
  elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
115
124
  _values 'project command' init list show add-path remove-path pin unpin attach detach
116
125
  elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
117
126
  _values 'target command' list show add remove
127
+ elif [[ "$words[2]" == "skill" && CURRENT == 3 ]]; then
128
+ _values 'skill command' which
129
+ elif [[ "$words[2]" == "core" && CURRENT == 3 ]]; then
130
+ _values 'core command' pin unpin
131
+ elif [[ "$words[2]" == "local-vault" && CURRENT == 3 ]]; then
132
+ _values 'local-vault command' init
118
133
  fi
119
134
  }
120
135
  _skillmux "$@"
@@ -122,19 +137,15 @@ _skillmux "$@"
122
137
  }
123
138
 
124
139
  if (shell === "fish") {
140
+ const topLevel = TOP_LEVEL_COMMANDS.map(
141
+ (c) => `complete -c skillmux -n "__fish_use_subcommand" -a ${c.name} -d "${c.description}"`,
142
+ ).join("\n");
125
143
  return `# fish completion for skillmux
126
144
  complete -c skillmux -f
127
- complete -c skillmux -n "__fish_use_subcommand" -a context -d "Manage connection contexts"
128
- complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
129
- complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
130
- complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
131
- complete -c skillmux -n "__fish_use_subcommand" -a init -d "Configure this machine and its clients"
132
- complete -c skillmux -n "__fish_use_subcommand" -a project -d "Configure project-scoped skills"
133
- complete -c skillmux -n "__fish_use_subcommand" -a target -d "Manage advanced skill-delivery targets"
134
- complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
145
+ ${topLevel}
135
146
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -d "Select a client"
136
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a target"
137
- complete -c skillmux -n "__fish_seen_subcommand_from init" -l path -r -d "Custom target directory"
147
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a delivery target"
148
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l dir -r -d "Custom target directory"
138
149
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
139
150
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
140
151
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
@@ -148,11 +159,14 @@ complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show
148
159
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
149
160
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
150
161
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity" -d "Select a client"
151
- complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced target"
162
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced delivery target"
152
163
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
153
164
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
154
165
  complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
155
166
  complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
167
+ complete -c skillmux -n "__fish_seen_subcommand_from core" -a "pin unpin" -d "Manage [core] pins"
168
+ complete -c skillmux -n "__fish_seen_subcommand_from skill" -a "which" -d "Show which root resolves a skill_id"
169
+ complete -c skillmux -n "__fish_seen_subcommand_from local-vault" -a "init" -d "Initialize a local_vault_paths marker"
156
170
  `;
157
171
  }
158
172
 
@@ -14,6 +14,7 @@ export const LIVE_RELOAD_KEYS = new Set([
14
14
  "inference.thresholds.match_score",
15
15
  "inference.thresholds.match_margin",
16
16
  "inference.thresholds.candidate_floor",
17
+ "inference.calibration.run_id",
17
18
  "recall.k_lexical",
18
19
  "recall.k_vector",
19
20
  "thresholds.candidate_limit",
@@ -53,6 +54,47 @@ const DEBOUNCE_MS = 300;
53
54
  const STABLE_STAT_INTERVAL_MS = 80;
54
55
  const STABLE_STAT_MAX_TRIES = 8;
55
56
 
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ function changedConfigKeys(
62
+ previous: Record<string, unknown>,
63
+ next: Record<string, unknown>,
64
+ prefix = "",
65
+ ): string[] {
66
+ const changed: string[] = [];
67
+ const keys = new Set([...Object.keys(previous), ...Object.keys(next)]);
68
+
69
+ for (const key of keys) {
70
+ const path = prefix ? `${prefix}.${key}` : key;
71
+ const previousValue = previous[key];
72
+ const nextValue = next[key];
73
+ if (!(key in previous) || !(key in next)) {
74
+ if (isRecord(previousValue) || isRecord(nextValue)) {
75
+ changed.push(
76
+ ...changedConfigKeys(
77
+ isRecord(previousValue) ? previousValue : {},
78
+ isRecord(nextValue) ? nextValue : {},
79
+ path,
80
+ ),
81
+ );
82
+ } else {
83
+ changed.push(path);
84
+ }
85
+ continue;
86
+ }
87
+
88
+ if (isRecord(previousValue) && isRecord(nextValue)) {
89
+ changed.push(...changedConfigKeys(previousValue, nextValue, path));
90
+ } else if (JSON.stringify(previousValue) !== JSON.stringify(nextValue)) {
91
+ changed.push(path);
92
+ }
93
+ }
94
+
95
+ return changed;
96
+ }
97
+
56
98
  /**
57
99
  * Watch the parent directory of a TOML config file for changes.
58
100
  * Parent-dir watching catches both direct writes (change events) and
@@ -79,6 +121,7 @@ export class ConfigWatcher {
79
121
  private constructor(
80
122
  private readonly tomlPath: string,
81
123
  private readonly opts: ConfigWatcherOptions,
124
+ private activeConfig: Config,
82
125
  ) {
83
126
  const dir = dirname(tomlPath);
84
127
  const filename = tomlPath.split(/[/\\]/).pop()!;
@@ -110,7 +153,8 @@ export class ConfigWatcher {
110
153
  tomlPath: string,
111
154
  opts: ConfigWatcherOptions,
112
155
  ): Promise<ConfigWatcher> {
113
- return new ConfigWatcher(tomlPath, opts);
156
+ const activeConfig = await loadConfig(tomlPath);
157
+ return new ConfigWatcher(tomlPath, opts, activeConfig);
114
158
  }
115
159
 
116
160
  private scheduleReload(): void {
@@ -130,10 +174,27 @@ export class ConfigWatcher {
130
174
 
131
175
  try {
132
176
  const config = await loadConfig(this.tomlPath);
177
+ const restartRequiredKeys = changedConfigKeys(
178
+ this.activeConfig as unknown as Record<string, unknown>,
179
+ config as unknown as Record<string, unknown>,
180
+ )
181
+ .filter((key) => !LIVE_RELOAD_KEYS.has(key))
182
+ .sort();
183
+
184
+ if (restartRequiredKeys.length > 0) {
185
+ this.status = {
186
+ ...this.status,
187
+ last_reload_error: null,
188
+ restart_required_keys: restartRequiredKeys,
189
+ };
190
+ return;
191
+ }
192
+
193
+ this.activeConfig = config;
133
194
  this.status = {
134
195
  last_successful_reload_at: new Date().toISOString(),
135
196
  last_reload_error: null,
136
- restart_required_keys: this.status.restart_required_keys,
197
+ restart_required_keys: [],
137
198
  };
138
199
  this.opts.onReload(config);
139
200
  } catch (err) {
package/src/doctor.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { existsSync, mkdirSync } from "node:fs";
2
2
  import { createClients } from "./clients";
3
3
  import { embeddingDimension, expandHome } from "./config";
4
- import { resolveManifestPath } from "./manifest";
4
+ import { parseManifest, resolveManifestPath, validateManifest } from "./manifest";
5
5
  import { readSkillmuxMarker } from "./sync";
6
6
  import type { Config } from "./types";
7
7
  import { findShadowedSkills } from "./vault";
@@ -62,6 +62,24 @@ export async function diagnose(config: Config): Promise<DoctorReport> {
62
62
  });
63
63
  }
64
64
 
65
+ const vaultPath = expandHome(config.vault_path);
66
+ const manifestPath = resolveManifestPath(vaultPath);
67
+ if (!manifestPath) {
68
+ checks.push({ name: "manifest", ok: true, detail: "not yet initialized" });
69
+ } else {
70
+ try {
71
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
72
+ validateManifest(manifest, vaultPath, config.local_vault_paths.map(expandHome));
73
+ checks.push({ name: "manifest", ok: true, detail: manifestPath });
74
+ } catch (error) {
75
+ checks.push({
76
+ name: `manifest:${manifestPath}`,
77
+ ok: false,
78
+ detail: error instanceof Error ? error.message : String(error),
79
+ });
80
+ }
81
+ }
82
+
65
83
  try {
66
84
  mkdirSync(expandHome(config.state_dir), { recursive: true });
67
85
  const probe = Bun.file(expandHome(`${config.state_dir}/.doctor`));
package/src/output.ts CHANGED
@@ -36,33 +36,31 @@ export function formatJsonEnvelope<T>(opts: {
36
36
  };
37
37
  }
38
38
 
39
- export function mapExitCode(err: unknown): number {
40
- if (!err) return 0;
41
- const msg = err instanceof Error ? err.message : String(err);
42
- const lower = msg.toLowerCase();
43
-
44
- if (
45
- lower.includes("conflict") ||
46
- lower.includes("revision") ||
47
- lower.includes("externally managed") ||
48
- lower.includes("config_revision_conflict") ||
49
- lower.includes("config_externally_managed")
50
- ) {
51
- return 4;
39
+ export class CliError extends Error {
40
+ exitCode: number;
41
+
42
+ constructor(message: string, exitCode: number) {
43
+ super(message);
44
+ this.name = "CliError";
45
+ this.exitCode = exitCode;
52
46
  }
47
+ }
53
48
 
54
- if (
55
- lower.includes("unreachable") ||
56
- lower.includes("failed to reach") ||
57
- lower.includes("unauthorized") ||
58
- lower.includes("unauthenticated") ||
59
- lower.includes("401") ||
60
- lower.includes("403") ||
61
- lower.includes("connection refused")
62
- ) {
63
- return 3;
49
+ export function emitSuccess<T>(
50
+ ctx: { isJson: boolean; target?: ResolvedTarget | string | { name: string; server: string } },
51
+ data: T,
52
+ renderText: () => void,
53
+ ): void {
54
+ if (ctx.isJson) {
55
+ console.log(JSON.stringify(formatJsonEnvelope({ ok: true, target: ctx.target ?? "local", data })));
56
+ } else {
57
+ renderText();
64
58
  }
59
+ }
65
60
 
61
+ export function mapExitCode(err: unknown): number {
62
+ if (!err) return 0;
63
+ if (err instanceof CliError) return err.exitCode;
66
64
  return 2;
67
65
  }
68
66