@klhapp/skillmux 1.11.1 → 1.12.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 +20 -0
- package/README.md +2 -1
- package/docs/cli.md +1 -1
- package/docs/concepts.md +2 -1
- package/docs/configuration.md +6 -5
- package/docs/skill-management.md +16 -1
- package/package.json +1 -1
- package/src/cli.ts +3 -1
- package/src/commands/project.ts +6 -2
- package/src/commands/sync.ts +2 -1
- package/src/commands/target.ts +92 -7
- package/src/completions.ts +2 -2
- package/src/init.ts +11 -5
- package/src/manifest.ts +39 -9
- package/src/sync.ts +135 -3
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,26 @@ 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.12.0](https://github.com/klhq/skillmux/compare/v1.11.2...v1.12.0) (2026-09-12)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
* **manifest:** make the core skill cap configurable ([#190](https://github.com/klhq/skillmux/issues/190)) ([c5690b0](https://github.com/klhq/skillmux/commit/c5690b0f5fafda708ff87b991443e48bc02a4def))
|
|
14
|
+
|
|
15
|
+
## [1.11.2](https://github.com/klhq/skillmux/compare/v1.11.1...v1.11.2) (2026-09-05)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Added
|
|
19
|
+
|
|
20
|
+
* **target:** add built-in target migration ([#189](https://github.com/klhq/skillmux/issues/189)) ([fe85951](https://github.com/klhq/skillmux/commit/fe85951f1728907ce0faa9fe5df79677ea278973))
|
|
21
|
+
* **target:** add safe marker rehome workflow ([#187](https://github.com/klhq/skillmux/issues/187)) ([b8601aa](https://github.com/klhq/skillmux/commit/b8601aa9dc81d2c4a882c6d810d9922ba596e701))
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
### Chores
|
|
25
|
+
|
|
26
|
+
* **release:** force version 1.11.2 ([1ee9cbc](https://github.com/klhq/skillmux/commit/1ee9cbcfbfcbd9650f8e7f2d2dbbd54cdb0777ac))
|
|
27
|
+
|
|
8
28
|
## [1.11.1](https://github.com/klhq/skillmux/compare/v1.11.0...v1.11.1) (2026-09-03)
|
|
9
29
|
|
|
10
30
|
|
package/README.md
CHANGED
|
@@ -143,7 +143,8 @@ skillmux init \
|
|
|
143
143
|
--yes
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
-
Core pins apply to each configured target and stay capped at 25 skills
|
|
146
|
+
Core pins apply to each configured target and stay capped at 25 skills, or at
|
|
147
|
+
`[core].limit` when the manifest sets one. Add
|
|
147
148
|
project-specific skills from a repository root:
|
|
148
149
|
|
|
149
150
|
```sh
|
package/docs/cli.md
CHANGED
|
@@ -322,7 +322,7 @@ command prints the preserved path so cleanup remains an explicit user action.
|
|
|
322
322
|
## Core skills (`skillmux core`)
|
|
323
323
|
|
|
324
324
|
Pin or unpin skills into `[core]`, the tier every target receives by
|
|
325
|
-
default, capped at 25 skills:
|
|
325
|
+
default, capped at 25 skills unless `[core].limit` raises it:
|
|
326
326
|
|
|
327
327
|
```sh
|
|
328
328
|
skillmux core pin csv-formatter --yes
|
package/docs/concepts.md
CHANGED
|
@@ -55,7 +55,8 @@ one.
|
|
|
55
55
|
|
|
56
56
|
One skill can serve different roles across machines or projects, but the
|
|
57
57
|
shared manifest prevents conflicting core and project assignments. Core stays
|
|
58
|
-
capped at 25 skills to protect agent startup context.
|
|
58
|
+
capped at 25 skills by default to protect agent startup context. Raise or lower that with
|
|
59
|
+
`limit` under `[core]` in the manifest.
|
|
59
60
|
|
|
60
61
|
Delivery tiers do not select a deployment. A local Skillmux process can serve
|
|
61
62
|
routed skills over stdio, while a shared Skillmux process can serve its server
|
package/docs/configuration.md
CHANGED
|
@@ -284,24 +284,25 @@ Lives at the vault root (a legacy `skr.toml` is still read if present, never wri
|
|
|
284
284
|
|
|
285
285
|
```toml
|
|
286
286
|
[core]
|
|
287
|
-
skills = ["csv-formatter"] # pinned into every [targets.*] dir; capped at 25
|
|
287
|
+
skills = ["csv-formatter"] # pinned into every [targets.*] dir; capped at 25 by default
|
|
288
|
+
# limit = 30 # optional: raise or lower that cap
|
|
288
289
|
|
|
289
290
|
[project.repo1]
|
|
290
291
|
paths = ["/Users/you/code/repo1"] # only synced for paths that exist locally
|
|
291
292
|
skills = ["pdf-extractor"] # must not overlap [core]
|
|
292
293
|
|
|
293
294
|
[targets.claude-code]
|
|
294
|
-
dir = "/Users/you/.claude/skills"
|
|
295
295
|
host = "workhorse" # optional; init adds the current hostname
|
|
296
296
|
project_groups = ["repo1"] # which [project.*] groups materialize into this target; [] means none
|
|
297
297
|
```
|
|
298
298
|
|
|
299
|
-
- `[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.
|
|
299
|
+
- `[core].skills`: symlinked into every `[targets.*]` dir on `sync`. Capped at 25 skills unless `[core].limit` says otherwise; `sync` fails if a listed skill id isn't actually in the vault.
|
|
300
|
+
- `[core].limit` (optional, positive integer): the cap on `[core].skills`. Absent means 25. It lives in the manifest rather than machine config so the same manifest validates identically on every machine.
|
|
300
301
|
- `[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
302
|
- `[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.
|
|
303
|
+
- `[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
304
|
|
|
304
|
-
**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
|
|
305
|
+
**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 its cap) before writing anything:
|
|
305
306
|
|
|
306
307
|
```sh
|
|
307
308
|
skillmux core pin csv-formatter --yes # add to [core]
|
package/docs/skill-management.md
CHANGED
|
@@ -171,7 +171,7 @@ skillmux core unpin csv-formatter --yes
|
|
|
171
171
|
|
|
172
172
|
One command can change several skill IDs. Skillmux validates the complete
|
|
173
173
|
change before writing, so a conflict prevents the whole operation. Core stays
|
|
174
|
-
capped at 25 skills.
|
|
174
|
+
capped at 25 skills by default, which `[core].limit` overrides.
|
|
175
175
|
|
|
176
176
|
Run `skillmux sync` after a direct pin or unpin command.
|
|
177
177
|
|
|
@@ -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
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
|
package/src/commands/project.ts
CHANGED
|
@@ -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]) =>
|
|
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) =>
|
|
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]})`)
|
package/src/commands/sync.ts
CHANGED
|
@@ -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 =
|
|
133
|
+
const targetDir = resolveTargetDir(targetName, target);
|
|
133
134
|
|
|
134
135
|
if (restoreMonolith) {
|
|
135
136
|
const result = restoreMonolithTarget(targetDir, vaultPath);
|
package/src/commands/target.ts
CHANGED
|
@@ -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 ===
|
|
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]
|
|
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]
|
|
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
|
-
|
|
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
|
}
|
package/src/completions.ts
CHANGED
|
@@ -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,17 +245,22 @@ export function planInitManifest(
|
|
|
244
245
|
const existingTarget = existingManifest.targets[target.name];
|
|
245
246
|
return [
|
|
246
247
|
target.name,
|
|
247
|
-
|
|
248
|
-
?
|
|
249
|
-
|
|
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
|
),
|
|
253
258
|
},
|
|
254
259
|
};
|
|
255
|
-
|
|
260
|
+
const effectiveLimit = manifest.core.limit ?? CORE_SKILL_LIMIT;
|
|
261
|
+
if (manifest.core.skills.length > effectiveLimit) {
|
|
256
262
|
throw new Error(
|
|
257
|
-
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${
|
|
263
|
+
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${effectiveLimit}`,
|
|
258
264
|
);
|
|
259
265
|
}
|
|
260
266
|
for (const skillId of coreSkillIds) {
|
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,13 +25,16 @@ 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();
|
|
31
32
|
|
|
32
33
|
const manifestSchema = z.object({
|
|
33
|
-
core: z.object({
|
|
34
|
+
core: z.object({
|
|
35
|
+
skills: z.array(skillIdSchema),
|
|
36
|
+
limit: z.number().int().positive().optional(),
|
|
37
|
+
}).strict(),
|
|
34
38
|
project: z.record(groupNameSchema, projectGroupSchema).optional(),
|
|
35
39
|
targets: z.record(groupNameSchema, targetSchema).default({}),
|
|
36
40
|
}).strict();
|
|
@@ -39,10 +43,31 @@ export type ProjectGroup = z.infer<typeof projectGroupSchema>;
|
|
|
39
43
|
export type Target = z.infer<typeof targetSchema>;
|
|
40
44
|
export type Manifest = z.infer<typeof manifestSchema>;
|
|
41
45
|
|
|
46
|
+
export function resolveTargetDir(
|
|
47
|
+
name: string,
|
|
48
|
+
target: Target,
|
|
49
|
+
options: { home?: string; codexHome?: string } = {},
|
|
50
|
+
): string {
|
|
51
|
+
if (BUILT_IN_TARGET_NAMES.has(name)) {
|
|
52
|
+
return resolveBuiltInTarget(name, {
|
|
53
|
+
...options,
|
|
54
|
+
codexHome: options.codexHome ?? (process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined),
|
|
55
|
+
}).path;
|
|
56
|
+
}
|
|
57
|
+
if (!target.dir) throw new Error(`[targets.${name}] requires dir for a custom target`);
|
|
58
|
+
return expandHome(target.dir);
|
|
59
|
+
}
|
|
60
|
+
|
|
42
61
|
export function parseManifest(toml: string): Manifest {
|
|
43
62
|
const parsed = Bun.TOML.parse(toml) as Record<string, unknown>;
|
|
44
63
|
try {
|
|
45
|
-
|
|
64
|
+
const manifest = manifestSchema.parse(parsed);
|
|
65
|
+
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
66
|
+
if (!BUILT_IN_TARGET_NAMES.has(name) && !target.dir) {
|
|
67
|
+
throw new Error(`[targets.${name}] requires dir for a custom target`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return manifest;
|
|
46
71
|
} catch (error) {
|
|
47
72
|
if (error instanceof z.ZodError) {
|
|
48
73
|
for (const issue of error.issues) {
|
|
@@ -76,7 +101,10 @@ function tomlStringArray(values: string[]): string {
|
|
|
76
101
|
|
|
77
102
|
/** Purpose-built serializer for this manifest's fixed shape — not a general TOML writer. */
|
|
78
103
|
export function serializeManifest(manifest: Manifest): string {
|
|
79
|
-
|
|
104
|
+
// An absent limit is left out rather than written as the default, so the file keeps saying
|
|
105
|
+
// "unset" and a future change to CORE_SKILL_LIMIT still reaches manifests that never opted in.
|
|
106
|
+
const coreLimit = manifest.core.limit === undefined ? "" : `\nlimit = ${manifest.core.limit}`;
|
|
107
|
+
const sections: string[] = [`[core]\nskills = ${tomlStringArray(manifest.core.skills)}${coreLimit}`];
|
|
80
108
|
|
|
81
109
|
for (const [name, group] of Object.entries(manifest.project ?? {})) {
|
|
82
110
|
sections.push(
|
|
@@ -85,9 +113,10 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
85
113
|
}
|
|
86
114
|
|
|
87
115
|
for (const [name, target] of Object.entries(manifest.targets)) {
|
|
116
|
+
const dir = BUILT_IN_TARGET_NAMES.has(name) ? "" : `\ndir = ${JSON.stringify(target.dir)}`;
|
|
88
117
|
const host = target.host ? `\nhost = ${JSON.stringify(target.host)}` : "";
|
|
89
118
|
sections.push(
|
|
90
|
-
`[targets.${name}]
|
|
119
|
+
`[targets.${name}]${dir}${host}\nproject_groups = ${tomlStringArray(target.project_groups)}`,
|
|
91
120
|
);
|
|
92
121
|
}
|
|
93
122
|
|
|
@@ -117,14 +146,14 @@ export function pinCore(manifest: Manifest, skillId: string): Manifest {
|
|
|
117
146
|
if (existing) {
|
|
118
147
|
throw new Error(`skill "${skillId}" already pinned in ${existing}`);
|
|
119
148
|
}
|
|
120
|
-
return { ...manifest, core: { skills: [...manifest.core.skills, skillId] } };
|
|
149
|
+
return { ...manifest, core: { ...manifest.core, skills: [...manifest.core.skills, skillId] } };
|
|
121
150
|
}
|
|
122
151
|
|
|
123
152
|
export function unpinCore(manifest: Manifest, skillId: string): Manifest {
|
|
124
153
|
if (!manifest.core.skills.includes(skillId)) {
|
|
125
154
|
throw new Error(`skill "${skillId}" is not pinned in [core]`);
|
|
126
155
|
}
|
|
127
|
-
return { ...manifest, core: { skills: manifest.core.skills.filter((id) => id !== skillId) } };
|
|
156
|
+
return { ...manifest, core: { ...manifest.core, skills: manifest.core.skills.filter((id) => id !== skillId) } };
|
|
128
157
|
}
|
|
129
158
|
|
|
130
159
|
export function pinProject(manifest: Manifest, skillId: string, group: string, paths?: string[]): Manifest {
|
|
@@ -290,9 +319,10 @@ export function validateManifest(
|
|
|
290
319
|
vaultPath: string,
|
|
291
320
|
localVaultPaths: string[] = [],
|
|
292
321
|
): ManifestValidationResult {
|
|
293
|
-
|
|
322
|
+
const effectiveLimit = manifest.core.limit ?? CORE_SKILL_LIMIT;
|
|
323
|
+
if (manifest.core.skills.length > effectiveLimit) {
|
|
294
324
|
throw new Error(
|
|
295
|
-
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${
|
|
325
|
+
`[core] has ${manifest.core.skills.length} skills, exceeding the limit of ${effectiveLimit}`,
|
|
296
326
|
);
|
|
297
327
|
}
|
|
298
328
|
|
package/src/sync.ts
CHANGED
|
@@ -1,6 +1,20 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|