@klhapp/skillmux 0.6.0 → 1.0.1
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.
- package/CHANGELOG.md +51 -0
- package/README.md +9 -3
- package/docs/configuration.md +13 -10
- package/package.json +1 -1
- package/src/adapters.ts +11 -5
- package/src/calibrate.ts +7 -59
- package/src/cli.ts +521 -869
- package/src/commands/config.ts +202 -0
- package/src/commands/core.ts +52 -0
- package/src/commands/project.ts +412 -0
- package/src/commands/shared.ts +45 -0
- package/src/commands/target.ts +110 -0
- package/src/completions.ts +69 -55
- package/src/config-mutation.ts +65 -0
- package/src/config-watcher.ts +63 -2
- package/src/doctor.ts +19 -1
- package/src/output.ts +21 -23
- package/src/server.ts +228 -63
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { expandHome } from "../config";
|
|
2
|
+
import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
|
|
3
|
+
import { planInitManifest, applyInit } from "../init";
|
|
4
|
+
import { writeManifestAtomic } from "../manifest";
|
|
5
|
+
import { emitSuccess } from "../output";
|
|
6
|
+
import { confirmIfNeeded, loadManifestContext } from "./shared";
|
|
7
|
+
export async function runTarget(
|
|
8
|
+
subCommand: string,
|
|
9
|
+
args: string[],
|
|
10
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
const { vaultPath, manifestPath, manifest } = await loadManifestContext();
|
|
13
|
+
|
|
14
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
15
|
+
const names =
|
|
16
|
+
subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
|
|
17
|
+
if (subCommand === "show" && !manifest.targets[names[0]!]) {
|
|
18
|
+
throw new Error(`target "${names[0]}" does not exist`);
|
|
19
|
+
}
|
|
20
|
+
const targets = names.map((name) => {
|
|
21
|
+
const target = manifest.targets[name]!;
|
|
22
|
+
const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
23
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
24
|
+
return surface !== undefined && surface.path === expandHome(target.dir);
|
|
25
|
+
});
|
|
26
|
+
return { name, ...target, clients };
|
|
27
|
+
});
|
|
28
|
+
emitSuccess({ isJson: options.isJson }, { targets }, () => {
|
|
29
|
+
if (targets.length === 0) {
|
|
30
|
+
console.log("no targets configured");
|
|
31
|
+
} else {
|
|
32
|
+
for (const target of targets) {
|
|
33
|
+
console.log(`${target.name}:`);
|
|
34
|
+
console.log(` dir: ${target.dir}`);
|
|
35
|
+
console.log(` host: ${target.host ?? "(global)"}`);
|
|
36
|
+
console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
|
|
37
|
+
console.log(
|
|
38
|
+
` projects: ${target.project_groups.join(", ") || "(none)"}`,
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
if (subCommand === "add") {
|
|
47
|
+
const name = args[0];
|
|
48
|
+
const dirIndex = args.indexOf("--dir");
|
|
49
|
+
const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
|
|
50
|
+
if (!name || !rawPath)
|
|
51
|
+
throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
|
|
52
|
+
const path = expandHome(rawPath);
|
|
53
|
+
if (options.dryRun) {
|
|
54
|
+
const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
|
|
55
|
+
emitSuccess(
|
|
56
|
+
{ isJson: options.isJson },
|
|
57
|
+
{ target: planned.targets[name] },
|
|
58
|
+
() => console.log(`target add: ${name} -> ${path} (dry-run)`),
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
if (
|
|
63
|
+
!(await confirmIfNeeded({
|
|
64
|
+
confirmed: args.includes("--yes"),
|
|
65
|
+
isJson: options.isJson,
|
|
66
|
+
prompt: `Adopt target ${name} at ${path}?`,
|
|
67
|
+
nonInteractiveError:
|
|
68
|
+
"skillmux target add requires --yes when run non-interactively",
|
|
69
|
+
}))
|
|
70
|
+
)
|
|
71
|
+
return;
|
|
72
|
+
applyInit(vaultPath, [{ name, dir: path }]);
|
|
73
|
+
console.log(`target "${name}" added at ${path}`);
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (subCommand === "remove") {
|
|
78
|
+
const name = args[0];
|
|
79
|
+
if (!name || !manifest.targets[name]) {
|
|
80
|
+
throw new Error(
|
|
81
|
+
name
|
|
82
|
+
? `target "${name}" does not exist`
|
|
83
|
+
: "usage: skillmux target remove <name> --yes",
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
if (options.dryRun) {
|
|
87
|
+
console.log(`target remove: ${name} (files preserved, dry-run)`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (
|
|
91
|
+
!(await confirmIfNeeded({
|
|
92
|
+
confirmed: args.includes("--yes"),
|
|
93
|
+
isJson: options.isJson,
|
|
94
|
+
prompt: `Remove target ${name} from the manifest and preserve its files?`,
|
|
95
|
+
nonInteractiveError:
|
|
96
|
+
"skillmux target remove requires --yes when run non-interactively",
|
|
97
|
+
}))
|
|
98
|
+
)
|
|
99
|
+
return;
|
|
100
|
+
const targets = { ...manifest.targets };
|
|
101
|
+
delete targets[name];
|
|
102
|
+
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
103
|
+
console.log(
|
|
104
|
+
`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`,
|
|
105
|
+
);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
throw new Error("usage: skillmux target <list|show|add|remove>");
|
|
110
|
+
}
|
package/src/completions.ts
CHANGED
|
@@ -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="
|
|
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
|
-
|
|
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 --
|
|
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
|
-
|
|
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
|
-
'--
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { writeFileSync, renameSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export interface ThresholdsPatchOptions {
|
|
4
|
+
matchScore: number;
|
|
5
|
+
matchMargin: number;
|
|
6
|
+
candidateFloor: number;
|
|
7
|
+
runId: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Remove a TOML section header and all lines until the next section header
|
|
12
|
+
* (or end of file). Matches exact header string at start of line.
|
|
13
|
+
*/
|
|
14
|
+
export function removeSectionBlock(source: string, header: string): string {
|
|
15
|
+
const lines = source.split("\n");
|
|
16
|
+
const out: string[] = [];
|
|
17
|
+
let skipping = false;
|
|
18
|
+
for (const line of lines) {
|
|
19
|
+
const trimmed = line.trimEnd();
|
|
20
|
+
if (trimmed === header || trimmed.startsWith(header + " ")) {
|
|
21
|
+
skipping = true;
|
|
22
|
+
continue;
|
|
23
|
+
}
|
|
24
|
+
if (skipping && line.trimStart().startsWith("[")) {
|
|
25
|
+
skipping = false;
|
|
26
|
+
}
|
|
27
|
+
if (!skipping) out.push(line);
|
|
28
|
+
}
|
|
29
|
+
return out.join("\n");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Surgically patch a TOML string to set [inference.thresholds] values and
|
|
34
|
+
* [inference.calibration] run_id while preserving unrelated sections and comments.
|
|
35
|
+
*/
|
|
36
|
+
export function patchToml(
|
|
37
|
+
source: string,
|
|
38
|
+
opts: ThresholdsPatchOptions,
|
|
39
|
+
): string {
|
|
40
|
+
const thresholdsBlock = `[inference.thresholds]\nmatch_score = ${opts.matchScore}\nmatch_margin = ${opts.matchMargin}\ncandidate_floor = ${opts.candidateFloor}\n`;
|
|
41
|
+
const calibrationBlock = `[inference.calibration]\nrun_id = "${opts.runId}"\n`;
|
|
42
|
+
|
|
43
|
+
// Remove any existing [inference.thresholds] and [inference.calibration] sections
|
|
44
|
+
let result = removeSectionBlock(source, "[inference.thresholds]");
|
|
45
|
+
result = removeSectionBlock(result, "[inference.calibration]");
|
|
46
|
+
|
|
47
|
+
// Append both sections cleanly
|
|
48
|
+
result = result.trimEnd() + "\n\n" + thresholdsBlock + "\n" + calibrationBlock;
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Atomically write patched TOML to disk via temp file write and renameSync.
|
|
54
|
+
*/
|
|
55
|
+
export async function patchTomlFile(
|
|
56
|
+
tomlPath: string,
|
|
57
|
+
opts: ThresholdsPatchOptions,
|
|
58
|
+
): Promise<void> {
|
|
59
|
+
const existing = await Bun.file(tomlPath).text();
|
|
60
|
+
const patched = patchToml(existing, opts);
|
|
61
|
+
|
|
62
|
+
const tmpPath = `${tomlPath}.${process.pid}.tmp`;
|
|
63
|
+
writeFileSync(tmpPath, patched);
|
|
64
|
+
renameSync(tmpPath, tomlPath);
|
|
65
|
+
}
|
package/src/config-watcher.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|