@klhapp/skillmux 0.4.5 → 0.6.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 +47 -0
- package/README.md +85 -9
- package/docs/configuration.md +45 -7
- package/package.json +1 -1
- package/src/cli.ts +1042 -38
- package/src/completions.ts +79 -3
- package/src/config.ts +7 -3
- package/src/init-clients.ts +220 -0
- package/src/init-instructions.ts +173 -0
- package/src/init.ts +280 -17
- package/src/manifest.ts +105 -4
- package/src/output.ts +5 -6
- package/src/project-setup.ts +36 -0
- package/src/prompts.ts +69 -0
- package/src/setup.ts +145 -0
- package/src/sync.ts +128 -11
package/src/completions.ts
CHANGED
|
@@ -8,7 +8,7 @@ _skillmux_completions() {
|
|
|
8
8
|
COMPREPLY=()
|
|
9
9
|
cur="\${COMP_WORDS[COMP_CWORD]}"
|
|
10
10
|
prev="\${COMP_WORDS[COMP_CWORD-1]}"
|
|
11
|
-
opts="context config calibrate serve index sync init report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
|
|
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"
|
|
12
12
|
|
|
13
13
|
if [ "$COMP_CWORD" -eq 1 ]; then
|
|
14
14
|
COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
|
|
@@ -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") )
|
|
@@ -31,10 +31,28 @@ _skillmux_completions() {
|
|
|
31
31
|
manifest)
|
|
32
32
|
COMPREPLY=( $(compgen -W "pin unpin" -- "$cur") )
|
|
33
33
|
;;
|
|
34
|
+
project)
|
|
35
|
+
COMPREPLY=( $(compgen -W "init list show add-path remove-path pin unpin attach detach" -- "$cur") )
|
|
36
|
+
;;
|
|
37
|
+
target)
|
|
38
|
+
COMPREPLY=( $(compgen -W "list show add remove" -- "$cur") )
|
|
39
|
+
;;
|
|
34
40
|
local-vault)
|
|
35
41
|
COMPREPLY=( $(compgen -W "init" -- "$cur") )
|
|
36
42
|
;;
|
|
43
|
+
--client)
|
|
44
|
+
COMPREPLY=( $(compgen -W "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -- "$cur") )
|
|
45
|
+
;;
|
|
46
|
+
--target)
|
|
47
|
+
COMPREPLY=( $(compgen -W "agent-skills claude-code codex custom" -- "$cur") )
|
|
48
|
+
;;
|
|
37
49
|
esac
|
|
50
|
+
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") )
|
|
52
|
+
fi
|
|
53
|
+
if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
|
|
54
|
+
COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
|
|
55
|
+
fi
|
|
38
56
|
}
|
|
39
57
|
complete -F _skillmux_completions skillmux
|
|
40
58
|
`;
|
|
@@ -52,6 +70,8 @@ _skillmux() {
|
|
|
52
70
|
'index:Rebuild local search index'
|
|
53
71
|
'sync:Synchronize vault skills'
|
|
54
72
|
'init:Initialize project targets'
|
|
73
|
+
'project:Configure project-scoped skills'
|
|
74
|
+
'target:Manage advanced skill-delivery targets'
|
|
55
75
|
'report:Generate usage stats'
|
|
56
76
|
'scan:Audit skills for issues'
|
|
57
77
|
'install:Install skills into vault'
|
|
@@ -63,7 +83,39 @@ _skillmux() {
|
|
|
63
83
|
'models:Manage local models'
|
|
64
84
|
'completions:Generate shell completions'
|
|
65
85
|
)
|
|
66
|
-
|
|
86
|
+
if (( CURRENT == 2 )); then
|
|
87
|
+
_describe -t commands 'skillmux command' commands
|
|
88
|
+
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]' \
|
|
101
|
+
'--json[emit a JSON envelope]'
|
|
102
|
+
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]' \
|
|
113
|
+
'--json[emit a JSON envelope]'
|
|
114
|
+
elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
|
|
115
|
+
_values 'project command' init list show add-path remove-path pin unpin attach detach
|
|
116
|
+
elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
|
|
117
|
+
_values 'target command' list show add remove
|
|
118
|
+
fi
|
|
67
119
|
}
|
|
68
120
|
_skillmux "$@"
|
|
69
121
|
`;
|
|
@@ -76,7 +128,31 @@ complete -c skillmux -n "__fish_use_subcommand" -a context -d "Manage connection
|
|
|
76
128
|
complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
|
|
77
129
|
complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
|
|
78
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"
|
|
79
134
|
complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
|
|
135
|
+
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"
|
|
138
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
|
|
139
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
|
|
140
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
|
|
141
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-instructions -d "Skip managed instruction files"
|
|
142
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-sync -d "Save without synchronizing"
|
|
143
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l interactive -d "Force guided setup"
|
|
144
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
|
|
145
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
|
|
146
|
+
complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
|
|
147
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show add-path remove-path pin unpin attach detach" -d "Manage projects"
|
|
148
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
|
|
149
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
|
|
150
|
+
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"
|
|
152
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
|
|
153
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
|
|
154
|
+
complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
|
|
155
|
+
complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
|
|
80
156
|
`;
|
|
81
157
|
}
|
|
82
158
|
|
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
|
|
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
|
-
|
|
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,220 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export const SUPPORTED_CLIENT_IDS = [
|
|
6
|
+
"claude-code",
|
|
7
|
+
"codex",
|
|
8
|
+
"gemini-cli",
|
|
9
|
+
"opencode",
|
|
10
|
+
"github-copilot",
|
|
11
|
+
"windsurf",
|
|
12
|
+
"antigravity",
|
|
13
|
+
"goose",
|
|
14
|
+
"hermes",
|
|
15
|
+
"skillmux-mcp",
|
|
16
|
+
] as const;
|
|
17
|
+
|
|
18
|
+
export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
|
|
19
|
+
export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
|
|
20
|
+
|
|
21
|
+
export interface DetectedClient {
|
|
22
|
+
client: ClientId;
|
|
23
|
+
evidence: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
interface ClientDefinition {
|
|
27
|
+
id: ClientId;
|
|
28
|
+
surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
|
|
29
|
+
deliveryMode: DeliveryMode;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface PlannedClientSurface {
|
|
33
|
+
id: string;
|
|
34
|
+
targetName: string;
|
|
35
|
+
path: string;
|
|
36
|
+
deliveryMode: "managed-pins";
|
|
37
|
+
clients: ClientId[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface ClientSurfacePlan {
|
|
41
|
+
clients: ClientDefinition[];
|
|
42
|
+
surfaces: PlannedClientSurface[];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
type ReadinessStatus = "ready" | "planned" | "manual" | "not-applicable";
|
|
46
|
+
|
|
47
|
+
export interface ReadinessAxis {
|
|
48
|
+
status: ReadinessStatus;
|
|
49
|
+
detail: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface ClientReadiness {
|
|
53
|
+
client: ClientId;
|
|
54
|
+
skillSurface: ReadinessAxis;
|
|
55
|
+
mcpRegistration: ReadinessAxis;
|
|
56
|
+
instructionSetup: ReadinessAxis;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export interface ResolvedBuiltInTarget {
|
|
60
|
+
targetName: string;
|
|
61
|
+
path: string;
|
|
62
|
+
warning?: string;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const CLIENTS: Record<ClientId, ClientDefinition> = {
|
|
66
|
+
"claude-code": { id: "claude-code", surfaceId: "claude-code", deliveryMode: "managed-pins" },
|
|
67
|
+
codex: { id: "codex", surfaceId: "codex", deliveryMode: "managed-pins" },
|
|
68
|
+
"gemini-cli": { id: "gemini-cli", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
69
|
+
opencode: { id: "opencode", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
70
|
+
"github-copilot": { id: "github-copilot", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
71
|
+
windsurf: { id: "windsurf", surfaceId: "agent-skills", deliveryMode: "managed-pins" },
|
|
72
|
+
antigravity: { id: "antigravity", surfaceId: "antigravity", deliveryMode: "managed-pins" },
|
|
73
|
+
goose: { id: "goose", deliveryMode: "full-vault" },
|
|
74
|
+
hermes: { id: "hermes", deliveryMode: "full-vault" },
|
|
75
|
+
"skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
|
|
76
|
+
};
|
|
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
|
+
|
|
103
|
+
function surfacePath(
|
|
104
|
+
surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
|
|
105
|
+
options: { home: string; codexHome?: string },
|
|
106
|
+
): string {
|
|
107
|
+
if (surfaceId === "agent-skills") return join(options.home, ".agents", "skills");
|
|
108
|
+
if (surfaceId === "claude-code") return join(options.home, ".claude", "skills");
|
|
109
|
+
if (surfaceId === "codex") return join(options.codexHome ?? join(options.home, ".codex"), "skills");
|
|
110
|
+
return join(options.home, ".gemini", "config", "skills");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function resolveBuiltInTarget(
|
|
114
|
+
name: string,
|
|
115
|
+
options: { home?: string; codexHome?: string; customPath?: string } = {},
|
|
116
|
+
): ResolvedBuiltInTarget {
|
|
117
|
+
const home = options.home ?? homedir();
|
|
118
|
+
if (name === "custom") {
|
|
119
|
+
if (!options.customPath) throw new Error("--target custom requires --path <dir>");
|
|
120
|
+
return { targetName: name, path: options.customPath };
|
|
121
|
+
}
|
|
122
|
+
if (name === "agent-skills" || name === "agents") {
|
|
123
|
+
return {
|
|
124
|
+
targetName: name,
|
|
125
|
+
path: surfacePath("agent-skills", { home }),
|
|
126
|
+
...(name === "agents"
|
|
127
|
+
? { warning: "--target agents is deprecated; use --target agent-skills" }
|
|
128
|
+
: {}),
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
if (name === "claude-code" || name === "claude") {
|
|
132
|
+
return {
|
|
133
|
+
targetName: name,
|
|
134
|
+
path: surfacePath("claude-code", { home }),
|
|
135
|
+
...(name === "claude"
|
|
136
|
+
? { warning: "--target claude is deprecated; use --target claude-code" }
|
|
137
|
+
: {}),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
if (name === "codex") {
|
|
141
|
+
return {
|
|
142
|
+
targetName: name,
|
|
143
|
+
path: surfacePath("codex", { home, codexHome: options.codexHome }),
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
throw new Error(
|
|
147
|
+
`unknown --target "${name}"; supported targets: agent-skills, claude-code, codex, custom`,
|
|
148
|
+
);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export function planClientSurfaces(
|
|
152
|
+
requestedClients: readonly string[],
|
|
153
|
+
options: { home?: string; codexHome?: string } = {},
|
|
154
|
+
): ClientSurfacePlan {
|
|
155
|
+
const clients = [...new Set(requestedClients)].map((id) => {
|
|
156
|
+
if (!SUPPORTED_CLIENT_IDS.includes(id as ClientId)) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`unsupported client "${id}"; supported clients: ${SUPPORTED_CLIENT_IDS.join(", ")}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return CLIENTS[id as ClientId];
|
|
162
|
+
});
|
|
163
|
+
const home = options.home ?? homedir();
|
|
164
|
+
const surfaces = new Map<string, PlannedClientSurface>();
|
|
165
|
+
|
|
166
|
+
for (const client of clients) {
|
|
167
|
+
if (!client.surfaceId) continue;
|
|
168
|
+
const path = surfacePath(client.surfaceId, { home, codexHome: options.codexHome });
|
|
169
|
+
const existing = surfaces.get(path);
|
|
170
|
+
if (existing) {
|
|
171
|
+
if (!existing.clients.includes(client.id)) existing.clients.push(client.id);
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
surfaces.set(path, {
|
|
175
|
+
id: client.surfaceId,
|
|
176
|
+
targetName: client.surfaceId,
|
|
177
|
+
path,
|
|
178
|
+
deliveryMode: "managed-pins",
|
|
179
|
+
clients: [client.id],
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { clients, surfaces: [...surfaces.values()] };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function assessClientReadiness(
|
|
187
|
+
plan: ClientSurfacePlan,
|
|
188
|
+
instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {},
|
|
189
|
+
): ClientReadiness[] {
|
|
190
|
+
return plan.clients.map((client) => {
|
|
191
|
+
const surface = plan.surfaces.find((candidate) => candidate.clients.includes(client.id));
|
|
192
|
+
let skillSurface: ReadinessAxis;
|
|
193
|
+
if (surface) {
|
|
194
|
+
skillSurface = { status: "planned", detail: surface.path };
|
|
195
|
+
} else if (client.id === "goose") {
|
|
196
|
+
skillSurface = { status: "manual", detail: "configure the full vault in Goose" };
|
|
197
|
+
} else if (client.id === "hermes") {
|
|
198
|
+
skillSurface = { status: "manual", detail: "configure the full vault in Hermes external_dirs" };
|
|
199
|
+
} else {
|
|
200
|
+
skillSurface = {
|
|
201
|
+
status: "not-applicable",
|
|
202
|
+
detail: "skills resolve through Skillmux MCP",
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const mcpRegistration: ReadinessAxis = client.deliveryMode === "mcp"
|
|
207
|
+
? { status: "manual", detail: "register the Skillmux MCP server" }
|
|
208
|
+
: { status: "not-applicable", detail: "native skill loading" };
|
|
209
|
+
|
|
210
|
+
return {
|
|
211
|
+
client: client.id,
|
|
212
|
+
skillSurface,
|
|
213
|
+
mcpRegistration,
|
|
214
|
+
instructionSetup: instructionReadiness[client.id] ?? {
|
|
215
|
+
status: "manual",
|
|
216
|
+
detail: "instruction adapter not applied",
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
});
|
|
220
|
+
}
|
|
@@ -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
|
+
}
|