@klhapp/skillmux 0.4.3 → 0.4.5
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 +14 -0
- package/README.md +2 -2
- package/docs/configuration.md +7 -4
- package/package.json +1 -1
- package/src/cli.ts +13 -9
- package/src/manifest.ts +20 -11
- package/src/server.ts +4 -2
- package/src/sync.ts +8 -8
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,20 @@ 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
|
+
## [0.4.5](https://github.com/klhq/skillmux/compare/v0.4.4...v0.4.5) (2026-07-24)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
* **manifest:** rename project group repos to paths ([51263f1](https://github.com/klhq/skillmux/commit/51263f175a45b6368509bb3c25ce8dcbeb07d127))
|
|
14
|
+
|
|
15
|
+
## [0.4.4](https://github.com/klhq/skillmux/compare/v0.4.3...v0.4.4) (2026-07-23)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
### Fixed
|
|
19
|
+
|
|
20
|
+
* connect stdio transport before blocking on runtime init ([#54](https://github.com/klhq/skillmux/issues/54)) ([847af1c](https://github.com/klhq/skillmux/commit/847af1c39265937701e432b631710fc0a2aa9ea0))
|
|
21
|
+
|
|
8
22
|
## [0.4.3](https://github.com/klhq/skillmux/compare/v0.4.2...v0.4.3) (2026-07-23)
|
|
9
23
|
|
|
10
24
|
|
package/README.md
CHANGED
|
@@ -208,7 +208,7 @@ This writes `skillmux.toml` at the vault root and marks `~/.claude/skills` as sk
|
|
|
208
208
|
skillmux manifest pin csv-formatter --core
|
|
209
209
|
```
|
|
210
210
|
|
|
211
|
-
Unpin the same way: `skillmux manifest unpin csv-formatter --core`. `--project <group>` pins into a `[project.<group>]` tier instead (add `--
|
|
211
|
+
Unpin the same way: `skillmux manifest unpin csv-formatter --core`. `--project <group>` pins into a `[project.<group>]` tier instead (add `--path <path>` the first time, to create the group). Every pin is validated before writing — the skill must actually resolve from the vault, and `[core]` stays under its 25-skill cap.
|
|
212
212
|
|
|
213
213
|
Hand-editing `skillmux.toml` still works if you prefer it:
|
|
214
214
|
|
|
@@ -221,7 +221,7 @@ dir = "/Users/you/.claude/skills"
|
|
|
221
221
|
project_groups = []
|
|
222
222
|
```
|
|
223
223
|
|
|
224
|
-
Full manifest schema, including `[project.<group>]` pins scoped to one
|
|
224
|
+
Full manifest schema, including `[project.<group>]` pins scoped to one or more local paths and machine-local overlay vaults via `local_vault_paths`, is in [`docs/configuration.md`](docs/configuration.md#tiers-and-the-manifest).
|
|
225
225
|
|
|
226
226
|
### 3. Materialize
|
|
227
227
|
|
package/docs/configuration.md
CHANGED
|
@@ -85,7 +85,7 @@ Lives at the vault root (a legacy `skr.toml` is still read if present, never wri
|
|
|
85
85
|
skills = ["csv-formatter"] # pinned into every [targets.*] dir; capped at 25
|
|
86
86
|
|
|
87
87
|
[project.repo1]
|
|
88
|
-
|
|
88
|
+
paths = ["/Users/you/code/repo1"] # only synced for paths that exist locally
|
|
89
89
|
skills = ["pdf-extractor"] # must not overlap [core]
|
|
90
90
|
|
|
91
91
|
[targets.claude]
|
|
@@ -94,7 +94,8 @@ project_groups = ["repo1"] # which [project.*] groups materialize into
|
|
|
94
94
|
```
|
|
95
95
|
|
|
96
96
|
- `[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.
|
|
97
|
-
- `[project.<group>].skills` — symlinked only into `<
|
|
97
|
+
- `[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.
|
|
98
|
+
- `[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.
|
|
98
99
|
- `[targets.<name>]` — one entry per adopted surface. `skillmux init --target <name> --yes` writes these; hand-editing is fine as long as `sync` is still allowed to own the directory (see below). `project_groups` is an explicit list, not a boolean — a target only receives the specific groups it names, never every group in the manifest.
|
|
99
100
|
|
|
100
101
|
**Pin/unpin without hand-editing.** `skillmux manifest pin`/`unpin` mutate `[core]`/`[project.*]` for you, validating with the same rules `sync`/`doctor` enforce (skill must resolve from `vault_path`, no duplicate pins, `[core]` stays under the 25-skill cap) before writing anything:
|
|
@@ -102,14 +103,16 @@ project_groups = ["repo1"] # which [project.*] groups materialize into
|
|
|
102
103
|
```sh
|
|
103
104
|
skillmux manifest pin csv-formatter --core # add to [core]
|
|
104
105
|
skillmux manifest pin pdf-extractor --project repo1 # add to an existing group
|
|
105
|
-
skillmux manifest pin pdf-extractor --project repo2 --
|
|
106
|
+
skillmux manifest pin pdf-extractor --project repo2 --path ~/code/repo2 # create a new group
|
|
106
107
|
skillmux manifest unpin csv-formatter --core # remove from [core]
|
|
107
108
|
skillmux manifest unpin pdf-extractor --project repo1 # remove from a group (group stays, even if empty)
|
|
108
109
|
```
|
|
109
110
|
|
|
110
|
-
`--
|
|
111
|
+
`--path` is only accepted when `--project <group>` names a group that doesn't exist yet — passing it for an existing group is rejected, since changing an existing group's `paths` isn't this command's job (append another entry by hand-editing `skillmux.toml` instead). Hand-editing `skillmux.toml` directly is still fully supported; these commands are a convenience layer over the same file, not a replacement for it.
|
|
111
112
|
|
|
112
113
|
> **Breaking change:** `[targets.<name>].project` (a boolean) has been replaced by `project_groups` (an array of `[project.*]` names). A manifest still using the old field fails to parse with an error pointing at the new one. To migrate, replace `project = true` with `project_groups = [...]` listing every group that target previously received (previously *all* groups, unconditionally); replace `project = false` with `project_groups = []`.
|
|
114
|
+
>
|
|
115
|
+
> **Breaking change:** `[project.<group>].repos` has been renamed to `paths` — it was never required to be a git repository, just a local directory, and the old name collided in meaning with `skillmux install <repo>`'s unrelated git-source `repo` concept. A manifest still using `repos` fails to parse with an error pointing at `paths`; migrate by renaming the key (values are unchanged).
|
|
113
116
|
|
|
114
117
|
Every `[core]`/`[project.*]` skill_id must resolve from the canonical `vault_path` — pinning a skill that only exists in a `local_vault_paths` entry (see below) fails `sync`/`doctor` with a distinct error, since the manifest is meant to be portable across machines and a machine-local override wouldn't exist elsewhere.
|
|
115
118
|
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -148,6 +148,10 @@ async function main() {
|
|
|
148
148
|
};
|
|
149
149
|
process.once("SIGTERM", shutdown);
|
|
150
150
|
process.once("SIGINT", shutdown);
|
|
151
|
+
if (transport === "stdio") {
|
|
152
|
+
process.stdin.on("close", shutdown);
|
|
153
|
+
process.stdin.on("end", shutdown);
|
|
154
|
+
}
|
|
151
155
|
break;
|
|
152
156
|
}
|
|
153
157
|
case "index":
|
|
@@ -571,14 +575,14 @@ async function runWhich(args: string[]): Promise<void> {
|
|
|
571
575
|
for (const shadowedRoot of roots.slice(1)) console.log(` shadows: ${shadowedRoot}`);
|
|
572
576
|
}
|
|
573
577
|
|
|
574
|
-
const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--
|
|
578
|
+
const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
|
|
575
579
|
|
|
576
|
-
function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string;
|
|
580
|
+
function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
|
|
577
581
|
const skillId = args[0];
|
|
578
582
|
if (!skillId) throw new Error(MANIFEST_USAGE);
|
|
579
583
|
let core = false;
|
|
580
584
|
let project: string | undefined;
|
|
581
|
-
const
|
|
585
|
+
const paths: string[] = [];
|
|
582
586
|
for (let i = 1; i < args.length; i++) {
|
|
583
587
|
const arg = args[i];
|
|
584
588
|
if (arg === "--core") core = true;
|
|
@@ -586,19 +590,19 @@ function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean;
|
|
|
586
590
|
const value = args[++i];
|
|
587
591
|
if (!value) throw new Error("--project requires a group name");
|
|
588
592
|
project = value;
|
|
589
|
-
} else if (arg === "--
|
|
593
|
+
} else if (arg === "--path") {
|
|
590
594
|
const value = args[++i];
|
|
591
|
-
if (!value) throw new Error("--
|
|
592
|
-
|
|
595
|
+
if (!value) throw new Error("--path requires a path");
|
|
596
|
+
paths.push(value);
|
|
593
597
|
} else throw new Error(`unknown manifest option: ${arg}`);
|
|
594
598
|
}
|
|
595
599
|
if (core === (project !== undefined)) throw new Error(MANIFEST_USAGE);
|
|
596
|
-
return { skillId, core, project,
|
|
600
|
+
return { skillId, core, project, paths };
|
|
597
601
|
}
|
|
598
602
|
|
|
599
603
|
async function runManifest(subCommand: string, args: string[]): Promise<void> {
|
|
600
604
|
if (subCommand !== "pin" && subCommand !== "unpin") throw new Error(MANIFEST_USAGE);
|
|
601
|
-
const { skillId, core, project,
|
|
605
|
+
const { skillId, core, project, paths } = parseManifestPinArgs(args);
|
|
602
606
|
const config = await loadConfig();
|
|
603
607
|
const vaultPath = expandHome(config.vault_path);
|
|
604
608
|
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
@@ -612,7 +616,7 @@ async function runManifest(subCommand: string, args: string[]): Promise<void> {
|
|
|
612
616
|
} else {
|
|
613
617
|
updated =
|
|
614
618
|
subCommand === "pin"
|
|
615
|
-
? pinProject(manifest, skillId, project!,
|
|
619
|
+
? pinProject(manifest, skillId, project!, paths)
|
|
616
620
|
: unpinProject(manifest, skillId, project!);
|
|
617
621
|
}
|
|
618
622
|
validateManifest(updated, vaultPath, localVaultPaths);
|
package/src/manifest.ts
CHANGED
|
@@ -19,7 +19,7 @@ const groupNameSchema = z.string().regex(/^[a-z][a-z0-9_-]*$/).max(64);
|
|
|
19
19
|
const skillIdSchema = z.string().regex(SKILL_ID_PATTERN);
|
|
20
20
|
|
|
21
21
|
const projectGroupSchema = z.object({
|
|
22
|
-
|
|
22
|
+
paths: z.array(z.string().min(1)),
|
|
23
23
|
skills: z.array(skillIdSchema),
|
|
24
24
|
}).strict();
|
|
25
25
|
|
|
@@ -54,6 +54,15 @@ export function parseManifest(toml: string): Manifest {
|
|
|
54
54
|
`[targets.${String(issue.path[1])}] uses the removed field "project" (boolean) — replace it with "project_groups" (an array of [project.<group>] names).`,
|
|
55
55
|
);
|
|
56
56
|
}
|
|
57
|
+
if (
|
|
58
|
+
issue.code === "unrecognized_keys" &&
|
|
59
|
+
issue.path[0] === "project" &&
|
|
60
|
+
issue.keys.includes("repos")
|
|
61
|
+
) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
`[project.${String(issue.path[1])}] uses the removed field "repos" — replace it with "paths".`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
57
66
|
}
|
|
58
67
|
}
|
|
59
68
|
throw error;
|
|
@@ -70,7 +79,7 @@ export function serializeManifest(manifest: Manifest): string {
|
|
|
70
79
|
|
|
71
80
|
for (const [name, group] of Object.entries(manifest.project ?? {})) {
|
|
72
81
|
sections.push(
|
|
73
|
-
`[project.${name}]\
|
|
82
|
+
`[project.${name}]\npaths = ${tomlStringArray(group.paths)}\nskills = ${tomlStringArray(group.skills)}`,
|
|
74
83
|
);
|
|
75
84
|
}
|
|
76
85
|
|
|
@@ -106,15 +115,15 @@ export function unpinCore(manifest: Manifest, skillId: string): Manifest {
|
|
|
106
115
|
return { ...manifest, core: { skills: manifest.core.skills.filter((id) => id !== skillId) } };
|
|
107
116
|
}
|
|
108
117
|
|
|
109
|
-
export function pinProject(manifest: Manifest, skillId: string, group: string,
|
|
118
|
+
export function pinProject(manifest: Manifest, skillId: string, group: string, paths?: string[]): Manifest {
|
|
110
119
|
if (!groupNameSchema.safeParse(group).success) {
|
|
111
120
|
throw new Error(`invalid group name "${group}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`);
|
|
112
121
|
}
|
|
113
122
|
const existingGroup = manifest.project?.[group];
|
|
114
123
|
|
|
115
124
|
if (!existingGroup) {
|
|
116
|
-
if (!
|
|
117
|
-
throw new Error(`group "${group}" does not exist — pass --
|
|
125
|
+
if (!paths || paths.length === 0) {
|
|
126
|
+
throw new Error(`group "${group}" does not exist — pass --path <path> at least once to create it`);
|
|
118
127
|
}
|
|
119
128
|
const existing = findExistingPin(manifest, skillId);
|
|
120
129
|
if (existing) {
|
|
@@ -122,12 +131,12 @@ export function pinProject(manifest: Manifest, skillId: string, group: string, r
|
|
|
122
131
|
}
|
|
123
132
|
return {
|
|
124
133
|
...manifest,
|
|
125
|
-
project: { ...manifest.project, [group]: {
|
|
134
|
+
project: { ...manifest.project, [group]: { paths, skills: [skillId] } },
|
|
126
135
|
};
|
|
127
136
|
}
|
|
128
137
|
|
|
129
|
-
if (
|
|
130
|
-
throw new Error(`group "${group}" already exists — --
|
|
138
|
+
if (paths && paths.length > 0) {
|
|
139
|
+
throw new Error(`group "${group}" already exists — --path is only used when creating a new group`);
|
|
131
140
|
}
|
|
132
141
|
const existing = findExistingPin(manifest, skillId);
|
|
133
142
|
if (existing) {
|
|
@@ -208,9 +217,9 @@ export function validateManifest(
|
|
|
208
217
|
throw new Error(`skill "${skillId}" appears in both [core] and [project.${groupName}]`);
|
|
209
218
|
}
|
|
210
219
|
}
|
|
211
|
-
for (const
|
|
212
|
-
if (!existsSync(expandHome(
|
|
213
|
-
notes.push(`[project.${groupName}]
|
|
220
|
+
for (const path of group.paths) {
|
|
221
|
+
if (!existsSync(expandHome(path))) {
|
|
222
|
+
notes.push(`[project.${groupName}] paths entry not found locally, skipped: ${path}`);
|
|
214
223
|
}
|
|
215
224
|
}
|
|
216
225
|
}
|
package/src/server.ts
CHANGED
|
@@ -60,9 +60,10 @@ export async function startServer(opts?: {
|
|
|
60
60
|
}): Promise<ServerHandle> {
|
|
61
61
|
const config = opts?.config ?? await loadConfig();
|
|
62
62
|
configure({ config, clients: opts?.clients ?? createClients(config) });
|
|
63
|
-
await initializeRuntime(readinessState);
|
|
64
|
-
metricsRegistry.setReadiness(readinessState.get());
|
|
65
63
|
const stopWatcher = await startVaultWatcher();
|
|
64
|
+
const initPromise = initializeRuntime(readinessState)
|
|
65
|
+
.then(() => metricsRegistry.setReadiness(readinessState.get()))
|
|
66
|
+
.catch((err) => console.error("skillmux runtime init error:", err));
|
|
66
67
|
|
|
67
68
|
const server = new McpServer({ name: "skillmux", version: "0.1.0" });
|
|
68
69
|
|
|
@@ -403,6 +404,7 @@ export async function startServer(opts?: {
|
|
|
403
404
|
},
|
|
404
405
|
});
|
|
405
406
|
let stopped = false;
|
|
407
|
+
await initPromise;
|
|
406
408
|
console.log(`skillmux serving over HTTP on ${hostname}:${bunServer.port}`);
|
|
407
409
|
return {
|
|
408
410
|
port: bunServer.port,
|
package/src/sync.ts
CHANGED
|
@@ -129,18 +129,18 @@ export function restoreMonolith(targetDir: string, vaultPath: string): RestoreMo
|
|
|
129
129
|
return { restored: true };
|
|
130
130
|
}
|
|
131
131
|
|
|
132
|
-
export function resolveProjectPinDir(targetDir: string,
|
|
132
|
+
export function resolveProjectPinDir(targetDir: string, path: string): string {
|
|
133
133
|
const rel = relative(homedir(), targetDir);
|
|
134
134
|
if (rel === "" || rel.startsWith("..")) {
|
|
135
135
|
throw new Error(
|
|
136
136
|
`target dir "${targetDir}" must be inside $HOME to compute a project pin dir (got relative path "${rel}")`,
|
|
137
137
|
);
|
|
138
138
|
}
|
|
139
|
-
return join(
|
|
139
|
+
return join(path, rel);
|
|
140
140
|
}
|
|
141
141
|
|
|
142
142
|
export interface ProjectGroupInput {
|
|
143
|
-
|
|
143
|
+
paths: string[];
|
|
144
144
|
skills: string[];
|
|
145
145
|
}
|
|
146
146
|
|
|
@@ -154,7 +154,7 @@ export interface SyncProjectTargetsParams {
|
|
|
154
154
|
|
|
155
155
|
export interface ProjectPinSyncResult extends SyncTargetResult {
|
|
156
156
|
group: string;
|
|
157
|
-
|
|
157
|
+
path: string;
|
|
158
158
|
pinDir: string;
|
|
159
159
|
}
|
|
160
160
|
|
|
@@ -166,14 +166,14 @@ export function syncProjectTargets(
|
|
|
166
166
|
const results: ProjectPinSyncResult[] = [];
|
|
167
167
|
|
|
168
168
|
for (const [group, projectGroup] of Object.entries(projectGroups)) {
|
|
169
|
-
for (const
|
|
170
|
-
if (!existsSync(
|
|
171
|
-
const pinDir = resolveProjectPinDir(targetDir,
|
|
169
|
+
for (const path of projectGroup.paths) {
|
|
170
|
+
if (!existsSync(path)) continue;
|
|
171
|
+
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
172
172
|
const result = syncTarget(
|
|
173
173
|
{ vaultPath, targetDir: pinDir, targetName, coreSkillIds: projectGroup.skills, localVaultPaths },
|
|
174
174
|
options,
|
|
175
175
|
);
|
|
176
|
-
results.push({ group,
|
|
176
|
+
results.push({ group, path, pinDir, ...result });
|
|
177
177
|
}
|
|
178
178
|
}
|
|
179
179
|
|