@klhapp/skillmux 0.4.5 → 0.5.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.
@@ -20,7 +20,7 @@ _skillmux_completions() {
20
20
  COMPREPLY=( $(compgen -W "add list current use remove" -- "$cur") )
21
21
  ;;
22
22
  config)
23
- COMPREPLY=( $(compgen -W "show get validate diff set status" -- "$cur") )
23
+ COMPREPLY=( $(compgen -W "init show get validate diff set status" -- "$cur") )
24
24
  ;;
25
25
  calibrate)
26
26
  COMPREPLY=( $(compgen -W "run list show apply generate-dataset" -- "$cur") )
@@ -34,7 +34,16 @@ _skillmux_completions() {
34
34
  local-vault)
35
35
  COMPREPLY=( $(compgen -W "init" -- "$cur") )
36
36
  ;;
37
+ --client)
38
+ COMPREPLY=( $(compgen -W "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -- "$cur") )
39
+ ;;
40
+ --target)
41
+ COMPREPLY=( $(compgen -W "agent-skills claude-code codex custom" -- "$cur") )
42
+ ;;
37
43
  esac
44
+ if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
45
+ COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --yes --dry-run --json" -- "$cur") )
46
+ fi
38
47
  }
39
48
  complete -F _skillmux_completions skillmux
40
49
  `;
@@ -63,7 +72,20 @@ _skillmux() {
63
72
  'models:Manage local models'
64
73
  'completions:Generate shell completions'
65
74
  )
66
- _describe -t commands 'skillmux command' commands
75
+ if (( CURRENT == 2 )); then
76
+ _describe -t commands 'skillmux command' commands
77
+ 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
+ '--path[custom target directory]:directory:_directories' \
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
+ '--yes[apply without prompts]' \
86
+ '--dry-run[print the plan without writing]' \
87
+ '--json[emit a JSON envelope]'
88
+ fi
67
89
  }
68
90
  _skillmux "$@"
69
91
  `;
@@ -77,6 +99,15 @@ complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configurati
77
99
  complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
78
100
  complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
79
101
  complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
102
+ 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 path -r -d "Custom target directory"
105
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
106
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
107
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
108
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
109
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
110
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
80
111
  `;
81
112
  }
82
113
 
package/src/config.ts CHANGED
@@ -169,8 +169,7 @@ export function migrateLegacyPaths(): void {
169
169
  migrateLegacyDir("~/.cache/skill-router", "~/.cache/skillmux");
170
170
  }
171
171
 
172
- export async function loadConfig(path?: string): Promise<Config> {
173
- migrateLegacyPaths();
172
+ export function resolveConfigPath(path?: string): string {
174
173
  let configEnv = process.env.SKILLMUX_CONFIG;
175
174
  if (configEnv === undefined && process.env.SKILL_ROUTER_CONFIG !== undefined) {
176
175
  if (!warnedEnv.has("SKILL_ROUTER_CONFIG")) {
@@ -179,7 +178,12 @@ export async function loadConfig(path?: string): Promise<Config> {
179
178
  }
180
179
  configEnv = process.env.SKILL_ROUTER_CONFIG;
181
180
  }
182
- const configPath = path ?? configEnv ?? DEFAULT_CONFIG_PATH;
181
+ return expandHome(path ?? configEnv ?? DEFAULT_CONFIG_PATH);
182
+ }
183
+
184
+ export async function loadConfig(path?: string): Promise<Config> {
185
+ migrateLegacyPaths();
186
+ const configPath = resolveConfigPath(path);
183
187
  const file = Bun.file(expandHome(configPath));
184
188
 
185
189
  const baseConfig = structuredClone(DEFAULTS);
@@ -0,0 +1,189 @@
1
+ import { homedir } from "node:os";
2
+ import { join } from "node:path";
3
+
4
+ export const SUPPORTED_CLIENT_IDS = [
5
+ "claude-code",
6
+ "codex",
7
+ "gemini-cli",
8
+ "opencode",
9
+ "github-copilot",
10
+ "windsurf",
11
+ "antigravity",
12
+ "goose",
13
+ "hermes",
14
+ "skillmux-mcp",
15
+ ] as const;
16
+
17
+ export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
18
+ export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
19
+
20
+ interface ClientDefinition {
21
+ id: ClientId;
22
+ surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
23
+ deliveryMode: DeliveryMode;
24
+ }
25
+
26
+ export interface PlannedClientSurface {
27
+ id: string;
28
+ targetName: string;
29
+ path: string;
30
+ deliveryMode: "managed-pins";
31
+ clients: ClientId[];
32
+ }
33
+
34
+ export interface ClientSurfacePlan {
35
+ clients: ClientDefinition[];
36
+ surfaces: PlannedClientSurface[];
37
+ }
38
+
39
+ type ReadinessStatus = "ready" | "planned" | "manual" | "not-applicable";
40
+
41
+ export interface ReadinessAxis {
42
+ status: ReadinessStatus;
43
+ detail: string;
44
+ }
45
+
46
+ export interface ClientReadiness {
47
+ client: ClientId;
48
+ skillSurface: ReadinessAxis;
49
+ mcpRegistration: ReadinessAxis;
50
+ instructionSetup: ReadinessAxis;
51
+ }
52
+
53
+ export interface ResolvedBuiltInTarget {
54
+ targetName: string;
55
+ path: string;
56
+ warning?: string;
57
+ }
58
+
59
+ const CLIENTS: Record<ClientId, ClientDefinition> = {
60
+ "claude-code": { id: "claude-code", surfaceId: "claude-code", deliveryMode: "managed-pins" },
61
+ codex: { id: "codex", surfaceId: "codex", deliveryMode: "managed-pins" },
62
+ "gemini-cli": { id: "gemini-cli", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
63
+ opencode: { id: "opencode", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
64
+ "github-copilot": { id: "github-copilot", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
65
+ windsurf: { id: "windsurf", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
66
+ antigravity: { id: "antigravity", surfaceId: "antigravity", deliveryMode: "managed-pins" },
67
+ goose: { id: "goose", deliveryMode: "full-vault" },
68
+ hermes: { id: "hermes", deliveryMode: "full-vault" },
69
+ "skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
70
+ };
71
+
72
+ function surfacePath(
73
+ surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
74
+ options: { home: string; codexHome?: string },
75
+ ): string {
76
+ if (surfaceId === "agent-skills") return join(options.home, ".agents", "skills");
77
+ if (surfaceId === "claude-code") return join(options.home, ".claude", "skills");
78
+ if (surfaceId === "codex") return join(options.codexHome ?? join(options.home, ".codex"), "skills");
79
+ return join(options.home, ".gemini", "config", "skills");
80
+ }
81
+
82
+ export function resolveBuiltInTarget(
83
+ name: string,
84
+ options: { home?: string; codexHome?: string; customPath?: string } = {},
85
+ ): ResolvedBuiltInTarget {
86
+ const home = options.home ?? homedir();
87
+ if (name === "custom") {
88
+ if (!options.customPath) throw new Error("--target custom requires --path <dir>");
89
+ return { targetName: name, path: options.customPath };
90
+ }
91
+ if (name === "agent-skills" || name === "agents") {
92
+ return {
93
+ targetName: name,
94
+ path: surfacePath("agent-skills", { home }),
95
+ ...(name === "agents"
96
+ ? { warning: "--target agents is deprecated; use --target agent-skills" }
97
+ : {}),
98
+ };
99
+ }
100
+ if (name === "claude-code" || name === "claude") {
101
+ return {
102
+ targetName: name,
103
+ path: surfacePath("claude-code", { home }),
104
+ ...(name === "claude"
105
+ ? { warning: "--target claude is deprecated; use --target claude-code" }
106
+ : {}),
107
+ };
108
+ }
109
+ if (name === "codex") {
110
+ return {
111
+ targetName: name,
112
+ path: surfacePath("codex", { home, codexHome: options.codexHome }),
113
+ };
114
+ }
115
+ throw new Error(
116
+ `unknown --target "${name}"; supported targets: agent-skills, claude-code, codex, custom`,
117
+ );
118
+ }
119
+
120
+ export function planClientSurfaces(
121
+ requestedClients: readonly string[],
122
+ options: { home?: string; codexHome?: string } = {},
123
+ ): ClientSurfacePlan {
124
+ const clients = [...new Set(requestedClients)].map((id) => {
125
+ if (!SUPPORTED_CLIENT_IDS.includes(id as ClientId)) {
126
+ throw new Error(
127
+ `unsupported client "${id}"; supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`,
128
+ );
129
+ }
130
+ return CLIENTS[id as ClientId];
131
+ });
132
+ const home = options.home ?? homedir();
133
+ const surfaces = new Map<string, PlannedClientSurface>();
134
+
135
+ for (const client of clients) {
136
+ if (!client.surfaceId) continue;
137
+ const path = surfacePath(client.surfaceId, { home, codexHome: options.codexHome });
138
+ const existing = surfaces.get(path);
139
+ if (existing) {
140
+ if (!existing.clients.includes(client.id)) existing.clients.push(client.id);
141
+ continue;
142
+ }
143
+ surfaces.set(path, {
144
+ id: client.surfaceId,
145
+ targetName: client.surfaceId,
146
+ path,
147
+ deliveryMode: "managed-pins",
148
+ clients: [client.id],
149
+ });
150
+ }
151
+
152
+ return { clients, surfaces: [...surfaces.values()] };
153
+ }
154
+
155
+ export function assessClientReadiness(
156
+ plan: ClientSurfacePlan,
157
+ instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {},
158
+ ): ClientReadiness[] {
159
+ return plan.clients.map((client) => {
160
+ const surface = plan.surfaces.find((candidate) => candidate.clients.includes(client.id));
161
+ let skillSurface: ReadinessAxis;
162
+ if (surface) {
163
+ skillSurface = { status: "planned", detail: surface.path };
164
+ } else if (client.id === "goose") {
165
+ skillSurface = { status: "manual", detail: "configure the full vault in Goose" };
166
+ } else if (client.id === "hermes") {
167
+ skillSurface = { status: "manual", detail: "configure the full vault in Hermes external_dirs" };
168
+ } else {
169
+ skillSurface = {
170
+ status: "not-applicable",
171
+ detail: "skills resolve through Skillmux MCP",
172
+ };
173
+ }
174
+
175
+ const mcpRegistration: ReadinessAxis = client.deliveryMode === "mcp"
176
+ ? { status: "manual", detail: "register the Skillmux MCP server" }
177
+ : { status: "not-applicable", detail: "native skill loading" };
178
+
179
+ return {
180
+ client: client.id,
181
+ skillSurface,
182
+ mcpRegistration,
183
+ instructionSetup: instructionReadiness[client.id] ?? {
184
+ status: "manual",
185
+ detail: "instruction adapter not applied",
186
+ },
187
+ };
188
+ });
189
+ }
@@ -0,0 +1,173 @@
1
+ import {
2
+ lstatSync,
3
+ mkdirSync,
4
+ readFileSync,
5
+ renameSync,
6
+ rmSync,
7
+ statSync,
8
+ writeFileSync,
9
+ } from "node:fs";
10
+ import { dirname, join } from "node:path";
11
+ import { DISCOVERY_PARAGRAPH } from "./init";
12
+ import type { ClientId } from "./init-clients";
13
+
14
+ export const INSTRUCTION_BLOCK_START = "<!-- skillmux:discovery:start -->";
15
+ export const INSTRUCTION_BLOCK_END = "<!-- skillmux:discovery:end -->";
16
+
17
+ const MANAGED_BLOCK = [
18
+ INSTRUCTION_BLOCK_START,
19
+ DISCOVERY_PARAGRAPH,
20
+ INSTRUCTION_BLOCK_END,
21
+ ].join("\n");
22
+
23
+ export interface InstructionChange {
24
+ path: string;
25
+ clients: ClientId[];
26
+ status: "create" | "update" | "unchanged";
27
+ before: string | null;
28
+ after: string;
29
+ }
30
+
31
+ export interface InstructionPlan {
32
+ changes: InstructionChange[];
33
+ manual: Array<{ client: ClientId; reason: string }>;
34
+ }
35
+
36
+ interface InstructionPlanOptions {
37
+ home?: string;
38
+ codexHome?: string;
39
+ claudeConfigDir?: string;
40
+ readFile?: (path: string) => string | null;
41
+ }
42
+
43
+ function instructionPath(
44
+ client: ClientId,
45
+ options: Required<Pick<InstructionPlanOptions, "home" | "codexHome" | "claudeConfigDir">>,
46
+ ): string | undefined {
47
+ if (client === "claude-code") return join(options.claudeConfigDir, "CLAUDE.md");
48
+ if (client === "codex") return join(options.codexHome, "AGENTS.md");
49
+ if (client === "gemini-cli" || client === "antigravity") {
50
+ return join(options.home, ".gemini", "GEMINI.md");
51
+ }
52
+ if (client === "opencode") return join(options.home, ".config", "opencode", "AGENTS.md");
53
+ if (client === "goose") return join(options.home, ".config", "goose", ".goosehints");
54
+ if (client === "hermes") return join(options.home, ".hermes.md");
55
+ return undefined;
56
+ }
57
+
58
+ function readInstructionFile(path: string): string | null {
59
+ try {
60
+ const stat = lstatSync(path);
61
+ if (stat.isSymbolicLink()) {
62
+ throw new Error(`instruction file is a symbolic link and will not be modified: ${path}`);
63
+ }
64
+ if (!stat.isFile()) throw new Error(`instruction path is not a file: ${path}`);
65
+ return readFileSync(path, "utf8");
66
+ } catch (error) {
67
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
68
+ throw error;
69
+ }
70
+ }
71
+
72
+ function withManagedBlock(existing: string | null, path: string): string {
73
+ if (existing === null || existing.length === 0) return `${MANAGED_BLOCK}\n`;
74
+
75
+ const starts = existing.split(INSTRUCTION_BLOCK_START).length - 1;
76
+ const ends = existing.split(INSTRUCTION_BLOCK_END).length - 1;
77
+ if (starts === 0 && ends === 0) return `${existing.trimEnd()}\n\n${MANAGED_BLOCK}\n`;
78
+ if (starts !== 1 || ends !== 1) {
79
+ throw new Error(`instruction file has a malformed skillmux managed block: ${path}`);
80
+ }
81
+
82
+ const start = existing.indexOf(INSTRUCTION_BLOCK_START);
83
+ const end = existing.indexOf(INSTRUCTION_BLOCK_END, start);
84
+ if (end < start) throw new Error(`instruction file has a malformed skillmux managed block: ${path}`);
85
+ return `${existing.slice(0, start)}${MANAGED_BLOCK}${existing.slice(end + INSTRUCTION_BLOCK_END.length)}`;
86
+ }
87
+
88
+ export function planInstructionSetup(
89
+ requestedClients: readonly ClientId[],
90
+ options: InstructionPlanOptions = {},
91
+ ): InstructionPlan {
92
+ const home = options.home ?? process.env.HOME ?? "";
93
+ const resolvedOptions = {
94
+ home,
95
+ codexHome: options.codexHome ?? process.env.CODEX_HOME ?? join(home, ".codex"),
96
+ claudeConfigDir: options.claudeConfigDir ?? process.env.CLAUDE_CONFIG_DIR ?? join(home, ".claude"),
97
+ };
98
+ const readFile = options.readFile ?? readInstructionFile;
99
+ const changesByPath = new Map<string, InstructionChange>();
100
+ const manual: InstructionPlan["manual"] = [];
101
+
102
+ for (const client of [...new Set(requestedClients)]) {
103
+ const path = instructionPath(client, resolvedOptions);
104
+ if (!path) {
105
+ manual.push({ client, reason: "no safe durable user instruction file is known" });
106
+ continue;
107
+ }
108
+ const existingChange = changesByPath.get(path);
109
+ if (existingChange) {
110
+ existingChange.clients.push(client);
111
+ continue;
112
+ }
113
+
114
+ const before = readFile(path);
115
+ const after = withManagedBlock(before, path);
116
+ changesByPath.set(path, {
117
+ path,
118
+ clients: [client],
119
+ status: before === null ? "create" : before === after ? "unchanged" : "update",
120
+ before,
121
+ after,
122
+ });
123
+ }
124
+
125
+ return { changes: [...changesByPath.values()], manual };
126
+ }
127
+
128
+ function atomicWrite(path: string, content: string, mode: number): void {
129
+ mkdirSync(dirname(path), { recursive: true });
130
+ const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
131
+ try {
132
+ writeFileSync(temporaryPath, content, { encoding: "utf8", mode });
133
+ renameSync(temporaryPath, path);
134
+ } finally {
135
+ rmSync(temporaryPath, { force: true });
136
+ }
137
+ }
138
+
139
+ export function applyInstructionPlan(plan: InstructionPlan): void {
140
+ for (const change of plan.changes) {
141
+ if (change.status === "unchanged") continue;
142
+ if (readInstructionFile(change.path) !== change.before) {
143
+ throw new Error(`instruction file changed after planning: ${change.path}`);
144
+ }
145
+ }
146
+
147
+ const applied: InstructionChange[] = [];
148
+ try {
149
+ for (const change of plan.changes) {
150
+ if (change.status === "unchanged") continue;
151
+ const mode = change.before === null ? 0o600 : statSync(change.path).mode;
152
+ atomicWrite(change.path, change.after, mode);
153
+ applied.push(change);
154
+ }
155
+ } catch (error) {
156
+ rollbackInstructionChanges(applied);
157
+ throw error;
158
+ }
159
+ }
160
+
161
+ function rollbackInstructionChanges(changes: InstructionChange[]): void {
162
+ for (const change of [...changes].reverse()) {
163
+ if (change.before === null) {
164
+ rmSync(change.path, { force: true });
165
+ } else {
166
+ atomicWrite(change.path, change.before, statSync(change.path).mode);
167
+ }
168
+ }
169
+ }
170
+
171
+ export function rollbackInstructionPlan(plan: InstructionPlan): void {
172
+ rollbackInstructionChanges(plan.changes.filter((change) => change.status !== "unchanged"));
173
+ }