@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 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 `--repo <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.
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 repo and machine-local overlay vaults via `local_vault_paths`, is in [`docs/configuration.md`](docs/configuration.md#tiers-and-the-manifest).
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
 
@@ -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
- repos = ["/Users/you/code/repo1"] # only synced for repos paths that exist locally
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 `<repo>/<relative path from $HOME to the target dir>`, for each `repos` entry, and only for targets whose `project_groups` names that group. `repos` 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.
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 --repo ~/code/repo2 # create a new group
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
- `--repo` 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 `repos` isn't this command's job. 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
+ `--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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "0.4.3",
3
+ "version": "0.4.5",
4
4
  "description": "Local read-only MCP server routing natural-language task queries to skills in a SKILL.md vault, with zero-loss delivery",
5
5
  "type": "module",
6
6
  "private": false,
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> [--repo <path>...])";
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; repos: 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 repos: string[] = [];
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 === "--repo") {
593
+ } else if (arg === "--path") {
590
594
  const value = args[++i];
591
- if (!value) throw new Error("--repo requires a path");
592
- repos.push(value);
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, repos };
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, repos } = parseManifestPinArgs(args);
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!, repos)
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
- repos: z.array(z.string().min(1)),
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}]\nrepos = ${tomlStringArray(group.repos)}\nskills = ${tomlStringArray(group.skills)}`,
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, repos?: string[]): Manifest {
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 (!repos || repos.length === 0) {
117
- throw new Error(`group "${group}" does not exist — pass --repo <path> at least once to create it`);
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]: { repos, skills: [skillId] } },
134
+ project: { ...manifest.project, [group]: { paths, skills: [skillId] } },
126
135
  };
127
136
  }
128
137
 
129
- if (repos && repos.length > 0) {
130
- throw new Error(`group "${group}" already exists — --repo is only used when creating a new group`);
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 repo of group.repos) {
212
- if (!existsSync(expandHome(repo))) {
213
- notes.push(`[project.${groupName}] repos path not found locally, skipped: ${repo}`);
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, repo: string): 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(repo, rel);
139
+ return join(path, rel);
140
140
  }
141
141
 
142
142
  export interface ProjectGroupInput {
143
- repos: string[];
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
- repo: string;
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 repo of projectGroup.repos) {
170
- if (!existsSync(repo)) continue;
171
- const pinDir = resolveProjectPinDir(targetDir, repo);
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, repo, pinDir, ...result });
176
+ results.push({ group, path, pinDir, ...result });
177
177
  }
178
178
  }
179
179