@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 +25 -0
- package/README.md +35 -5
- package/docs/configuration.md +2 -2
- package/package.json +1 -1
- package/src/cli.ts +551 -27
- package/src/completions.ts +47 -2
- package/src/init-clients.ts +31 -0
- package/src/manifest.ts +101 -2
- package/src/output.ts +5 -6
- package/src/project-setup.ts +36 -0
- package/src/prompts.ts +69 -0
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
|
|
217
|
-
instruction file.
|
|
218
|
-
|
|
219
|
-
|
|
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.
|
|
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:
|
package/docs/configuration.md
CHANGED
|
@@ -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
|
|
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
|
|
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
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] [--
|
|
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 {
|
|
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
|
-
|
|
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))
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
.map((
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
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 &&
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 } {
|
package/src/completions.ts
CHANGED
|
@@ -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
|
|
package/src/init-clients.ts
CHANGED
|
@@ -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(
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
}
|