@klhapp/skillmux 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -5,6 +5,31 @@ 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.6.0](https://github.com/klhq/skillmux/compare/v0.5.0...v0.6.0) (2026-07-24)
9
+
10
+
11
+ ### Added
12
+
13
+ * **cli:** add line-oriented selection prompts ([b8579a4](https://github.com/klhq/skillmux/commit/b8579a47513a722c76d8826b80ca08155f2229f6))
14
+ * **init:** add guided client and project setup ([c426502](https://github.com/klhq/skillmux/commit/c426502693755185a7d83f3c4b8c2b6b502bbb90))
15
+ * **init:** detect installed clients for setup ([0c529f3](https://github.com/klhq/skillmux/commit/0c529f310de8bc96d18ddb6b25174d375478d9fa))
16
+ * **init:** launch guided client setup in terminals ([9bd15ac](https://github.com/klhq/skillmux/commit/9bd15acfbad088732ad6d92927f0e16a2f57f44d))
17
+ * **project:** add direct project management commands ([132e8af](https://github.com/klhq/skillmux/commit/132e8af0124ee75163161df16af83dc774b773a8))
18
+ * **project:** add direct project management commands ([f36e2b3](https://github.com/klhq/skillmux/commit/f36e2b356f5f170c887734367cebb838acacc2e4))
19
+ * **project:** add project init command ([3cf8ecc](https://github.com/klhq/skillmux/commit/3cf8ecc94fb16f0f8ed807976e36935e09918a4e))
20
+ * **project:** harden project setup planning ([faff0e1](https://github.com/klhq/skillmux/commit/faff0e14a6d0a6268da3e01a33553efd9ede7d58))
21
+ * **project:** upsert project setup state ([7a92e03](https://github.com/klhq/skillmux/commit/7a92e03e281b517dff45d11eef5714d0512fd952))
22
+ * **setup:** guide machine and project choices ([2ef1010](https://github.com/klhq/skillmux/commit/2ef1010e8cb0d42b20a6f60980e7be0e28bf1e5f))
23
+ * **target:** add advanced target management commands ([bde2069](https://github.com/klhq/skillmux/commit/bde206981bcb93c0b1be317ef7f1c8d6f6ece34c))
24
+ * **target:** add advanced target management commands ([9e6a993](https://github.com/klhq/skillmux/commit/9e6a993643865b2134be05ba7306c671023f38ff))
25
+
26
+
27
+ ### Fixed
28
+
29
+ * **cli:** keep guided init interactive and legacy-safe ([f6ae405](https://github.com/klhq/skillmux/commit/f6ae40564ed26b19ac464847b455d840d78daa93))
30
+ * **project:** validate guided project inputs ([0bed954](https://github.com/klhq/skillmux/commit/0bed9543897e83860038d83620a7c3638540ca95))
31
+ * **setup:** report client target prerequisites ([62fd8b8](https://github.com/klhq/skillmux/commit/62fd8b80444a37c8bcde403be95d8224b9776b0b))
32
+
8
33
  ## [0.5.0](https://github.com/klhq/skillmux/compare/v0.4.5...v0.5.0) (2026-07-24)
9
34
 
10
35
 
package/README.md CHANGED
@@ -186,6 +186,11 @@ bun run src/cli.ts serve
186
186
 
187
187
  Optional — skip this if `resolve_skill` alone is enough (most setups). Use it once you want a small set of skills loaded *statically* in every agent that reads from a given directory, instead of routed on demand — see [Tiers](#tiers-routed-vs-pinned).
188
188
 
189
+ Run `skillmux init` with no arguments in a terminal for guided setup. It checks
190
+ the vault, preselects clients with concrete filesystem evidence, asks for core
191
+ skills, shows one review, and applies after one confirmation. The wizard uses
192
+ line-oriented prompts and does not take over the terminal screen.
193
+
189
194
  ### 1. Check the plan
190
195
 
191
196
  ```sh
@@ -213,10 +218,13 @@ skillmux init --client claude-code --client codex --core csv-formatter --yes
213
218
  ```
214
219
 
215
220
  Skillmux writes `skillmux.toml`, adds host-scoped targets, adopts each selected
216
- directory, and installs one managed discovery block in each client's durable
217
- instruction file. Existing manifest entries and instruction text stay intact.
218
- Without `--yes`, an interactive terminal asks about each change; a pipe or CI
219
- job must pass `--yes`.
221
+ directory, and installs one managed discovery block for each client with a
222
+ known safe durable instruction file. Other clients report manual setup.
223
+ Existing manifest entries and instruction text stay intact.
224
+ Explicit flags keep the command deterministic. Without `--yes`, an interactive
225
+ terminal reviews the plan before applying; a pipe or CI job must pass `--yes`.
226
+ Use `--no-instructions` to leave instruction files untouched or `--no-sync` to
227
+ save setup for a later `skillmux sync`.
220
228
 
221
229
  Use direct targets when you want paths rather than clients:
222
230
 
@@ -244,7 +252,29 @@ skillmux init --client claude-code --migrate-full-vault \
244
252
  After conversion and `skillmux sync`, the client sees the selected core pins
245
253
  instead of every vault skill.
246
254
 
247
- ### 3. Add or remove pins later
255
+ ### 3. Set up project-scoped skills
256
+
257
+ From a project directory:
258
+
259
+ ```sh
260
+ skillmux project init
261
+ ```
262
+
263
+ The guided flow detects the Git root, suggests a project-group name, asks which
264
+ configured clients should receive the group, and accepts project skill IDs.
265
+ For automation:
266
+
267
+ ```sh
268
+ skillmux project init ~/code/skillmux --name skillmux \
269
+ --client claude-code --client codex \
270
+ --skill sdd-tdd --skill code-context --yes
271
+ ```
272
+
273
+ Re-running the command adds missing paths, skills, and target attachments
274
+ without duplicating existing entries. Project setup runs `sync` unless
275
+ `--no-sync` is supplied.
276
+
277
+ ### 4. Add or remove pins later
248
278
 
249
279
  `--core` seeds only the skill IDs you name. Without it, `init` preserves
250
280
  existing core pins and makes no guesses. Add another pin with:
@@ -118,7 +118,7 @@ project_groups = ["repo1"] # which [project.*] groups materialize into
118
118
  - `[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.
119
119
  - `[targets.<name>]` — one entry per adopted surface. `skillmux init --target <name> --yes` writes these and scopes newly added targets to the current hostname. 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.
120
120
 
121
- **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:
121
+ **Pin/unpin without hand-editing.** `skillmux manifest pin`/`unpin` mutate `[core]`/`[project.*]` for you, 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:
122
122
 
123
123
  ```sh
124
124
  skillmux manifest pin csv-formatter --core # add to [core]
@@ -134,7 +134,7 @@ skillmux manifest unpin pdf-extractor --project repo1 # r
134
134
  >
135
135
  > **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).
136
136
 
137
- 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.
137
+ 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` with a distinct error, since the manifest is meant to be portable across machines and a machine-local override wouldn't exist elsewhere. `doctor` does not validate the manifest yet.
138
138
 
139
139
  ### Ownership marker
140
140
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@klhapp/skillmux",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
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
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Database } from "bun:sqlite";
3
- import { existsSync, mkdirSync, rmSync } from "node:fs";
3
+ import { existsSync, lstatSync, mkdirSync, rmSync } from "node:fs";
4
4
  import { hostname } from "node:os";
5
- import { join } from "node:path";
5
+ import { basename, join } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { generateDataset } from "./dataset-generator";
8
8
 
@@ -13,8 +13,10 @@ import { diagnose } from "./doctor";
13
13
  import { evalVault } from "./eval";
14
14
  import {
15
15
  assessClientReadiness,
16
+ detectInstalledClients,
16
17
  planClientSurfaces,
17
18
  resolveBuiltInTarget,
19
+ SUPPORTED_CLIENT_IDS,
18
20
  type ClientId,
19
21
  type ReadinessAxis,
20
22
  } from "./init-clients";
@@ -47,9 +49,15 @@ import {
47
49
  serializeManifest,
48
50
  unpinCore,
49
51
  unpinProject,
52
+ upsertProject,
53
+ updateProjectPaths,
54
+ updateProjectTargets,
50
55
  validateManifest,
56
+ writeManifestAtomic,
51
57
  } from "./manifest";
52
58
  import { downloadLocalModels } from "./models";
59
+ import { resolveProjectDirectory, suggestProjectName } from "./project-setup";
60
+ import { parseCommaList, promptMultiSelect, promptText, shouldUseWizard } from "./prompts";
53
61
  import { backfillEmbeddings, configure, rebuildIndex } from "./router-core";
54
62
  import { renderScanJson, renderScanText, scanExitCode, scanPath, type ScanSeverity } from "./scan";
55
63
  import {
@@ -91,6 +99,8 @@ const KNOWN_COMMANDS = [
91
99
  "index",
92
100
  "sync",
93
101
  "init",
102
+ "project",
103
+ "target",
94
104
  "report",
95
105
  "scan",
96
106
  "install",
@@ -191,6 +201,12 @@ async function main() {
191
201
  case "init":
192
202
  await runInit(rawArgv.slice(1), { isJson, dryRun: isDryRun });
193
203
  break;
204
+ case "project":
205
+ await runProject(subCommand, commandArgs, { isJson, dryRun: isDryRun });
206
+ break;
207
+ case "target":
208
+ await runTarget(subCommand, commandArgs, { isJson, dryRun: isDryRun });
209
+ break;
194
210
  case "report":
195
211
  await runReport(rawArgv.slice(1));
196
212
  break;
@@ -224,7 +240,7 @@ async function main() {
224
240
  const suggestion = suggestCorrection(command, KNOWN_COMMANDS);
225
241
  const msg = suggestion
226
242
  ? `Unknown command "${command}". Did you mean "${suggestion}"?`
227
- : `usage: skillmux <serve|index|sync|init|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
243
+ : `usage: skillmux <serve|index|sync|init|project|target|report|scan|install|eval|doctor|which|manifest pin/unpin|local-vault init|config show|models download|calibrate generate-dataset>`;
228
244
  throw new Error(msg);
229
245
  }
230
246
  }
@@ -603,7 +619,13 @@ Setup:
603
619
  skillmux config init --vault <path> --yes
604
620
  skillmux init [--client <name>...] [--target <name>...] [--path <dir>]
605
621
  [--vault <path>] [--core <skill_id>...]
606
- [--migrate-full-vault] [--yes|--dry-run] [--json]
622
+ [--migrate-full-vault] [--no-instructions] [--no-sync]
623
+ [--interactive|--yes|--dry-run] [--json]
624
+ skillmux project init [path] [--name <group>] [--skill <skill_id>...]
625
+ [--client <name>...] [--target <name>...] [--no-sync]
626
+ [--interactive|--yes|--dry-run] [--json]
627
+ skillmux project <list|show|add-path|remove-path|pin|unpin|attach|detach>
628
+ skillmux target <list|show|add|remove>
607
629
 
608
630
  Init clients:
609
631
  claude-code, codex, gemini-cli, opencode, github-copilot, windsurf,
@@ -613,7 +635,7 @@ Init targets:
613
635
  agent-skills, claude-code, codex, custom
614
636
 
615
637
  Commands:
616
- serve, index, sync, init, report, scan, install, eval, doctor, which,
638
+ serve, index, sync, init, project, target, report, scan, install, eval, doctor, which,
617
639
  manifest, local-vault, config, models, calibrate, context, completions`);
618
640
  }
619
641
 
@@ -713,6 +735,429 @@ async function runWhich(args: string[]): Promise<void> {
713
735
  }
714
736
 
715
737
  const MANIFEST_USAGE = "usage: skillmux manifest <pin|unpin> <skill_id> (--core | --project <group> [--path <path>...])";
738
+ const PROJECT_INIT_USAGE =
739
+ "usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
740
+
741
+ interface ProjectInitArgs {
742
+ path: string;
743
+ name: string;
744
+ skills: string[];
745
+ clients: string[];
746
+ targets: string[];
747
+ yes: boolean;
748
+ sync: boolean;
749
+ }
750
+
751
+ function configuredTargetForSurface(
752
+ manifest: ReturnType<typeof parseManifest>,
753
+ surface: { targetName: string; path: string },
754
+ ): string | undefined {
755
+ if (manifest.targets[surface.targetName]) return surface.targetName;
756
+ return Object.entries(manifest.targets).find(
757
+ ([, target]) => expandHome(target.dir) === surface.path,
758
+ )?.[0];
759
+ }
760
+
761
+ function configuredTargetsForClients(
762
+ manifest: ReturnType<typeof parseManifest>,
763
+ clients: readonly string[],
764
+ ): string[] {
765
+ return planClientSurfaces(clients).surfaces.map((surface) => {
766
+ const target = configuredTargetForSurface(manifest, surface);
767
+ if (target) return target;
768
+ const client = surface.clients[0]!;
769
+ throw new Error(
770
+ `client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
771
+ );
772
+ });
773
+ }
774
+
775
+ function parseProjectInitArgs(args: string[]): ProjectInitArgs {
776
+ let projectPath: string | undefined;
777
+ let name: string | undefined;
778
+ const skills: string[] = [];
779
+ const clients: string[] = [];
780
+ const targets: string[] = [];
781
+ let yes = false;
782
+ let sync = true;
783
+
784
+ for (let i = 0; i < args.length; i++) {
785
+ const arg = args[i]!;
786
+ if (arg === "--name") {
787
+ name = args[++i];
788
+ if (!name) throw new Error("--name requires a group name");
789
+ } else if (arg === "--skill") {
790
+ const skill = args[++i];
791
+ if (!skill) throw new Error("--skill requires a skill_id");
792
+ skills.push(skill);
793
+ } else if (arg === "--target") {
794
+ const target = args[++i];
795
+ if (!target) throw new Error("--target requires a name");
796
+ targets.push(target);
797
+ } else if (arg === "--client") {
798
+ const client = args[++i];
799
+ if (!client) throw new Error("--client requires a name");
800
+ clients.push(client);
801
+ } else if (arg === "--yes") {
802
+ yes = true;
803
+ } else if (arg === "--no-sync") {
804
+ sync = false;
805
+ } else if (arg === "--dry-run" || arg === "--json" || arg === "--interactive") {
806
+ continue;
807
+ } else if (arg.startsWith("-")) {
808
+ throw new Error(`unknown project init option: ${arg}`);
809
+ } else if (projectPath) {
810
+ throw new Error(PROJECT_INIT_USAGE);
811
+ } else {
812
+ projectPath = arg;
813
+ }
814
+ }
815
+
816
+ const path = resolveProjectDirectory(projectPath ? expandHome(projectPath) : undefined);
817
+ return { path, name: name ?? suggestProjectName(basename(path)), skills, clients, targets, yes, sync };
818
+ }
819
+
820
+ async function runProject(
821
+ subCommand: string,
822
+ args: string[],
823
+ options: { isJson: boolean; dryRun: boolean },
824
+ ): Promise<void> {
825
+ if (subCommand === "list" || subCommand === "show") {
826
+ const config = await loadConfig();
827
+ const vaultPath = expandHome(config.vault_path);
828
+ const manifestPath = resolveManifestPath(vaultPath);
829
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
830
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
831
+ const names = subCommand === "show"
832
+ ? [args[0] ?? ""]
833
+ : Object.keys(manifest.project ?? {});
834
+ if (subCommand === "show" && !manifest.project?.[names[0]!]) {
835
+ throw new Error(`[project.${names[0]}] does not exist`);
836
+ }
837
+ const projects = names.map((name) => ({
838
+ name,
839
+ paths: manifest.project?.[name]!.paths ?? [],
840
+ skills: manifest.project?.[name]!.skills ?? [],
841
+ targets: Object.entries(manifest.targets)
842
+ .filter(([, target]) => target.project_groups.includes(name))
843
+ .map(([target]) => target),
844
+ }));
845
+ if (options.isJson) {
846
+ console.log(JSON.stringify({ schema_version: 1, projects }));
847
+ } else if (projects.length === 0) {
848
+ console.log("no project groups configured");
849
+ } else {
850
+ for (const project of projects) {
851
+ console.log(`${project.name}:`);
852
+ console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
853
+ console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
854
+ console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
855
+ }
856
+ }
857
+ return;
858
+ }
859
+ if (subCommand === "add-path" || subCommand === "remove-path") {
860
+ const group = args[0];
861
+ if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> [path] --yes`);
862
+ const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
863
+ const projectPath = resolveProjectDirectory(rawPath ? expandHome(rawPath) : undefined);
864
+ const yes = args.includes("--yes");
865
+ if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
866
+ throw new Error(`project path is not a directory: ${projectPath}`);
867
+ }
868
+ const config = await loadConfig();
869
+ const vaultPath = expandHome(config.vault_path);
870
+ const manifestPath = resolveManifestPath(vaultPath);
871
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
872
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
873
+ const updated = updateProjectPaths(manifest, group, {
874
+ ...(subCommand === "add-path" ? { add: [projectPath] } : { remove: [projectPath] }),
875
+ });
876
+ validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
877
+ if (options.dryRun) {
878
+ console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
879
+ return;
880
+ }
881
+ if (!yes) {
882
+ if (!options.isJson && isInteractive()) {
883
+ if (!(await confirmAction(`${subCommand} ${projectPath} in [project.${group}]?`))) return;
884
+ } else {
885
+ throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
886
+ }
887
+ }
888
+ writeManifestAtomic(manifestPath, updated);
889
+ console.log(`${subCommand}: [project.${group}] ${projectPath}`);
890
+ return;
891
+ }
892
+ if (subCommand === "pin" || subCommand === "unpin") {
893
+ const group = args[0];
894
+ const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
895
+ if (!group || skills.length === 0) {
896
+ throw new Error(`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`);
897
+ }
898
+ const yes = args.includes("--yes");
899
+ const config = await loadConfig();
900
+ const vaultPath = expandHome(config.vault_path);
901
+ const manifestPath = resolveManifestPath(vaultPath);
902
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
903
+ let updated = parseManifest(await Bun.file(manifestPath).text());
904
+ for (const skill of skills) {
905
+ updated = subCommand === "pin"
906
+ ? pinProject(updated, skill, group)
907
+ : unpinProject(updated, skill, group);
908
+ }
909
+ validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
910
+ if (options.dryRun) {
911
+ console.log(`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`);
912
+ return;
913
+ }
914
+ if (!yes) {
915
+ if (!options.isJson && isInteractive()) {
916
+ if (!(await confirmAction(`${subCommand} ${skills.join(", ")} in [project.${group}]?`))) return;
917
+ } else {
918
+ throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
919
+ }
920
+ }
921
+ writeManifestAtomic(manifestPath, updated);
922
+ console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
923
+ return;
924
+ }
925
+ if (subCommand === "attach" || subCommand === "detach") {
926
+ const group = args[0];
927
+ if (!group) throw new Error(`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`);
928
+ const clients: string[] = [];
929
+ const requestedTargets: string[] = [];
930
+ for (let i = 1; i < args.length; i++) {
931
+ if (args[i] === "--client") {
932
+ const value = args[++i];
933
+ if (!value) throw new Error("--client requires a name");
934
+ clients.push(value);
935
+ } else if (args[i] === "--target") {
936
+ const value = args[++i];
937
+ if (!value) throw new Error("--target requires a name");
938
+ requestedTargets.push(value);
939
+ } else if (args[i] !== "--yes" && args[i] !== "--dry-run" && args[i] !== "--json") {
940
+ throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
941
+ }
942
+ }
943
+ const config = await loadConfig();
944
+ const vaultPath = expandHome(config.vault_path);
945
+ const manifestPath = resolveManifestPath(vaultPath);
946
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
947
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
948
+ const clientTargets = configuredTargetsForClients(manifest, clients);
949
+ const targets = [...new Set([...requestedTargets, ...clientTargets])];
950
+ if (targets.length === 0) {
951
+ throw new Error(`project ${subCommand} requires --client or --target`);
952
+ }
953
+ const updated = updateProjectTargets(manifest, group, {
954
+ ...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
955
+ });
956
+ validateManifest(updated, vaultPath, config.local_vault_paths.map(expandHome));
957
+ if (options.dryRun) {
958
+ console.log(`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`);
959
+ return;
960
+ }
961
+ if (!args.includes("--yes")) {
962
+ if (!options.isJson && isInteractive()) {
963
+ if (!(await confirmAction(`${subCommand} [project.${group}] to ${targets.join(", ")}?`))) return;
964
+ } else {
965
+ throw new Error(`skillmux project ${subCommand} requires --yes when run non-interactively`);
966
+ }
967
+ }
968
+ writeManifestAtomic(manifestPath, updated);
969
+ console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
970
+ return;
971
+ }
972
+ if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
973
+ let request = parseProjectInitArgs(args);
974
+ const guided = shouldUseWizard(args, {
975
+ interactive: isInteractive(),
976
+ json: options.isJson,
977
+ dryRun: options.dryRun,
978
+ });
979
+ if (!existsSync(request.path)) throw new Error(`project path does not exist: ${request.path}`);
980
+ if (!lstatSync(request.path).isDirectory()) {
981
+ throw new Error(`project path is not a directory: ${request.path}`);
982
+ }
983
+
984
+ const config = await loadConfig();
985
+ const vaultPath = expandHome(config.vault_path);
986
+ const localVaultPaths = config.local_vault_paths.map(expandHome);
987
+ const manifestPath = resolveManifestPath(vaultPath);
988
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
989
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
990
+ if (guided) {
991
+ const name = await promptText("Project group", request.name);
992
+ const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
993
+ const surface = planClientSurfaces([client]).surfaces[0];
994
+ return surface !== undefined && configuredTargetForSurface(manifest, surface) !== undefined;
995
+ });
996
+ const clients = await promptMultiSelect(
997
+ "Which clients should receive project skills?",
998
+ availableClients.map((client) => ({
999
+ value: client,
1000
+ label: client,
1001
+ selected: request.clients.length === 0 || request.clients.includes(client),
1002
+ })),
1003
+ );
1004
+ const skills = parseCommaList(
1005
+ await promptText("Project skill IDs, comma-separated", request.skills.join(",")),
1006
+ );
1007
+ request = { ...request, name, clients, skills };
1008
+ }
1009
+ const clientTargets = configuredTargetsForClients(manifest, request.clients);
1010
+ const targets = [...new Set([...request.targets, ...clientTargets])];
1011
+ const updated = upsertProject(manifest, {
1012
+ name: request.name,
1013
+ paths: [request.path],
1014
+ skills: request.skills,
1015
+ targets,
1016
+ });
1017
+ const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
1018
+ const plan = {
1019
+ mode: "project",
1020
+ project: request.name,
1021
+ path: request.path,
1022
+ skills: request.skills,
1023
+ clients: request.clients,
1024
+ targets,
1025
+ sync: request.sync,
1026
+ notes,
1027
+ };
1028
+
1029
+ if (options.dryRun) {
1030
+ console.log(options.isJson ? JSON.stringify({ schema_version: 1, plan }) : `project plan: ${JSON.stringify(plan)}`);
1031
+ return;
1032
+ }
1033
+ if (!request.yes) {
1034
+ if (!options.isJson && isInteractive()) {
1035
+ if (guided) {
1036
+ console.log("\nReview");
1037
+ console.log(` project: ${request.name}`);
1038
+ console.log(` path: ${request.path}`);
1039
+ console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
1040
+ console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
1041
+ console.log(` sync: ${request.sync ? "yes" : "no"}`);
1042
+ }
1043
+ if (!(await confirmAction(`Apply project setup for ${request.name} at ${request.path}?`))) {
1044
+ console.log("project setup cancelled");
1045
+ return;
1046
+ }
1047
+ } else {
1048
+ throw new Error("skillmux project init requires --yes when run non-interactively");
1049
+ }
1050
+ }
1051
+
1052
+ writeManifestAtomic(manifestPath, updated);
1053
+ if (request.sync) {
1054
+ try {
1055
+ await runSync([]);
1056
+ } catch (error) {
1057
+ throw new Error(
1058
+ `project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
1059
+ error instanceof Error ? error.message : String(error)
1060
+ }`,
1061
+ );
1062
+ }
1063
+ }
1064
+ if (options.isJson) {
1065
+ console.log(JSON.stringify({ schema_version: 1, result: plan }));
1066
+ } else {
1067
+ console.log(`project "${request.name}" ready at ${request.path}`);
1068
+ }
1069
+ }
1070
+
1071
+ async function runTarget(
1072
+ subCommand: string,
1073
+ args: string[],
1074
+ options: { isJson: boolean; dryRun: boolean },
1075
+ ): Promise<void> {
1076
+ const config = await loadConfig();
1077
+ const vaultPath = expandHome(config.vault_path);
1078
+ const manifestPath = resolveManifestPath(vaultPath);
1079
+ if (!manifestPath) throw new Error(`no skillmux.toml found at ${vaultPath}; run skillmux init first`);
1080
+ const manifest = parseManifest(await Bun.file(manifestPath).text());
1081
+
1082
+ if (subCommand === "list" || subCommand === "show") {
1083
+ const names = subCommand === "show" ? [args[0] ?? ""] : Object.keys(manifest.targets);
1084
+ if (subCommand === "show" && !manifest.targets[names[0]!]) {
1085
+ throw new Error(`target "${names[0]}" does not exist`);
1086
+ }
1087
+ const targets = names.map((name) => {
1088
+ const target = manifest.targets[name]!;
1089
+ const clients = SUPPORTED_CLIENT_IDS.filter((client) => {
1090
+ const surface = planClientSurfaces([client]).surfaces[0];
1091
+ return surface !== undefined && surface.path === expandHome(target.dir);
1092
+ });
1093
+ return { name, ...target, clients };
1094
+ });
1095
+ if (options.isJson) {
1096
+ console.log(JSON.stringify({ schema_version: 1, targets }));
1097
+ } else if (targets.length === 0) {
1098
+ console.log("no targets configured");
1099
+ } else {
1100
+ for (const target of targets) {
1101
+ console.log(`${target.name}:`);
1102
+ console.log(` dir: ${target.dir}`);
1103
+ console.log(` host: ${target.host ?? "(global)"}`);
1104
+ console.log(` clients: ${target.clients.join(", ") || "(custom)"}`);
1105
+ console.log(` projects: ${target.project_groups.join(", ") || "(none)"}`);
1106
+ }
1107
+ }
1108
+ return;
1109
+ }
1110
+
1111
+ if (subCommand === "add") {
1112
+ const name = args[0];
1113
+ const pathIndex = args.indexOf("--path");
1114
+ const rawPath = pathIndex === -1 ? undefined : args[pathIndex + 1];
1115
+ if (!name || !rawPath) throw new Error("usage: skillmux target add <name> --path <dir> --yes");
1116
+ const path = expandHome(rawPath);
1117
+ if (options.dryRun) {
1118
+ const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
1119
+ console.log(options.isJson
1120
+ ? JSON.stringify({ schema_version: 1, target: planned.targets[name] })
1121
+ : `target add: ${name} -> ${path} (dry-run)`);
1122
+ return;
1123
+ }
1124
+ if (!args.includes("--yes")) {
1125
+ if (!options.isJson && isInteractive()) {
1126
+ if (!(await confirmAction(`Adopt target ${name} at ${path}?`))) return;
1127
+ } else {
1128
+ throw new Error("skillmux target add requires --yes when run non-interactively");
1129
+ }
1130
+ }
1131
+ applyInit(vaultPath, [{ name, dir: path }]);
1132
+ console.log(`target "${name}" added at ${path}`);
1133
+ return;
1134
+ }
1135
+
1136
+ if (subCommand === "remove") {
1137
+ const name = args[0];
1138
+ if (!name || !manifest.targets[name]) {
1139
+ throw new Error(name ? `target "${name}" does not exist` : "usage: skillmux target remove <name> --yes");
1140
+ }
1141
+ if (options.dryRun) {
1142
+ console.log(`target remove: ${name} (files preserved, dry-run)`);
1143
+ return;
1144
+ }
1145
+ if (!args.includes("--yes")) {
1146
+ if (!options.isJson && isInteractive()) {
1147
+ if (!(await confirmAction(`Remove target ${name} from the manifest and preserve its files?`))) return;
1148
+ } else {
1149
+ throw new Error("skillmux target remove requires --yes when run non-interactively");
1150
+ }
1151
+ }
1152
+ const targets = { ...manifest.targets };
1153
+ delete targets[name];
1154
+ writeManifestAtomic(manifestPath, { ...manifest, targets });
1155
+ console.log(`target "${name}" removed from the manifest; files preserved at ${manifest.targets[name]!.dir}`);
1156
+ return;
1157
+ }
1158
+
1159
+ throw new Error("usage: skillmux target <list|show|add|remove>");
1160
+ }
716
1161
 
717
1162
  function parseManifestPinArgs(args: string[]): { skillId: string; core: boolean; project?: string; paths: string[] } {
718
1163
  const skillId = args[0];
@@ -873,6 +1318,8 @@ function parseInitArgs(args: string[]): {
873
1318
  coreSkillIds: string[];
874
1319
  customPath?: string;
875
1320
  migrateFullVault: boolean;
1321
+ skipInstructions: boolean;
1322
+ sync: boolean;
876
1323
  vaultPath?: string;
877
1324
  yes: boolean;
878
1325
  } {
@@ -881,6 +1328,8 @@ function parseInitArgs(args: string[]): {
881
1328
  const coreSkillIds: string[] = [];
882
1329
  let customPath: string | undefined;
883
1330
  let migrateFullVault = false;
1331
+ let skipInstructions = false;
1332
+ let sync = true;
884
1333
  let vaultPath: string | undefined;
885
1334
  let yes = false;
886
1335
  for (let i = 0; i < args.length; i++) {
@@ -910,17 +1359,31 @@ function parseInitArgs(args: string[]): {
910
1359
  if (!value) throw new Error("--core requires a skill_id");
911
1360
  coreSkillIds.push(value);
912
1361
  i++;
913
- } else if (option === "--dry-run" || option === "--json") {
1362
+ } else if (option === "--dry-run" || option === "--json" || option === "--interactive") {
914
1363
  continue;
915
1364
  } else if (option === "--migrate-full-vault") {
916
1365
  migrateFullVault = true;
1366
+ } else if (option === "--no-instructions") {
1367
+ skipInstructions = true;
1368
+ } else if (option === "--no-sync") {
1369
+ sync = false;
917
1370
  } else if (option === "--yes") {
918
1371
  yes = true;
919
1372
  } else {
920
1373
  throw new Error(`unknown init option: ${option}`);
921
1374
  }
922
1375
  }
923
- return { targets, clients, coreSkillIds, customPath, migrateFullVault, vaultPath, yes };
1376
+ return {
1377
+ targets,
1378
+ clients,
1379
+ coreSkillIds,
1380
+ customPath,
1381
+ migrateFullVault,
1382
+ skipInstructions,
1383
+ sync,
1384
+ vaultPath,
1385
+ yes,
1386
+ };
924
1387
  }
925
1388
 
926
1389
  async function runInit(
@@ -933,9 +1396,16 @@ async function runInit(
933
1396
  coreSkillIds,
934
1397
  customPath,
935
1398
  migrateFullVault,
1399
+ skipInstructions,
1400
+ sync,
936
1401
  vaultPath: requestedVaultPath,
937
1402
  yes,
938
1403
  } = parseInitArgs(args);
1404
+ const guided = shouldUseWizard(args, {
1405
+ interactive: isInteractive(),
1406
+ json: options.isJson,
1407
+ dryRun: options.dryRun,
1408
+ });
939
1409
  migrateLegacyPaths();
940
1410
  const configPath = resolveConfigPath();
941
1411
  let configPlan: ConfigInitPlan | undefined;
@@ -966,10 +1436,33 @@ async function runInit(
966
1436
  throw new Error(vaultHealth.message);
967
1437
  }
968
1438
 
969
- const clientPlan = planClientSurfaces(requestedClients, {
1439
+ let selectedClients = requestedClients;
1440
+ if (guided) {
1441
+ const detected = detectInstalledClients({
1442
+ codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
1443
+ });
1444
+ const evidence = new Map(detected.map((item) => [item.client, item.evidence]));
1445
+ selectedClients = await promptMultiSelect(
1446
+ "Which clients do you use?",
1447
+ SUPPORTED_CLIENT_IDS.map((client) => ({
1448
+ value: client,
1449
+ label: client,
1450
+ detail: evidence.has(client) ? `detected: ${evidence.get(client)}` : undefined,
1451
+ selected: evidence.has(client) || requestedClients.includes(client),
1452
+ })),
1453
+ );
1454
+ }
1455
+ let selectedCoreSkillIds = coreSkillIds;
1456
+ if (guided) {
1457
+ selectedCoreSkillIds = parseCommaList(
1458
+ await promptText("Core skill IDs to add, comma-separated", coreSkillIds.join(",")),
1459
+ );
1460
+ }
1461
+
1462
+ const clientPlan = planClientSurfaces(selectedClients, {
970
1463
  codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
971
1464
  });
972
- const instructionPlan = planInstructionSetup(clientPlan.clients.map((client) => client.id), {
1465
+ const instructionPlan = planInstructionSetup(skipInstructions ? [] : clientPlan.clients.map((client) => client.id), {
973
1466
  codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
974
1467
  });
975
1468
  const instructionReadiness: Partial<Record<ClientId, ReadinessAxis>> = {};
@@ -1002,8 +1495,19 @@ async function runInit(
1002
1495
  const targetByPath = new Map(
1003
1496
  explicitSurfaceTargets.map((target) => [target.path, target.targetName] as const),
1004
1497
  );
1498
+ const existingManifestPath = resolveManifestPath(vaultPath);
1499
+ const existingManifest = existingManifestPath
1500
+ ? parseManifest(await Bun.file(existingManifestPath).text())
1501
+ : undefined;
1005
1502
  for (const surface of clientPlan.surfaces) {
1006
- if (!targetByPath.has(surface.path)) targetByPath.set(surface.path, surface.targetName);
1503
+ if (!targetByPath.has(surface.path)) {
1504
+ targetByPath.set(
1505
+ surface.path,
1506
+ existingManifest
1507
+ ? configuredTargetForSurface(existingManifest, surface) ?? surface.targetName
1508
+ : surface.targetName,
1509
+ );
1510
+ }
1007
1511
  }
1008
1512
  const candidatePaths = [
1009
1513
  ...new Set([
@@ -1068,7 +1572,7 @@ async function runInit(
1068
1572
  const hasChanges = !(
1069
1573
  requestedTargets.length === 0 &&
1070
1574
  !hasInstructionWrites &&
1071
- coreSkillIds.length === 0 &&
1575
+ selectedCoreSkillIds.length === 0 &&
1072
1576
  !hasConfigWrite
1073
1577
  );
1074
1578
 
@@ -1108,7 +1612,7 @@ async function runInit(
1108
1612
  ...(candidate.state === "full-vault" ? { migrateFullVault: true } : {}),
1109
1613
  };
1110
1614
  });
1111
- const plannedManifest = planInitManifest(vaultPath, confirmedTargets, coreSkillIds);
1615
+ const plannedManifest = planInitManifest(vaultPath, confirmedTargets, selectedCoreSkillIds);
1112
1616
  const serializedPlan = {
1113
1617
  vault_path: vaultPath,
1114
1618
  config: configPlan
@@ -1171,19 +1675,38 @@ async function runInit(
1171
1675
 
1172
1676
  if (!yes) {
1173
1677
  if (!options.isJson && isInteractive()) {
1174
- const prompts = [
1175
- ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
1176
- ...instructionPlan.changes
1177
- .filter((change) => change.status !== "unchanged")
1178
- .map((change) => `${change.status} instruction file ${change.path}?`),
1179
- ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1180
- ...(coreSkillIds.length > 0 ? [`Pin core skills: ${coreSkillIds.join(", ")}?`] : []),
1181
- ];
1182
- for (const prompt of prompts) {
1183
- if (!(await confirmAction(prompt))) {
1184
- console.log("init cancelled; nothing written");
1678
+ if (guided) {
1679
+ console.log("\nReview");
1680
+ console.log(` clients: ${selectedClients.join(", ") || "(none)"}`);
1681
+ console.log(
1682
+ ` targets: ${confirmedTargets.map((target) => `${target.name} -> ${target.dir}`).join(", ") || "(none)"}`,
1683
+ );
1684
+ console.log(
1685
+ ` instructions: ${instructionPlan.changes.filter((change) => change.status !== "unchanged").length} file(s)`,
1686
+ );
1687
+ console.log(` core: ${plannedManifest.core.skills.join(", ") || "(none)"}`);
1688
+ console.log(` sync: ${sync ? "yes" : "no"}`);
1689
+ if (!(await confirmAction("Apply this setup plan?"))) {
1690
+ console.log("init cancelled");
1185
1691
  return;
1186
1692
  }
1693
+ } else {
1694
+ const prompts = [
1695
+ ...confirmedTargets.map((target) => `Adopt ${target.name} at ${target.dir}?`),
1696
+ ...instructionPlan.changes
1697
+ .filter((change) => change.status !== "unchanged")
1698
+ .map((change) => `${change.status} instruction file ${change.path}?`),
1699
+ ...(hasConfigWrite ? [`Create machine config ${configPath}?`] : []),
1700
+ ...(selectedCoreSkillIds.length > 0
1701
+ ? [`Pin core skills: ${selectedCoreSkillIds.join(", ")}?`]
1702
+ : []),
1703
+ ];
1704
+ for (const prompt of prompts) {
1705
+ if (!(await confirmAction(prompt))) {
1706
+ console.log("init cancelled; nothing written");
1707
+ return;
1708
+ }
1709
+ }
1187
1710
  }
1188
1711
  } else {
1189
1712
  throw new Error(
@@ -1214,7 +1737,7 @@ async function runInit(
1214
1737
  if (configCreated && configPlan) rollbackConfigInit(configPlan);
1215
1738
  };
1216
1739
 
1217
- if (confirmedTargets.length === 0 && coreSkillIds.length === 0) {
1740
+ if (confirmedTargets.length === 0 && selectedCoreSkillIds.length === 0) {
1218
1741
  applyAdditional();
1219
1742
  } else {
1220
1743
  applyInit(
@@ -1226,7 +1749,7 @@ async function runInit(
1226
1749
  rollback: rollbackAdditional,
1227
1750
  }
1228
1751
  : undefined,
1229
- coreSkillIds,
1752
+ selectedCoreSkillIds,
1230
1753
  );
1231
1754
  }
1232
1755
 
@@ -1253,16 +1776,17 @@ async function runInit(
1253
1776
  if (configCreated) console.log(`created ${configPath}`);
1254
1777
  if (confirmedTargets.length > 0) {
1255
1778
  console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}, adopted: ${confirmedTargets.map((t) => t.name).join(", ")}`);
1256
- } else if (coreSkillIds.length > 0) {
1779
+ } else if (selectedCoreSkillIds.length > 0) {
1257
1780
  console.log(`\nwrote ${join(vaultPath, "skillmux.toml")}`);
1258
1781
  }
1259
1782
  if (plannedManifest.core.skills.length === 0 && confirmedTargets.length > 0) {
1260
1783
  console.log("next: skillmux manifest pin <skill_id> --core");
1261
1784
  }
1262
1785
  if (confirmedTargets.length > 0) console.log("next: skillmux sync");
1263
- if (requestedClients.length === 0 || requestedClients.includes("skillmux-mcp")) {
1786
+ if (selectedClients.length === 0 || selectedClients.includes("skillmux-mcp")) {
1264
1787
  console.log(`\n${printLastMile()}`);
1265
1788
  }
1789
+ if (guided && sync && confirmedTargets.length > 0) await runSync([]);
1266
1790
  }
1267
1791
 
1268
1792
  function parseReportArgs(args: string[]): { server?: string; db?: string; since?: string } {
@@ -8,7 +8,7 @@ _skillmux_completions() {
8
8
  COMPREPLY=()
9
9
  cur="\${COMP_WORDS[COMP_CWORD]}"
10
10
  prev="\${COMP_WORDS[COMP_CWORD-1]}"
11
- opts="context config calibrate serve index sync init report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
11
+ opts="context config calibrate serve index sync init project target report scan install eval doctor which manifest local-vault models completions --context --server --json --allow-insecure --verbose --dry-run --help"
12
12
 
13
13
  if [ "$COMP_CWORD" -eq 1 ]; then
14
14
  COMPREPLY=( $(compgen -W "$opts" -- "$cur") )
@@ -31,6 +31,12 @@ _skillmux_completions() {
31
31
  manifest)
32
32
  COMPREPLY=( $(compgen -W "pin unpin" -- "$cur") )
33
33
  ;;
34
+ project)
35
+ COMPREPLY=( $(compgen -W "init list show add-path remove-path pin unpin attach detach" -- "$cur") )
36
+ ;;
37
+ target)
38
+ COMPREPLY=( $(compgen -W "list show add remove" -- "$cur") )
39
+ ;;
34
40
  local-vault)
35
41
  COMPREPLY=( $(compgen -W "init" -- "$cur") )
36
42
  ;;
@@ -42,7 +48,10 @@ _skillmux_completions() {
42
48
  ;;
43
49
  esac
44
50
  if [ "\${COMP_WORDS[1]}" = "init" ] && [ "\${#COMPREPLY[@]}" -eq 0 ]; then
45
- COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --yes --dry-run --json" -- "$cur") )
51
+ COMPREPLY=( $(compgen -W "--client --target --path --vault --core --migrate-full-vault --no-instructions --no-sync --interactive --yes --dry-run --json" -- "$cur") )
52
+ fi
53
+ if [ "\${COMP_WORDS[1]}" = "project" ] && [ "\${COMP_WORDS[2]}" = "init" ]; then
54
+ COMPREPLY=( $(compgen -W "--name --skill --client --target --no-sync --interactive --yes --dry-run --json" -- "$cur") )
46
55
  fi
47
56
  }
48
57
  complete -F _skillmux_completions skillmux
@@ -61,6 +70,8 @@ _skillmux() {
61
70
  'index:Rebuild local search index'
62
71
  'sync:Synchronize vault skills'
63
72
  'init:Initialize project targets'
73
+ 'project:Configure project-scoped skills'
74
+ 'target:Manage advanced skill-delivery targets'
64
75
  'report:Generate usage stats'
65
76
  'scan:Audit skills for issues'
66
77
  'install:Install skills into vault'
@@ -82,9 +93,28 @@ _skillmux() {
82
93
  '--vault[vault directory]:directory:_directories' \
83
94
  '*--core[seed a core skill]:skill id:' \
84
95
  '--migrate-full-vault[convert a full-vault symlink to managed pins]' \
96
+ '--no-instructions[skip managed instruction files]' \
97
+ '--no-sync[save setup without synchronizing targets]' \
98
+ '--interactive[force guided setup]' \
99
+ '--yes[apply without prompts]' \
100
+ '--dry-run[print the plan without writing]' \
101
+ '--json[emit a JSON envelope]'
102
+ elif [[ "$words[2]" == "project" && "$words[3]" == "init" ]]; then
103
+ _arguments \
104
+ '1:project directory:_directories' \
105
+ '--name[project group name]:group:' \
106
+ '*--skill[project skill]:skill id:' \
107
+ '*--client[select a client]:client:(claude-code codex gemini-cli opencode github-copilot windsurf antigravity)' \
108
+ '*--target[select an advanced target]:target:' \
109
+ '--no-sync[save setup without synchronizing targets]' \
110
+ '--interactive[force guided setup]' \
85
111
  '--yes[apply without prompts]' \
86
112
  '--dry-run[print the plan without writing]' \
87
113
  '--json[emit a JSON envelope]'
114
+ elif [[ "$words[2]" == "project" && CURRENT == 3 ]]; then
115
+ _values 'project command' init list show add-path remove-path pin unpin attach detach
116
+ elif [[ "$words[2]" == "target" && CURRENT == 3 ]]; then
117
+ _values 'target command' list show add remove
88
118
  fi
89
119
  }
90
120
  _skillmux "$@"
@@ -98,6 +128,9 @@ complete -c skillmux -n "__fish_use_subcommand" -a context -d "Manage connection
98
128
  complete -c skillmux -n "__fish_use_subcommand" -a config -d "Manage configuration"
99
129
  complete -c skillmux -n "__fish_use_subcommand" -a calibrate -d "Manage policy calibration"
100
130
  complete -c skillmux -n "__fish_use_subcommand" -a serve -d "Start MCP server"
131
+ complete -c skillmux -n "__fish_use_subcommand" -a init -d "Configure this machine and its clients"
132
+ complete -c skillmux -n "__fish_use_subcommand" -a project -d "Configure project-scoped skills"
133
+ complete -c skillmux -n "__fish_use_subcommand" -a target -d "Manage advanced skill-delivery targets"
101
134
  complete -c skillmux -n "__fish_use_subcommand" -a completions -d "Generate shell completions"
102
135
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity goose hermes skillmux-mcp" -d "Select a client"
103
136
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l target -x -a "agent-skills claude-code codex custom" -d "Select a target"
@@ -105,9 +138,21 @@ complete -c skillmux -n "__fish_seen_subcommand_from init" -l path -r -d "Custom
105
138
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l vault -r -d "Vault directory"
106
139
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l core -x -d "Seed a core skill"
107
140
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l migrate-full-vault -d "Convert a full-vault symlink"
141
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-instructions -d "Skip managed instruction files"
142
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l no-sync -d "Save without synchronizing"
143
+ complete -c skillmux -n "__fish_seen_subcommand_from init" -l interactive -d "Force guided setup"
108
144
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l yes -d "Apply without prompts"
109
145
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l dry-run -d "Print the plan without writing"
110
146
  complete -c skillmux -n "__fish_seen_subcommand_from init" -l json -d "Emit a JSON envelope"
147
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -a "init list show add-path remove-path pin unpin attach detach" -d "Manage projects"
148
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l name -x -d "Project group name"
149
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l skill -x -d "Project skill"
150
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l client -x -a "claude-code codex gemini-cli opencode github-copilot windsurf antigravity" -d "Select a client"
151
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l target -x -d "Select an advanced target"
152
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l no-sync -d "Save without synchronizing"
153
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l interactive -d "Force guided setup"
154
+ complete -c skillmux -n "__fish_seen_subcommand_from project" -l yes -d "Apply without prompts"
155
+ complete -c skillmux -n "__fish_seen_subcommand_from target" -a "list show add remove" -d "Manage targets"
111
156
  `;
112
157
  }
113
158
 
@@ -1,3 +1,4 @@
1
+ import { existsSync } from "node:fs";
1
2
  import { homedir } from "node:os";
2
3
  import { join } from "node:path";
3
4
 
@@ -17,6 +18,11 @@ export const SUPPORTED_CLIENT_IDS = [
17
18
  export type ClientId = (typeof SUPPORTED_CLIENT_IDS)[number];
18
19
  export type DeliveryMode = "managed-pins" | "full-vault" | "mcp";
19
20
 
21
+ export interface DetectedClient {
22
+ client: ClientId;
23
+ evidence: string;
24
+ }
25
+
20
26
  interface ClientDefinition {
21
27
  id: ClientId;
22
28
  surfaceId?: "agent-skills" | "claude-code" | "codex" | "antigravity";
@@ -69,6 +75,31 @@ const CLIENTS: Record<ClientId, ClientDefinition> = {
69
75
  "skillmux-mcp": { id: "skillmux-mcp", deliveryMode: "mcp" },
70
76
  };
71
77
 
78
+ export function detectInstalledClients(
79
+ options: {
80
+ home?: string;
81
+ codexHome?: string;
82
+ exists?: (path: string) => boolean;
83
+ } = {},
84
+ ): DetectedClient[] {
85
+ const home = options.home ?? homedir();
86
+ const codexHome = options.codexHome ?? join(home, ".codex");
87
+ const exists = options.exists ?? existsSync;
88
+ const candidates: Array<[ClientId, string]> = [
89
+ ["claude-code", join(home, ".claude")],
90
+ ["codex", codexHome],
91
+ ["gemini-cli", join(home, ".gemini")],
92
+ ["opencode", join(home, ".config", "opencode")],
93
+ ["github-copilot", join(home, ".config", "github-copilot")],
94
+ ["windsurf", join(home, ".codeium", "windsurf")],
95
+ ["goose", join(home, ".config", "goose")],
96
+ ["hermes", join(home, ".hermes")],
97
+ ];
98
+ return candidates
99
+ .filter(([, evidence]) => exists(evidence))
100
+ .map(([client, evidence]) => ({ client, evidence }));
101
+ }
102
+
72
103
  function surfacePath(
73
104
  surfaceId: NonNullable<ClientDefinition["surfaceId"]>,
74
105
  options: { home: string; codexHome?: string },
package/src/manifest.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { existsSync } from "node:fs";
1
+ 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";
@@ -32,7 +32,7 @@ const targetSchema = z.object({
32
32
  const manifestSchema = z.object({
33
33
  core: z.object({ skills: z.array(skillIdSchema) }).strict(),
34
34
  project: z.record(groupNameSchema, projectGroupSchema).optional(),
35
- targets: z.record(groupNameSchema, targetSchema),
35
+ targets: z.record(groupNameSchema, targetSchema).default({}),
36
36
  }).strict();
37
37
 
38
38
  export type ProjectGroup = z.infer<typeof projectGroupSchema>;
@@ -94,6 +94,16 @@ export function serializeManifest(manifest: Manifest): string {
94
94
  return `${sections.join("\n\n")}\n`;
95
95
  }
96
96
 
97
+ export function writeManifestAtomic(path: string, manifest: Manifest): void {
98
+ const temporaryPath = `${path}.${process.pid}.${crypto.randomUUID()}.tmp`;
99
+ try {
100
+ writeFileSync(temporaryPath, serializeManifest(manifest), "utf8");
101
+ renameSync(temporaryPath, path);
102
+ } finally {
103
+ rmSync(temporaryPath, { force: true });
104
+ }
105
+ }
106
+
97
107
  function findExistingPin(manifest: Manifest, skillId: string): string | null {
98
108
  if (manifest.core.skills.includes(skillId)) return "[core]";
99
109
  for (const [groupName, group] of Object.entries(manifest.project ?? {})) {
@@ -167,6 +177,95 @@ export function unpinProject(manifest: Manifest, skillId: string, group: string)
167
177
  };
168
178
  }
169
179
 
180
+ export interface UpsertProjectOptions {
181
+ name: string;
182
+ paths: string[];
183
+ skills: string[];
184
+ targets: string[];
185
+ }
186
+
187
+ export function upsertProject(manifest: Manifest, options: UpsertProjectOptions): Manifest {
188
+ if (!groupNameSchema.safeParse(options.name).success) {
189
+ throw new Error(
190
+ `invalid group name "${options.name}" — must match /^[a-z][a-z0-9_-]*$/ (max 64 chars)`,
191
+ );
192
+ }
193
+
194
+ const existingGroup = manifest.project?.[options.name] ?? { paths: [], skills: [] };
195
+ for (const skillId of options.skills) {
196
+ if (!skillIdSchema.safeParse(skillId).success) {
197
+ throw new Error(`invalid skill ID "${skillId}"`);
198
+ }
199
+ if (existingGroup.skills.includes(skillId)) continue;
200
+ const existing = findExistingPin(manifest, skillId);
201
+ if (existing) throw new Error(`skill "${skillId}" already pinned in ${existing}`);
202
+ }
203
+
204
+ const targets = { ...manifest.targets };
205
+ for (const targetName of options.targets) {
206
+ const target = targets[targetName];
207
+ if (!target) throw new Error(`target "${targetName}" does not exist`);
208
+ targets[targetName] = {
209
+ ...target,
210
+ project_groups: [...new Set([...target.project_groups, options.name])],
211
+ };
212
+ }
213
+
214
+ return {
215
+ ...manifest,
216
+ project: {
217
+ ...manifest.project,
218
+ [options.name]: {
219
+ paths: [...new Set([...existingGroup.paths, ...options.paths])],
220
+ skills: [...new Set([...existingGroup.skills, ...options.skills])],
221
+ },
222
+ },
223
+ targets,
224
+ };
225
+ }
226
+
227
+ export function updateProjectPaths(
228
+ manifest: Manifest,
229
+ group: string,
230
+ changes: { add?: string[]; remove?: string[] },
231
+ ): Manifest {
232
+ const existingGroup = manifest.project?.[group];
233
+ if (!existingGroup) throw new Error(`[project.${group}] does not exist`);
234
+ const removed = new Set(changes.remove ?? []);
235
+ const paths = [...new Set([...existingGroup.paths, ...(changes.add ?? [])])]
236
+ .filter((path) => !removed.has(path));
237
+ return {
238
+ ...manifest,
239
+ project: {
240
+ ...manifest.project,
241
+ [group]: { ...existingGroup, paths },
242
+ },
243
+ };
244
+ }
245
+
246
+ export function updateProjectTargets(
247
+ manifest: Manifest,
248
+ group: string,
249
+ changes: { attach?: string[]; detach?: string[] },
250
+ ): Manifest {
251
+ if (!manifest.project?.[group]) throw new Error(`[project.${group}] does not exist`);
252
+ const attach = new Set(changes.attach ?? []);
253
+ const detach = new Set(changes.detach ?? []);
254
+ const requested = new Set([...attach, ...detach]);
255
+ for (const target of requested) {
256
+ if (!manifest.targets[target]) throw new Error(`target "${target}" does not exist`);
257
+ }
258
+ return {
259
+ ...manifest,
260
+ targets: Object.fromEntries(Object.entries(manifest.targets).map(([name, target]) => {
261
+ let groups = target.project_groups;
262
+ if (attach.has(name)) groups = [...new Set([...groups, group])];
263
+ if (detach.has(name)) groups = groups.filter((item) => item !== group);
264
+ return [name, { ...target, project_groups: groups }];
265
+ })),
266
+ };
267
+ }
268
+
170
269
  export interface ManifestValidationResult {
171
270
  notes: string[];
172
271
  }
package/src/output.ts CHANGED
@@ -110,12 +110,11 @@ export function suggestCorrection(input: string, candidates: string[]): string |
110
110
  return bestMatch;
111
111
  }
112
112
 
113
- export function isInteractive(): boolean {
114
- return (
115
- process.stdout.isTTY === true &&
116
- !process.env.NO_COLOR &&
117
- process.env.TERM !== "dumb"
118
- );
113
+ export function isInteractive(
114
+ env: NodeJS.ProcessEnv = process.env,
115
+ stdoutIsTTY = process.stdout.isTTY,
116
+ ): boolean {
117
+ return stdoutIsTTY === true && env.TERM !== "dumb";
119
118
  }
120
119
 
121
120
  export function renderTargetBanner(target: ResolvedTarget): void {
@@ -0,0 +1,36 @@
1
+ import { resolve } from "node:path";
2
+
3
+ interface ResolveProjectDirectoryOptions {
4
+ cwd?: string;
5
+ findGitRoot?: (cwd: string) => string | null;
6
+ }
7
+
8
+ function findGitRoot(cwd: string): string | null {
9
+ const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], {
10
+ cwd,
11
+ stdout: "pipe",
12
+ stderr: "ignore",
13
+ });
14
+ if (result.exitCode !== 0) return null;
15
+ const root = result.stdout.toString().trim();
16
+ return root || null;
17
+ }
18
+
19
+ export function resolveProjectDirectory(
20
+ explicitPath?: string,
21
+ options: ResolveProjectDirectoryOptions = {},
22
+ ): string {
23
+ const cwd = options.cwd ?? process.cwd();
24
+ if (explicitPath) return resolve(explicitPath);
25
+ const gitRoot = (options.findGitRoot ?? findGitRoot)(cwd);
26
+ return resolve(gitRoot ?? cwd);
27
+ }
28
+
29
+ export function suggestProjectName(directoryName: string): string {
30
+ const slug = directoryName
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9_-]+/g, "-")
33
+ .replace(/^-+|-+$/g, "");
34
+ const prefixed = /^[a-z]/.test(slug) ? slug : `project-${slug || "workspace"}`;
35
+ return prefixed.slice(0, 64).replace(/-+$/g, "");
36
+ }
package/src/prompts.ts ADDED
@@ -0,0 +1,69 @@
1
+ import { createInterface } from "node:readline/promises";
2
+
3
+ export interface SelectOption<T extends string> {
4
+ value: T;
5
+ label: string;
6
+ detail?: string;
7
+ selected?: boolean;
8
+ }
9
+
10
+ export function parseNumberSelection(input: string, optionCount: number): number[] {
11
+ if (input.trim() === "") return [];
12
+ const indexes = input.split(",").map((part) => {
13
+ const choice = Number(part.trim());
14
+ if (!Number.isInteger(choice) || choice < 1 || choice > optionCount) {
15
+ throw new Error(`select numbers between 1 and ${optionCount}, separated by commas`);
16
+ }
17
+ return choice - 1;
18
+ });
19
+ return [...new Set(indexes)].sort((a, b) => a - b);
20
+ }
21
+
22
+ export function parseCommaList(input: string): string[] {
23
+ return [...new Set(input.split(",").map((value) => value.trim()).filter(Boolean))];
24
+ }
25
+
26
+ export function shouldUseWizard(
27
+ args: readonly string[],
28
+ mode: { interactive: boolean; json: boolean; dryRun: boolean },
29
+ ): boolean {
30
+ if (!mode.interactive || mode.json || mode.dryRun || args.includes("--yes")) return false;
31
+ if (args.includes("--interactive")) return true;
32
+ return args.length === 0;
33
+ }
34
+
35
+ export async function promptMultiSelect<T extends string>(
36
+ question: string,
37
+ options: readonly SelectOption<T>[],
38
+ ): Promise<T[]> {
39
+ console.log(`\n${question}`);
40
+ options.forEach((option, index) => {
41
+ const checked = option.selected ? "x" : " ";
42
+ const detail = option.detail ? ` ${option.detail}` : "";
43
+ console.log(` ${index + 1}. [${checked}] ${option.label}${detail}`);
44
+ });
45
+ const defaults = options
46
+ .map((option, index) => option.selected ? String(index + 1) : "")
47
+ .filter(Boolean)
48
+ .join(",");
49
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
50
+ try {
51
+ const suffix = defaults ? ` [${defaults}]` : "";
52
+ const answer = await readline.question(`Select numbers, comma-separated${suffix}: `);
53
+ const selection = answer.trim() === "" && defaults ? defaults : answer;
54
+ return parseNumberSelection(selection, options.length).map((index) => options[index]!.value);
55
+ } finally {
56
+ readline.close();
57
+ }
58
+ }
59
+
60
+ export async function promptText(question: string, defaultValue = ""): Promise<string> {
61
+ const readline = createInterface({ input: process.stdin, output: process.stdout });
62
+ try {
63
+ const suffix = defaultValue ? ` [${defaultValue}]` : "";
64
+ const answer = (await readline.question(`${question}${suffix}: `)).trim();
65
+ return answer || defaultValue;
66
+ } finally {
67
+ readline.close();
68
+ }
69
+ }