@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.
@@ -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
- _describe -t commands 'skillmux command' commands
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 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,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
+ }