@klhapp/skillmux 0.5.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.
- package/CHANGELOG.md +67 -0
- package/README.md +44 -8
- package/docs/configuration.md +13 -10
- package/package.json +1 -1
- package/src/adapters.ts +11 -5
- package/src/cli.ts +1286 -328
- package/src/completions.ts +95 -36
- package/src/config-watcher.ts +63 -2
- package/src/doctor.ts +19 -1
- package/src/init-clients.ts +31 -0
- package/src/manifest.ts +101 -2
- package/src/output.ts +26 -29
- package/src/project-setup.ts +36 -0
- package/src/prompts.ts +69 -0
- package/src/server.ts +228 -63
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,18 @@ _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
|
+
;;
|
|
60
|
+
project)
|
|
61
|
+
COMPREPLY=( $(compgen -W "init list show add-path remove-path pin unpin attach detach" -- "$cur") )
|
|
62
|
+
;;
|
|
63
|
+
target)
|
|
64
|
+
COMPREPLY=( $(compgen -W "list show add remove" -- "$cur") )
|
|
65
|
+
;;
|
|
34
66
|
local-vault)
|
|
35
67
|
COMPREPLY=( $(compgen -W "init" -- "$cur") )
|
|
36
68
|
;;
|
|
@@ -42,7 +74,10 @@ _skillmux_completions() {
|
|
|
42
74
|
;;
|
|
43
75
|
esac
|
|
44
76
|
if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
|
|
45
|
-
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") )
|
|
78
|
+
fi
|
|
79
|
+
if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
|
|
80
|
+
COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
|
|
46
81
|
fi
|
|
47
82
|
}
|
|
48
83
|
complete -F _skillmux_completions skillmux
|
|
@@ -50,41 +85,51 @@ complete -F _skillmux_completions skillmux
|
|
|
50
85
|
}
|
|
51
86
|
|
|
52
87
|
if (shell === "zsh") {
|
|
88
|
+
const commands = TOP_LEVEL_COMMANDS.map((c) => ` '${c.name}:${c.description}'`).join("\n");
|
|
53
89
|
return `#compdef skillmux
|
|
54
90
|
_skillmux() {
|
|
55
91
|
local -a commands
|
|
56
92
|
commands=(
|
|
57
|
-
|
|
58
|
-
'config:Manage configuration'
|
|
59
|
-
'calibrate:Manage policy calibration'
|
|
60
|
-
'serve:Start MCP server'
|
|
61
|
-
'index:Rebuild local search index'
|
|
62
|
-
'sync:Synchronize vault skills'
|
|
63
|
-
'init:Initialize project targets'
|
|
64
|
-
'report:Generate usage stats'
|
|
65
|
-
'scan:Audit skills for issues'
|
|
66
|
-
'install:Install skills into vault'
|
|
67
|
-
'eval:Evaluate search accuracy'
|
|
68
|
-
'doctor:Check runtime health'
|
|
69
|
-
'which:Show which root resolves a skill_id'
|
|
70
|
-
'manifest:Pin/unpin skills into [core] or [project.*]'
|
|
71
|
-
'local-vault:Manage local_vault_paths discoverability markers'
|
|
72
|
-
'models:Manage local models'
|
|
73
|
-
'completions:Generate shell completions'
|
|
93
|
+
${commands}
|
|
74
94
|
)
|
|
75
95
|
if (( CURRENT == 2 )); then
|
|
76
96
|
_describe -t commands 'skillmux command' commands
|
|
77
97
|
elif [[ "$words[2]" == "init" ]]; then
|
|
78
|
-
_arguments
|
|
79
|
-
'*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp)'
|
|
80
|
-
'*--target[select a target]:target:(agent-skills claude-code codex custom)'
|
|
81
|
-
'--
|
|
82
|
-
'--vault[vault directory]:directory:_directories'
|
|
83
|
-
'*--core[seed a core skill]:skill id:'
|
|
84
|
-
'--migrate-full-vault[convert a full-vault symlink to managed pins]'
|
|
85
|
-
'--
|
|
86
|
-
'--
|
|
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]' \\
|
|
110
|
+
'--json[emit a JSON envelope]'
|
|
111
|
+
elif [[ "$words[2]" == "project" && "$words[3]" == "init" ]]; then
|
|
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]' \\
|
|
87
122
|
'--json[emit a JSON envelope]'
|
|
123
|
+
elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
|
|
124
|
+
_values 'project command' init list show add-path remove-path pin unpin attach detach
|
|
125
|
+
elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
|
|
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
|
|
88
133
|
fi
|
|
89
134
|
}
|
|
90
135
|
_skillmux "$@"
|
|
@@ -92,22 +137,36 @@ _skillmux "$@"
|
|
|
92
137
|
}
|
|
93
138
|
|
|
94
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");
|
|
95
143
|
return `# fish completion for skillmux
|
|
96
144
|
complete -c skillmux -f
|
|
97
|
-
|
|
98
|
-
complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
|
|
99
|
-
complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
|
|
100
|
-
complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
|
|
101
|
-
complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
|
|
145
|
+
${topLevel}
|
|
102
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"
|
|
103
|
-
complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a target"
|
|
104
|
-
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"
|
|
105
149
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
|
|
106
150
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
|
|
107
151
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
|
|
152
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-instructions -d "Skip managed instruction files"
|
|
153
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-sync -d "Save without synchronizing"
|
|
154
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l interactive -d "Force guided setup"
|
|
108
155
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
|
|
109
156
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
|
|
110
157
|
complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
|
|
158
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show add-path remove-path pin unpin attach detach" -d "Manage projects"
|
|
159
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
|
|
160
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
|
|
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"
|
|
162
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced delivery target"
|
|
163
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
|
|
164
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
|
|
165
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
|
|
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"
|
|
111
170
|
`;
|
|
112
171
|
}
|
|
113
172
|
|
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/init-clients.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
1
2
|
import { homedir } from "node:os";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
|
|
@@ -17,6 +18,11 @@ export const SUPPORTED_CLIENT_IDS = [
|
|
|
17
18
|
export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
|
|
18
19
|
export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
|
|
19
20
|
|
|
21
|
+
export interface DetectedClient {
|
|
22
|
+
client: ClientId;
|
|
23
|
+
evidence: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
20
26
|
interface ClientDefinition {
|
|
21
27
|
id: ClientId;
|
|
22
28
|
surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
|
|
@@ -69,6 +75,31 @@ const CLIENTS: Record<ClientId, ClientDefinition> = {
|
|
|
69
75
|
"skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
|
|
70
76
|
};
|
|
71
77
|
|
|
78
|
+
export function detectInstalledClients(
|
|
79
|
+
options: {
|
|
80
|
+
home?: string;
|
|
81
|
+
codexHome?: string;
|
|
82
|
+
exists?: (path: string) => boolean;
|
|
83
|
+
} = {},
|
|
84
|
+
): DetectedClient[] {
|
|
85
|
+
const home = options.home ?? homedir();
|
|
86
|
+
const codexHome = options.codexHome ?? join(home, ".codex");
|
|
87
|
+
const exists = options.exists ?? existsSync;
|
|
88
|
+
const candidates: Array<[ClientId, string]> = [
|
|
89
|
+
["claude-code", join(home, ".claude")],
|
|
90
|
+
["codex", codexHome],
|
|
91
|
+
["gemini-cli", join(home, ".gemini")],
|
|
92
|
+
["opencode", join(home, ".config", "opencode")],
|
|
93
|
+
["github-copilot", join(home, ".config", "github-copilot")],
|
|
94
|
+
["windsurf", join(home, ".codeium", "windsurf")],
|
|
95
|
+
["goose", join(home, ".config", "goose")],
|
|
96
|
+
["hermes", join(home, ".hermes")],
|
|
97
|
+
];
|
|
98
|
+
return candidates
|
|
99
|
+
.filter(([, evidence]) => exists(evidence))
|
|
100
|
+
.map(([client, evidence]) => ({ client, evidence }));
|
|
101
|
+
}
|
|
102
|
+
|
|
72
103
|
function surfacePath(
|
|
73
104
|
surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
|
|
74
105
|
options: { home: string; codexHome?: string },
|
package/src/manifest.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync } from "node:fs";
|
|
1
|
+
import { existsSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { expandHome } from "./config";
|
|
@@ -32,7 +32,7 @@ const targetSchema = z.object({
|
|
|
32
32
|
const manifestSchema = z.object({
|
|
33
33
|
core: z.object({ skills: z.array(skillIdSchema) }).strict(),
|
|
34
34
|
project: z.record(groupNameSchema, projectGroupSchema).optional(),
|
|
35
|
-
targets: z.record(groupNameSchema, targetSchema),
|
|
35
|
+
targets: z.record(groupNameSchema, targetSchema).default({}),
|
|
36
36
|
}).strict();
|
|
37
37
|
|
|
38
38
|
export type ProjectGroup = z.infer<typeof projectGroupSchema>;
|
|
@@ -94,6 +94,16 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
94
94
|
return `${sections.join("\n\n")}\n`;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
export function writeManifestAtomic(path: string, manifest: Manifest): void {
|
|
98
|
+
const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
|
|
99
|
+
try {
|
|
100
|
+
writeFileSync(temporaryPath, serializeManifest(manifest), "utf8");
|
|
101
|
+
renameSync(temporaryPath, path);
|
|
102
|
+
} finally {
|
|
103
|
+
rmSync(temporaryPath, { force: true });
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
97
107
|
function findExistingPin(manifest: Manifest, skillId: string): string | null {
|
|
98
108
|
if (manifest.core.skills.includes(skillId)) return "[core]";
|
|
99
109
|
for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
|
|
@@ -167,6 +177,95 @@ export function unpinProject(manifest: Manifest, skillId: string, group: string)
|
|
|
167
177
|
};
|
|
168
178
|
}
|
|
169
179
|
|
|
180
|
+
export interface UpsertProjectOptions {
|
|
181
|
+
name: string;
|
|
182
|
+
paths: string[];
|
|
183
|
+
skills: string[];
|
|
184
|
+
targets: string[];
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function upsertProject(manifest: Manifest, options: UpsertProjectOptions): Manifest {
|
|
188
|
+
if (!groupNameSchema.safeParse(options.name).success) {
|
|
189
|
+
throw new Error(
|
|
190
|
+
`invalid group name "${options.name}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`,
|
|
191
|
+
);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
const existingGroup = manifest.project?.[options.name] ?? { paths: [], skills: [] };
|
|
195
|
+
for (const skillId of options.skills) {
|
|
196
|
+
if (!skillIdSchema.safeParse(skillId).success) {
|
|
197
|
+
throw new Error(`invalid skill ID "${skillId}"`);
|
|
198
|
+
}
|
|
199
|
+
if (existingGroup.skills.includes(skillId)) continue;
|
|
200
|
+
const existing = findExistingPin(manifest, skillId);
|
|
201
|
+
if (existing) throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const targets = { ...manifest.targets };
|
|
205
|
+
for (const targetName of options.targets) {
|
|
206
|
+
const target = targets[targetName];
|
|
207
|
+
if (!target) throw new Error(`target "${targetName}" does not exist`);
|
|
208
|
+
targets[targetName] = {
|
|
209
|
+
...target,
|
|
210
|
+
project_groups: [...new Set([...target.project_groups, options.name])],
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return {
|
|
215
|
+
...manifest,
|
|
216
|
+
project: {
|
|
217
|
+
...manifest.project,
|
|
218
|
+
[options.name]: {
|
|
219
|
+
paths: [...new Set([...existingGroup.paths, ...options.paths])],
|
|
220
|
+
skills: [...new Set([...existingGroup.skills, ...options.skills])],
|
|
221
|
+
},
|
|
222
|
+
},
|
|
223
|
+
targets,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function updateProjectPaths(
|
|
228
|
+
manifest: Manifest,
|
|
229
|
+
group: string,
|
|
230
|
+
changes: { add?: string[]; remove?: string[] },
|
|
231
|
+
): Manifest {
|
|
232
|
+
const existingGroup = manifest.project?.[group];
|
|
233
|
+
if (!existingGroup) throw new Error(`[project.${group}] does not exist`);
|
|
234
|
+
const removed = new Set(changes.remove ?? []);
|
|
235
|
+
const paths = [...new Set([...existingGroup.paths, ...(changes.add ?? [])])]
|
|
236
|
+
.filter((path) => !removed.has(path));
|
|
237
|
+
return {
|
|
238
|
+
...manifest,
|
|
239
|
+
project: {
|
|
240
|
+
...manifest.project,
|
|
241
|
+
[group]: { ...existingGroup, paths },
|
|
242
|
+
},
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
export function updateProjectTargets(
|
|
247
|
+
manifest: Manifest,
|
|
248
|
+
group: string,
|
|
249
|
+
changes: { attach?: string[]; detach?: string[] },
|
|
250
|
+
): Manifest {
|
|
251
|
+
if (!manifest.project?.[group]) throw new Error(`[project.${group}] does not exist`);
|
|
252
|
+
const attach = new Set(changes.attach ?? []);
|
|
253
|
+
const detach = new Set(changes.detach ?? []);
|
|
254
|
+
const requested = new Set([...attach, ...detach]);
|
|
255
|
+
for (const target of requested) {
|
|
256
|
+
if (!manifest.targets[target]) throw new Error(`target "${target}" does not exist`);
|
|
257
|
+
}
|
|
258
|
+
return {
|
|
259
|
+
...manifest,
|
|
260
|
+
targets: Object.fromEntries(Object.entries(manifest.targets).map(([name, target]) => {
|
|
261
|
+
let groups = target.project_groups;
|
|
262
|
+
if (attach.has(name)) groups = [...new Set([...groups, group])];
|
|
263
|
+
if (detach.has(name)) groups = groups.filter((item) => item !== group);
|
|
264
|
+
return [name, { ...target, project_groups: groups }];
|
|
265
|
+
})),
|
|
266
|
+
};
|
|
267
|
+
}
|
|
268
|
+
|
|
170
269
|
export interface ManifestValidationResult {
|
|
171
270
|
notes: string[];
|
|
172
271
|
}
|
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
|
|
|
@@ -110,12 +108,11 @@ export function suggestCorrection(input: string, candidates: string[]): string |
|
|
|
110
108
|
return bestMatch;
|
|
111
109
|
}
|
|
112
110
|
|
|
113
|
-
export function isInteractive(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
);
|
|
111
|
+
export function isInteractive(
|
|
112
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
113
|
+
stdoutIsTTY = process.stdout.isTTY,
|
|
114
|
+
): boolean {
|
|
115
|
+
return stdoutIsTTY === true && env.TERM !== "dumb";
|
|
119
116
|
}
|
|
120
117
|
|
|
121
118
|
export function renderTargetBanner(target: ResolvedTarget): void {
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { resolve } from "node:path";
|
|
2
|
+
|
|
3
|
+
interface ResolveProjectDirectoryOptions {
|
|
4
|
+
cwd?: string;
|
|
5
|
+
findGitRoot?: (cwd: string) => string | null;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
function findGitRoot(cwd: string): string | null {
|
|
9
|
+
const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
|
|
10
|
+
cwd,
|
|
11
|
+
stdout: "pipe",
|
|
12
|
+
stderr: "ignore",
|
|
13
|
+
});
|
|
14
|
+
if (result.exitCode !== 0) return null;
|
|
15
|
+
const root = result.stdout.toString().trim();
|
|
16
|
+
return root || null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function resolveProjectDirectory(
|
|
20
|
+
explicitPath?: string,
|
|
21
|
+
options: ResolveProjectDirectoryOptions = {},
|
|
22
|
+
): string {
|
|
23
|
+
const cwd = options.cwd ?? process.cwd();
|
|
24
|
+
if (explicitPath) return resolve(explicitPath);
|
|
25
|
+
const gitRoot = (options.findGitRoot ?? findGitRoot)(cwd);
|
|
26
|
+
return resolve(gitRoot ?? cwd);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function suggestProjectName(directoryName: string): string {
|
|
30
|
+
const slug = directoryName
|
|
31
|
+
.toLowerCase()
|
|
32
|
+
.replace(/[^a-z0-9_-]+/g, "-")
|
|
33
|
+
.replace(/^-+|-+$/g, "");
|
|
34
|
+
const prefixed = /^[a-z]/.test(slug) ? slug : `project-${slug || "workspace"}`;
|
|
35
|
+
return prefixed.slice(0, 64).replace(/-+$/g, "");
|
|
36
|
+
}
|