@klhapp/skillmux 1.11.1 → 1.11.2

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 CHANGED
@@ -5,6 +5,19 @@ All notable changes to this project are documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.11.2](https://github.com/klhq/skillmux/compare/v1.11.1...v1.11.2) (2026-09-05)
9
+
10
+
11
+ ### Added
12
+
13
+ * **target:** add built-in target migration ([#189](https://github.com/klhq/skillmux/issues/189)) ([fe85951](https://github.com/klhq/skillmux/commit/fe85951f1728907ce0faa9fe5df79677ea278973))
14
+ * **target:** add safe marker rehome workflow ([#187](https://github.com/klhq/skillmux/issues/187)) ([b8601aa](https://github.com/klhq/skillmux/commit/b8601aa9dc81d2c4a882c6d810d9922ba596e701))
15
+
16
+
17
+ ### Chores
18
+
19
+ * **release:** force version 1.11.2 ([1ee9cbc](https://github.com/klhq/skillmux/commit/1ee9cbcfbfcbd9650f8e7f2d2dbbd54cdb0777ac))
20
+
8
21
  ## [1.11.1](https://github.com/klhq/skillmux/compare/v1.11.0...v1.11.1) (2026-09-03)
9
22
 
10
23
 
@@ -291,7 +291,6 @@ paths = ["/Users/you/code/repo1"] # only synced for paths that exist locally
291
291
  skills = ["pdf-extractor"] # must not overlap [core]
292
292
 
293
293
  [targets.claude-code]
294
- dir = "/Users/you/.claude/skills"
295
294
  host = "workhorse" # optional; init adds the current hostname
296
295
  project_groups = ["repo1"] # which [project.*] groups materialize into this target; [] means none
297
296
  ```
@@ -299,7 +298,7 @@ project_groups = ["repo1"] # which [project.*] groups materialize into
299
298
  - `[core].skills`: symlinked into every `[targets.*]` dir on `sync`. Capped at 25 skills; `sync` fails if a listed skill id isn't actually in the vault.
300
299
  - `[project.<group>].skills`: symlinked only into `<path>/<relative path from $HOME to the target dir>`, for each `paths` entry, and only for targets whose `project_groups` names that group. `paths` entries must resolve under `$HOME` (that's how the pin path is derived). A skill can't appear in both `[core]` and the same `[project.*]` group.
301
300
  - `[project.<group>].paths` can list the same project's checkout on more than one machine (e.g. `["/home/alice/code/repo1", "/Users/alice/code/repo1"]`). `sync` silently skips any entry that doesn't exist on the machine it's running on (see below), so one shared manifest can span machines with different checkout locations without needing per-machine manifests.
302
- - `[targets.<name>]`: one entry per adopted surface. `skillmux init --agent <name> --yes` writes these for each supported agent and scopes newly added targets to the current hostname; `skillmux target add <name> --dir <dir> --yes` writes one directly for a directory not tied to any supported agent. Hand-editing is fine as long as `sync` is still allowed to own the directory (see below). An optional `host` limits the target to an exact machine-hostname match; omit it for a global, backward-compatible target. A host mismatch is reported and skipped before any target filesystem operation. `project_groups` is an explicit list, not a boolean: a target only receives the specific groups it names, never every group in the manifest.
301
+ - `[targets.<name>]`: one entry per adopted surface. Built-in names (`agent-skills`, `claude-code`, and `codex`) derive their directories from the name and omit `dir`. A custom target requires `dir`; create one with `skillmux target add <name> --dir <dir> --yes`. `skillmux target migrate --yes` removes legacy built-in `dir` fields without touching target files. An optional `host` limits the target to an exact machine-hostname match; omit it for a global, backward-compatible target. A host mismatch is reported and skipped before any target filesystem operation. `project_groups` is an explicit list, not a boolean: a target only receives the specific groups it names, never every group in the manifest.
303
302
 
304
303
  **Pin/unpin without hand-editing.** `skillmux core pin`/`unpin` mutate `[core]` for you, and `skillmux project pin`/`unpin` mutate `[project.*]`, validating with the same rules `sync` enforces (skill must resolve from `vault_path`, no duplicate pins, `[core]` stays under the 25-skill cap) before writing anything:
305
304
 
@@ -298,6 +298,21 @@ command surface and route table.
298
298
  `skillmux target remove <name> --yes` removes the manifest record and preserves
299
299
  the target directory, marker, and files. Cleanup stays under your control.
300
300
 
301
+ Built-in targets (`agent-skills`, `claude-code`, and `codex`) resolve their
302
+ native directories from their names. Their manifest entries store only host and
303
+ project-group configuration. Custom targets retain an explicit `dir`. To remove
304
+ legacy built-in `dir` fields without changing any target files, run
305
+ `skillmux target migrate --dry-run` and then `skillmux target migrate --yes`.
306
+
307
+ When a managed target's marker still names a previous vault checkout after a
308
+ vault move, use `skillmux target rehome <name> --yes`. It validates every
309
+ recorded managed symlink in the target and its existing project pins before
310
+ updating their `.skillmux` markers to the configured `vault_path`. Use
311
+ `--dry-run` first to inspect the marker paths. Rehome never creates or removes
312
+ skill links. It retargets a link only when it resolves exactly to the previous
313
+ `vault_path` recorded in its marker, and it refuses legacy markers or any link
314
+ that cannot be proven to belong to either the previous or configured vault.
315
+
301
316
  Restore a managed target to one symlink that exposes the full vault:
302
317
 
303
318
  ```sh
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "1.11.1",
3
+ "version": "1.11.2",
4
4
  "description": "Skill management and retrieval for AI agents: sync native skills across clients and route the long tail over MCP",
5
5
  "type": "module",
6
6
  "private": false,
package/src/cli.ts CHANGED
@@ -651,6 +651,8 @@ usage:
651
651
  skillmux target show <name>
652
652
  skillmux target add <name> [--dir <dir>] --yes
653
653
  skillmux target remove <name> --yes
654
+ skillmux target rehome <name> --yes
655
+ skillmux target migrate --yes
654
656
 
655
657
  --dir may be omitted when <name> is a built-in target with a deterministic
656
658
  path: agent-skills, claude-code, codex. Any other <name> requires --dir.
@@ -803,7 +805,7 @@ Setup:
803
805
  [--agent <name>...] [--target <name>...] [--no-sync]
804
806
  [--interactive|--yes|--dry-run] [--json]
805
807
  skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
806
- skillmux target <list|show|add|remove> (a target is a directory sync writes into)
808
+ skillmux target <list|show|add|remove|rehome|migrate> (a target is a directory sync writes into)
807
809
  skillmux core <pin|unpin> <skill_id>... [--yes] [--dry-run] [--json]
808
810
  skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)
809
811
  skillmux config init --vault <path> --yes
@@ -14,6 +14,7 @@ import {
14
14
  updateProjectTargets,
15
15
  upsertProject,
16
16
  validateManifest,
17
+ resolveTargetDir,
17
18
  writeManifestAtomic,
18
19
  } from "../manifest";
19
20
  import {
@@ -51,7 +52,7 @@ export function configuredTargetForSurface(
51
52
  ): string | undefined {
52
53
  if (manifest.targets[surface.targetName]) return surface.targetName;
53
54
  return Object.entries(manifest.targets).find(
54
- ([, target]) => expandHome(target.dir) === surface.path,
55
+ ([name, target]) => resolveTargetDir(name, target) === surface.path,
55
56
  )?.[0];
56
57
  }
57
58
 
@@ -325,7 +326,10 @@ export async function runProject(
325
326
  // agent-skills) — show the resolved directory, not just the target name,
326
327
  // so it's clear at confirmation time which physical folder this affects.
327
328
  const targetDirs = Object.fromEntries(
328
- targets.map((t) => [t, manifest.targets[t]?.dir ?? "(unknown)"]),
329
+ targets.map((t) => {
330
+ const target = manifest.targets[t];
331
+ return [t, target ? resolveTargetDir(t, target) : "(unknown)"];
332
+ }),
329
333
  );
330
334
  const targetsDisplay = targets
331
335
  .map((t) => `${t} (${targetDirs[t]})`)
@@ -5,6 +5,7 @@ import {
5
5
  parseManifest,
6
6
  resolveManifestPath,
7
7
  validateManifest,
8
+ resolveTargetDir,
8
9
  } from "../manifest";
9
10
  import { emitSuccess, isInteractive, warn } from "../output";
10
11
  import {
@@ -129,7 +130,7 @@ export async function runSync(args: string[]): Promise<void> {
129
130
  targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
130
131
  continue;
131
132
  }
132
- const targetDir = expandHome(target.dir);
133
+ const targetDir = resolveTargetDir(targetName, target);
133
134
 
134
135
  if (restoreMonolith) {
135
136
  const result = restoreMonolithTarget(targetDir, vaultPath);
@@ -1,3 +1,5 @@
1
+ import { existsSync } from "node:fs";
2
+ import { hostname } from "node:os";
1
3
  import { expandHome } from "../config";
2
4
  import {
3
5
  BUILT_IN_TARGET_NAMES,
@@ -6,8 +8,9 @@ import {
6
8
  SUPPORTED_AGENT_IDS,
7
9
  } from "../init-agents";
8
10
  import { planInitManifest, applyInit } from "../init";
9
- import { writeManifestAtomic } from "../manifest";
11
+ import { resolveTargetDir, writeManifestAtomic } from "../manifest";
10
12
  import { emitSuccess, unknownSubcommandError } from "../output";
13
+ import { applyTargetMarkerRehome, planTargetMarkerRehome, resolveProjectPinDir } from "../sync";
11
14
  import { confirmIfNeeded, loadManifestContext } from "./shared";
12
15
 
13
16
  export async function runTarget(
@@ -15,7 +18,7 @@ export async function runTarget(
15
18
  args: string[],
16
19
  options: { isJson: boolean; dryRun: boolean },
17
20
  ): Promise<void> {
18
- const { vaultPath, manifestPath, manifest } = await loadManifestContext();
21
+ const { config, vaultPath, manifestPath, manifest } = await loadManifestContext();
19
22
 
20
23
  if (subCommand === "list" || subCommand === "show") {
21
24
  const names =
@@ -27,9 +30,9 @@ export async function runTarget(
27
30
  const target = manifest.targets[name]!;
28
31
  const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
29
32
  const surface = planAgentSurfaces([agent]).surfaces[0];
30
- return surface !== undefined && surface.path === expandHome(target.dir);
33
+ return surface !== undefined && surface.path === resolveTargetDir(name, target);
31
34
  });
32
- return { name, ...target, agents };
35
+ return { name, ...target, dir: resolveTargetDir(name, target), agents };
33
36
  });
34
37
  emitSuccess({ isJson: options.isJson }, { targets }, () => {
35
38
  if (targets.length === 0) {
@@ -106,7 +109,7 @@ export async function runTarget(
106
109
  if (options.dryRun) {
107
110
  emitSuccess(
108
111
  { isJson: options.isJson },
109
- { name, preserved_dir: manifest.targets[name]!.dir },
112
+ { name, preserved_dir: resolveTargetDir(name, manifest.targets[name]!) },
110
113
  () => console.log(`target remove: ${name} (files preserved, dry-run)`),
111
114
  );
112
115
  return;
@@ -122,7 +125,7 @@ export async function runTarget(
122
125
  )
123
126
  return;
124
127
  const targets = { ...manifest.targets };
125
- const removedDir = manifest.targets[name]!.dir;
128
+ const removedDir = resolveTargetDir(name, manifest.targets[name]!);
126
129
  delete targets[name];
127
130
  writeManifestAtomic(manifestPath, { ...manifest, targets });
128
131
  emitSuccess(
@@ -136,5 +139,87 @@ export async function runTarget(
136
139
  return;
137
140
  }
138
141
 
139
- throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove"]);
142
+ if (subCommand === "rehome") {
143
+ const name = args[0];
144
+ const target = name ? manifest.targets[name] : undefined;
145
+ if (!name || !target) {
146
+ throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target rehome <name> --yes");
147
+ }
148
+ if (target.host !== undefined && target.host !== hostname()) {
149
+ throw new Error(`target "${name}" is scoped to host ${target.host}, not ${hostname()}`);
150
+ }
151
+
152
+ const targetDir = resolveTargetDir(name, target);
153
+ if (!existsSync(targetDir)) throw new Error(`target "${name}" directory does not exist: ${targetDir}`);
154
+ const dirs = [targetDir];
155
+ for (const groupName of target.project_groups) {
156
+ const group = manifest.project?.[groupName];
157
+ if (!group) continue;
158
+ for (const projectPath of group.paths) {
159
+ if (!existsSync(projectPath)) continue;
160
+ const pinDir = resolveProjectPinDir(targetDir, projectPath);
161
+ if (existsSync(pinDir)) dirs.push(pinDir);
162
+ }
163
+ }
164
+ const plans = dirs.map((dir) =>
165
+ planTargetMarkerRehome(dir, name, vaultPath, config.local_vault_paths.map(expandHome)),
166
+ );
167
+ const markerPaths = plans.map((plan) => plan.markerPath);
168
+ if (options.dryRun) {
169
+ emitSuccess(
170
+ { isJson: options.isJson },
171
+ { name, marker_paths: markerPaths },
172
+ () => console.log(`target rehome: ${name} (${markerPaths.length} markers, dry-run)`),
173
+ );
174
+ return;
175
+ }
176
+ if (
177
+ !(await confirmIfNeeded({
178
+ confirmed: args.includes("--yes"),
179
+ isJson: options.isJson,
180
+ prompt: `rehome ${markerPaths.length} ${name} marker(s) to ${vaultPath}?`,
181
+ nonInteractiveError: "skillmux target rehome requires --yes when run non-interactively",
182
+ }))
183
+ )
184
+ return;
185
+ applyTargetMarkerRehome(plans, vaultPath);
186
+ emitSuccess(
187
+ { isJson: options.isJson },
188
+ { name, marker_paths: markerPaths },
189
+ () => console.log(`target "${name}" rehomed ${markerPaths.length} marker(s) to ${vaultPath}`),
190
+ );
191
+ return;
192
+ }
193
+
194
+ if (subCommand === "migrate") {
195
+ const builtInTargets = Object.entries(manifest.targets).filter(([name]) =>
196
+ BUILT_IN_TARGET_NAMES.has(name),
197
+ );
198
+ if (options.dryRun) {
199
+ emitSuccess(
200
+ { isJson: options.isJson },
201
+ { migrated_targets: builtInTargets.map(([name]) => name) },
202
+ () => console.log(`target migrate: ${builtInTargets.length} built-in target(s) (dry-run)`),
203
+ );
204
+ return;
205
+ }
206
+ if (
207
+ !(await confirmIfNeeded({
208
+ confirmed: args.includes("--yes"),
209
+ isJson: options.isJson,
210
+ prompt: `remove redundant dir fields from ${builtInTargets.length} built-in target(s)?`,
211
+ nonInteractiveError: "skillmux target migrate requires --yes when run non-interactively",
212
+ }))
213
+ )
214
+ return;
215
+ writeManifestAtomic(manifestPath, manifest);
216
+ emitSuccess(
217
+ { isJson: options.isJson },
218
+ { migrated_targets: builtInTargets.map(([name]) => name) },
219
+ () => console.log(`target migrate: normalized ${builtInTargets.length} built-in target(s)`),
220
+ );
221
+ return;
222
+ }
223
+
224
+ throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove", "rehome", "migrate"]);
140
225
  }
@@ -138,7 +138,7 @@ ${commands}
138
138
  elif [[ "$words[2]" == "eval" && CURRENT == 3 ]]; then
139
139
  _values 'eval command' promote
140
140
  elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
141
- _values 'target command' list show add remove
141
+ _values 'target command' list show add remove rehome migrate
142
142
  elif [[ "$words[2]" == "skill" && CURRENT == 3 ]]; then
143
143
  _values 'skill command' which
144
144
  elif [[ "$words[2]" == "core" && CURRENT == 3 ]]; then
@@ -185,7 +185,7 @@ complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subco
185
185
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l dry-run -d "Print the plan without writing"
186
186
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l yes -d "Apply without prompts"
187
187
  complete -c skillmux -n "__fish_seen_subcommand_from eval; and __fish_seen_subcommand_from promote" -l json -d "Emit a JSON envelope"
188
- complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
188
+ complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove rehome migrate" -d "Manage targets"
189
189
  complete -c skillmux -n "__fish_seen_subcommand_from core" -a "pin unpin" -d "Manage [core] pins"
190
190
  complete -c skillmux -n "__fish_seen_subcommand_from skill" -a "which" -d "Show which root resolves a skill_id"
191
191
  complete -c skillmux -n "__fish_seen_subcommand_from local-vault" -a "init" -d "Initialize a local_vault_paths marker"
package/src/init.ts CHANGED
@@ -22,6 +22,7 @@ import {
22
22
  CORE_SKILL_LIMIT,
23
23
  MANIFEST_FILENAME,
24
24
  } from "./manifest";
25
+ import { BUILT_IN_TARGET_NAMES } from "./init-agents";
25
26
  import {
26
27
  adoptTarget,
27
28
  preflightAdoptTarget,
@@ -244,9 +245,13 @@ export function planInitManifest(
244
245
  const existingTarget = existingManifest.targets[target.name];
245
246
  return [
246
247
  target.name,
247
- existingTarget
248
- ? { ...existingTarget, dir: target.dir }
249
- : { dir: target.dir, host: hostname(), project_groups: [] },
248
+ BUILT_IN_TARGET_NAMES.has(target.name)
249
+ ? existingTarget
250
+ ? { ...existingTarget, dir: undefined }
251
+ : { host: hostname(), project_groups: [] }
252
+ : existingTarget
253
+ ? { ...existingTarget, dir: target.dir }
254
+ : { dir: target.dir, host: hostname(), project_groups: [] },
250
255
  ];
251
256
  }),
252
257
  ),
package/src/manifest.ts CHANGED
@@ -2,6 +2,7 @@ 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";
5
+ import { BUILT_IN_TARGET_NAMES, resolveBuiltInTarget } from "./init-agents";
5
6
  import { resolveSkillRoot, SKILL_ID_PATTERN } from "./vault";
6
7
 
7
8
  export const MANIFEST_FILENAME = "skillmux.toml";
@@ -24,7 +25,7 @@ const projectGroupSchema = z.object({
24
25
  }).strict();
25
26
 
26
27
  const targetSchema = z.object({
27
- dir: z.string().min(1),
28
+ dir: z.string().min(1).optional(),
28
29
  host: z.string().min(1).optional(),
29
30
  project_groups: z.array(groupNameSchema).default([]),
30
31
  }).strict();
@@ -39,10 +40,31 @@ export type ProjectGroup = z.infer<typeof projectGroupSchema>;
39
40
  export type Target = z.infer<typeof targetSchema>;
40
41
  export type Manifest = z.infer<typeof manifestSchema>;
41
42
 
43
+ export function resolveTargetDir(
44
+ name: string,
45
+ target: Target,
46
+ options: { home?: string; codexHome?: string } = {},
47
+ ): string {
48
+ if (BUILT_IN_TARGET_NAMES.has(name)) {
49
+ return resolveBuiltInTarget(name, {
50
+ ...options,
51
+ codexHome: options.codexHome ?? (process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined),
52
+ }).path;
53
+ }
54
+ if (!target.dir) throw new Error(`[targets.${name}] requires dir for a custom target`);
55
+ return expandHome(target.dir);
56
+ }
57
+
42
58
  export function parseManifest(toml: string): Manifest {
43
59
  const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
44
60
  try {
45
- return manifestSchema.parse(parsed);
61
+ const manifest = manifestSchema.parse(parsed);
62
+ for (const [name, target] of Object.entries(manifest.targets)) {
63
+ if (!BUILT_IN_TARGET_NAMES.has(name) && !target.dir) {
64
+ throw new Error(`[targets.${name}] requires dir for a custom target`);
65
+ }
66
+ }
67
+ return manifest;
46
68
  } catch (error) {
47
69
  if (error instanceof z.ZodError) {
48
70
  for (const issue of error.issues) {
@@ -85,9 +107,10 @@ export function serializeManifest(manifest: Manifest): string {
85
107
  }
86
108
 
87
109
  for (const [name, target] of Object.entries(manifest.targets)) {
110
+ const dir = BUILT_IN_TARGET_NAMES.has(name) ? "" : `\ndir = ${JSON.stringify(target.dir)}`;
88
111
  const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
89
112
  sections.push(
90
- `[targets.${name}]\ndir = ${JSON.stringify(target.dir)}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
113
+ `[targets.${name}]${dir}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
91
114
  );
92
115
  }
93
116
 
package/src/sync.ts CHANGED
@@ -1,6 +1,20 @@
1
- import { chmodSync, existsSync, mkdirSync, readFileSync, readdirSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from "node:fs";
1
+ import {
2
+ chmodSync,
3
+ existsSync,
4
+ lstatSync,
5
+ mkdirSync,
6
+ readFileSync,
7
+ readlinkSync,
8
+ readdirSync,
9
+ realpathSync,
10
+ renameSync,
11
+ rmSync,
12
+ symlinkSync,
13
+ unlinkSync,
14
+ writeFileSync,
15
+ } from "node:fs";
2
16
  import { homedir } from "node:os";
3
- import { join, relative } from "node:path";
17
+ import { basename, join, relative } from "node:path";
4
18
  import { findSymlinks } from "./install";
5
19
  import { resolveSkillRoot } from "./vault";
6
20
 
@@ -76,7 +90,29 @@ function writeTargetMarker(
76
90
  const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
77
91
  const serialized = JSON.stringify(marker, null, 2);
78
92
  if (!existsSync(markerPath) || readFileSync(markerPath, "utf-8") !== serialized) {
79
- writeFileSync(markerPath, serialized);
93
+ writeFileAtomic(markerPath, serialized);
94
+ }
95
+ }
96
+
97
+ function writeFileAtomic(path: string, contents: string): void {
98
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
99
+ try {
100
+ writeFileSync(temporaryPath, contents);
101
+ renameSync(temporaryPath, path);
102
+ } catch (error) {
103
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
104
+ throw error;
105
+ }
106
+ }
107
+
108
+ function replaceSymlinkAtomic(path: string, target: string): void {
109
+ const temporaryPath = `${path}.${process.pid}.${Date.now()}.tmp`;
110
+ try {
111
+ symlinkSync(target, temporaryPath);
112
+ renameSync(temporaryPath, path);
113
+ } catch (error) {
114
+ if (existsSync(temporaryPath)) unlinkSync(temporaryPath);
115
+ throw error;
80
116
  }
81
117
  }
82
118
 
@@ -227,6 +263,102 @@ export function preflightAdoptTarget(dir: string, targetName: string, vaultPath:
227
263
  }
228
264
  }
229
265
 
266
+ export interface TargetMarkerRehomePlan {
267
+ dir: string;
268
+ markerPath: string;
269
+ marker: SkillmuxMarker;
270
+ links: TargetLinkRehomePlan[];
271
+ }
272
+
273
+ interface TargetLinkRehomePlan {
274
+ entryPath: string;
275
+ previousTarget: string;
276
+ nextTarget: string;
277
+ }
278
+
279
+ /**
280
+ * Proves every managed entry resolves either to its active source or exactly to
281
+ * the vault recorded in its marker. Only the latter links are re-pointed.
282
+ */
283
+ export function planTargetMarkerRehome(
284
+ dir: string,
285
+ targetName: string,
286
+ vaultPath: string,
287
+ localVaultPaths: string[] = [],
288
+ ): TargetMarkerRehomePlan {
289
+ const markerPath = join(dir, SKILLMUX_MARKER_FILENAME);
290
+ if (!existsSync(markerPath)) {
291
+ throw new Error(`${dir} has a legacy marker; rehome requires .skillmux ownership`);
292
+ }
293
+ const marker = readSkillmuxMarker(dir);
294
+ if (!marker) throw new Error(`${dir} is not owned by skillmux`);
295
+ if (marker.role !== "target") throw new Error(`${dir} has a local_vault marker, not target ownership`);
296
+ if (marker.schema_version !== 1) {
297
+ throw new Error(`${dir} has a legacy marker; rehome requires schema_version 1`);
298
+ }
299
+ if (marker.target !== targetName) {
300
+ throw new Error(`${dir} is owned by target "${marker.target}", not "${targetName}"`);
301
+ }
302
+
303
+ const links: TargetLinkRehomePlan[] = [];
304
+ for (const skillId of marker.managed_entries ?? []) {
305
+ if (basename(skillId) !== skillId) {
306
+ throw new Error(`${dir} marker has an invalid managed entry "${skillId}"`);
307
+ }
308
+ const entryPath = join(dir, skillId);
309
+ if (!lstatSync(entryPath).isSymbolicLink()) {
310
+ throw new Error(`${entryPath} is not the managed symlink recorded by ${dir}`);
311
+ }
312
+ const sourceRoot = resolveSkillRoot(skillId, vaultPath, localVaultPaths) ?? vaultPath;
313
+ const expectedPath = join(sourceRoot, skillId);
314
+ if (!existsSync(expectedPath)) {
315
+ throw new Error(`${entryPath} cannot be proven to resolve from the configured vault's ${skillId}`);
316
+ }
317
+ const resolvedEntryPath = realpathSync(entryPath);
318
+ if (resolvedEntryPath === realpathSync(expectedPath)) continue;
319
+
320
+ const previousPath = join(marker.vault_path!, skillId);
321
+ if (!existsSync(previousPath) || resolvedEntryPath !== realpathSync(previousPath)) {
322
+ throw new Error(`${entryPath} does not resolve to the configured vault's ${skillId}`);
323
+ }
324
+ links.push({ entryPath, previousTarget: readlinkSync(entryPath), nextTarget: expectedPath });
325
+ }
326
+
327
+ return { dir, markerPath, marker, links };
328
+ }
329
+
330
+ /** Applies only preflighted rehome plans. All plans are validated before the first write. */
331
+ export function applyTargetMarkerRehome(plans: TargetMarkerRehomePlan[], vaultPath: string): void {
332
+ const completedLinks: TargetLinkRehomePlan[] = [];
333
+ const completedMarkers: TargetMarkerRehomePlan[] = [];
334
+ try {
335
+ for (const plan of plans) {
336
+ for (const link of plan.links) {
337
+ replaceSymlinkAtomic(link.entryPath, link.nextTarget);
338
+ completedLinks.push(link);
339
+ }
340
+ }
341
+ for (const plan of plans) {
342
+ writeTargetMarker(
343
+ plan.dir,
344
+ plan.marker.target!,
345
+ vaultPath,
346
+ plan.marker.managed_entries ?? [],
347
+ plan.marker.created_at,
348
+ );
349
+ completedMarkers.push(plan);
350
+ }
351
+ } catch (error) {
352
+ for (const plan of completedMarkers.reverse()) {
353
+ writeFileAtomic(plan.markerPath, JSON.stringify(plan.marker, null, 2));
354
+ }
355
+ for (const link of completedLinks.reverse()) {
356
+ replaceSymlinkAtomic(link.entryPath, link.previousTarget);
357
+ }
358
+ throw error;
359
+ }
360
+ }
361
+
230
362
  /**
231
363
  * Marks an existing directory as skillmux-owned without touching its content —
232
364
  * the consented, one-time adoption skillmux init performs (see SkillmuxMarker in