@klhapp/skillmux 0.6.0 → 1.0.1
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 +51 -0
- package/README.md +9 -3
- package/docs/configuration.md +13 -10
- package/package.json +1 -1
- package/src/adapters.ts +11 -5
- package/src/calibrate.ts +7 -59
- package/src/cli.ts +521 -869
- package/src/commands/config.ts +202 -0
- package/src/commands/core.ts +52 -0
- package/src/commands/project.ts +412 -0
- package/src/commands/shared.ts +45 -0
- package/src/commands/target.ts +110 -0
- package/src/completions.ts +69 -55
- package/src/config-mutation.ts +65 -0
- package/src/config-watcher.ts +63 -2
- package/src/doctor.ts +19 -1
- package/src/output.ts +21 -23
- package/src/server.ts +228 -63
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { expandHome, migrateLegacyPaths, resolveConfigPath } from "../config";
|
|
2
|
+
import { type TargetAdapter } from "../adapters";
|
|
3
|
+
import { type ResolvedTarget } from "../context";
|
|
4
|
+
import { applyConfigInit, planConfigInit, type ConfigInitPlan } from "../setup";
|
|
5
|
+
import { emitSuccess, isInteractive, renderTargetBanner } from "../output";
|
|
6
|
+
import { confirmAction } from "./shared";
|
|
7
|
+
function emitConfigInitOutcome(
|
|
8
|
+
ctx: { isJson: boolean },
|
|
9
|
+
opts: {
|
|
10
|
+
phase: "plan" | "result";
|
|
11
|
+
dryRun: boolean;
|
|
12
|
+
applied: boolean;
|
|
13
|
+
plan: ConfigInitPlan;
|
|
14
|
+
action: "create" | "preserve";
|
|
15
|
+
text: string;
|
|
16
|
+
},
|
|
17
|
+
): void {
|
|
18
|
+
if (ctx.isJson) {
|
|
19
|
+
console.log(
|
|
20
|
+
JSON.stringify({
|
|
21
|
+
schema_version: 1,
|
|
22
|
+
ok: true,
|
|
23
|
+
command: "config init",
|
|
24
|
+
phase: opts.phase,
|
|
25
|
+
dry_run: opts.dryRun,
|
|
26
|
+
applied: opts.applied,
|
|
27
|
+
plan: {
|
|
28
|
+
config_path: opts.plan.configPath,
|
|
29
|
+
vault_path: opts.plan.vaultPath,
|
|
30
|
+
action: opts.action,
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
console.log(opts.text);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function handleConfigCommand(
|
|
40
|
+
adapter: TargetAdapter,
|
|
41
|
+
sub: string,
|
|
42
|
+
args: string[],
|
|
43
|
+
ctx: { target: ResolvedTarget; isJson: boolean; dryRun: boolean },
|
|
44
|
+
) {
|
|
45
|
+
if (sub === "init") {
|
|
46
|
+
let vaultPath: string | undefined;
|
|
47
|
+
let yes = false;
|
|
48
|
+
for (let i = 0; i < args.length; i++) {
|
|
49
|
+
const option = args[i];
|
|
50
|
+
if (option === "--vault") {
|
|
51
|
+
vaultPath = args[++i];
|
|
52
|
+
if (!vaultPath)
|
|
53
|
+
throw new Error("usage: skillmux config init --vault <path> --yes");
|
|
54
|
+
} else if (option === "--yes") {
|
|
55
|
+
yes = true;
|
|
56
|
+
} else if (option === "--dry-run" || option === "--json") {
|
|
57
|
+
continue;
|
|
58
|
+
} else {
|
|
59
|
+
throw new Error(`unknown config init option: ${option}`);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
if (!vaultPath) {
|
|
63
|
+
if (isInteractive() && !ctx.isJson) {
|
|
64
|
+
vaultPath = "~/skills";
|
|
65
|
+
} else {
|
|
66
|
+
throw new Error("usage: skillmux config init --vault <path> --yes");
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
migrateLegacyPaths();
|
|
71
|
+
const plan = planConfigInit(resolveConfigPath(), expandHome(vaultPath));
|
|
72
|
+
if (plan.action === "preserve") {
|
|
73
|
+
emitConfigInitOutcome(ctx, {
|
|
74
|
+
phase: "result",
|
|
75
|
+
dryRun: ctx.dryRun,
|
|
76
|
+
applied: false,
|
|
77
|
+
plan,
|
|
78
|
+
action: "preserve",
|
|
79
|
+
text: `preserved existing config: ${plan.configPath}`,
|
|
80
|
+
});
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
if (ctx.dryRun) {
|
|
84
|
+
emitConfigInitOutcome(ctx, {
|
|
85
|
+
phase: "plan",
|
|
86
|
+
dryRun: true,
|
|
87
|
+
applied: false,
|
|
88
|
+
plan,
|
|
89
|
+
action: "create",
|
|
90
|
+
text: `config create: ${plan.configPath} (dry-run)`,
|
|
91
|
+
});
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (!yes) {
|
|
95
|
+
if (!ctx.isJson && isInteractive()) {
|
|
96
|
+
if (
|
|
97
|
+
!(await confirmAction(
|
|
98
|
+
`Create ${plan.configPath} with vault_path ${plan.vaultPath}?`,
|
|
99
|
+
))
|
|
100
|
+
) {
|
|
101
|
+
console.log("config init cancelled; nothing written");
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
throw new Error(
|
|
106
|
+
"config initialization requires --yes in noninteractive mode",
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const result = applyConfigInit(plan);
|
|
112
|
+
emitConfigInitOutcome(ctx, {
|
|
113
|
+
phase: "result",
|
|
114
|
+
dryRun: false,
|
|
115
|
+
applied: result === "created",
|
|
116
|
+
plan,
|
|
117
|
+
action: plan.action,
|
|
118
|
+
text:
|
|
119
|
+
result === "created"
|
|
120
|
+
? `created ${plan.configPath}`
|
|
121
|
+
: `preserved existing config: ${plan.configPath}`,
|
|
122
|
+
});
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (sub === "show") {
|
|
127
|
+
const data = await adapter.getConfigShow();
|
|
128
|
+
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, data, () => {
|
|
129
|
+
renderTargetBanner(ctx.target);
|
|
130
|
+
console.log(JSON.stringify(data.effective, null, 2));
|
|
131
|
+
});
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (sub === "get") {
|
|
136
|
+
const key = args[0];
|
|
137
|
+
if (!key) throw new Error("usage: skillmux config get <key>");
|
|
138
|
+
const val = await adapter.getConfigGet(key);
|
|
139
|
+
emitSuccess(
|
|
140
|
+
{ isJson: ctx.isJson, target: ctx.target },
|
|
141
|
+
{ key, value: val },
|
|
142
|
+
() => {
|
|
143
|
+
console.log(
|
|
144
|
+
typeof val === "object" ? JSON.stringify(val) : String(val),
|
|
145
|
+
);
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (sub === "validate") {
|
|
152
|
+
const res = await adapter.configValidate();
|
|
153
|
+
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
154
|
+
console.log(
|
|
155
|
+
res.valid ? "Configuration is valid." : "Configuration is invalid.",
|
|
156
|
+
);
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (sub === "diff") {
|
|
162
|
+
const res = await adapter.configDiff();
|
|
163
|
+
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
164
|
+
renderTargetBanner(ctx.target);
|
|
165
|
+
console.log(JSON.stringify(res.diff, null, 2));
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
if (sub === "set") {
|
|
171
|
+
const key = args[0];
|
|
172
|
+
const value = args[1];
|
|
173
|
+
if (!key || value === undefined) {
|
|
174
|
+
throw new Error("usage: skillmux config set <key> <value> [--dry-run]");
|
|
175
|
+
}
|
|
176
|
+
const res = await adapter.configSet(key, value, { dryRun: ctx.dryRun });
|
|
177
|
+
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
178
|
+
renderTargetBanner(ctx.target);
|
|
179
|
+
const prefix = ctx.dryRun ? "[dry-run] " : "";
|
|
180
|
+
console.log(
|
|
181
|
+
`${prefix}${key}: ${JSON.stringify(res.prior_val)} -> ${JSON.stringify(res.resulting_val)}`,
|
|
182
|
+
);
|
|
183
|
+
console.log(
|
|
184
|
+
`Persistence: ${res.persistence}, Application: ${res.application}`,
|
|
185
|
+
);
|
|
186
|
+
});
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (sub === "status") {
|
|
191
|
+
const res = await adapter.configStatus();
|
|
192
|
+
emitSuccess({ isJson: ctx.isJson, target: ctx.target }, res, () => {
|
|
193
|
+
renderTargetBanner(ctx.target);
|
|
194
|
+
console.log(`Runtime: ${res.runtime}`);
|
|
195
|
+
console.log(`Active revision: ${res.active_revision}`);
|
|
196
|
+
console.log(`Readiness: ${res.readiness.status}`);
|
|
197
|
+
});
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
throw new Error("usage: skillmux config show");
|
|
202
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { expandHome } from "../config";
|
|
2
|
+
import { pinCore, unpinCore, validateManifest, writeManifestAtomic } from "../manifest";
|
|
3
|
+
import { emitSuccess } from "../output";
|
|
4
|
+
import { confirmIfNeeded, loadManifestContext } from "./shared";
|
|
5
|
+
export async function runCore(
|
|
6
|
+
subCommand: string,
|
|
7
|
+
args: string[],
|
|
8
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
9
|
+
): Promise<void> {
|
|
10
|
+
if (subCommand !== "pin" && subCommand !== "unpin") {
|
|
11
|
+
throw new Error("usage: skillmux core <pin|unpin>");
|
|
12
|
+
}
|
|
13
|
+
const skillIds = args.filter((arg) => !arg.startsWith("-"));
|
|
14
|
+
if (skillIds.length === 0) {
|
|
15
|
+
throw new Error(`usage: skillmux core ${subCommand} <skill_id>... --yes`);
|
|
16
|
+
}
|
|
17
|
+
const yes = args.includes("--yes");
|
|
18
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
19
|
+
await loadManifestContext();
|
|
20
|
+
let updated = manifest;
|
|
21
|
+
for (const skillId of skillIds) {
|
|
22
|
+
updated =
|
|
23
|
+
subCommand === "pin"
|
|
24
|
+
? pinCore(updated, skillId)
|
|
25
|
+
: unpinCore(updated, skillId);
|
|
26
|
+
}
|
|
27
|
+
validateManifest(
|
|
28
|
+
updated,
|
|
29
|
+
vaultPath,
|
|
30
|
+
config.local_vault_paths.map(expandHome),
|
|
31
|
+
);
|
|
32
|
+
if (options.dryRun) {
|
|
33
|
+
emitSuccess(
|
|
34
|
+
{ isJson: options.isJson },
|
|
35
|
+
{ subcommand: subCommand, skill_ids: skillIds },
|
|
36
|
+
() =>
|
|
37
|
+
console.log(`${subCommand}: [core] ${skillIds.join(", ")} (dry-run)`),
|
|
38
|
+
);
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
if (
|
|
42
|
+
!(await confirmIfNeeded({
|
|
43
|
+
confirmed: yes,
|
|
44
|
+
isJson: options.isJson,
|
|
45
|
+
prompt: `${subCommand} ${skillIds.join(", ")} in [core]?`,
|
|
46
|
+
nonInteractiveError: `skillmux core ${subCommand} requires --yes when run non-interactively`,
|
|
47
|
+
}))
|
|
48
|
+
)
|
|
49
|
+
return;
|
|
50
|
+
writeManifestAtomic(manifestPath, updated);
|
|
51
|
+
console.log(`${subCommand}: [core] ${skillIds.join(", ")}`);
|
|
52
|
+
}
|
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
import { existsSync, lstatSync } from "node:fs";
|
|
2
|
+
import { basename } from "node:path";
|
|
3
|
+
import { expandHome } from "../config";
|
|
4
|
+
import { planClientSurfaces, SUPPORTED_CLIENT_IDS } from "../init-clients";
|
|
5
|
+
import {
|
|
6
|
+
parseManifest,
|
|
7
|
+
pinProject,
|
|
8
|
+
unpinProject,
|
|
9
|
+
updateProjectPaths,
|
|
10
|
+
updateProjectTargets,
|
|
11
|
+
upsertProject,
|
|
12
|
+
validateManifest,
|
|
13
|
+
writeManifestAtomic,
|
|
14
|
+
} from "../manifest";
|
|
15
|
+
import { resolveProjectDirectory, suggestProjectName } from "../project-setup";
|
|
16
|
+
import {
|
|
17
|
+
parseCommaList,
|
|
18
|
+
promptMultiSelect,
|
|
19
|
+
promptText,
|
|
20
|
+
shouldUseWizard,
|
|
21
|
+
} from "../prompts";
|
|
22
|
+
import { emitSuccess, isInteractive } from "../output";
|
|
23
|
+
import { confirmAction, confirmIfNeeded, loadManifestContext } from "./shared";
|
|
24
|
+
const PROJECT_INIT_USAGE =
|
|
25
|
+
"usage: skillmux project init [path] [--name <group>] [--skill <id>...] [--client <id>...] [--target <name>...] [--yes] [--no-sync]";
|
|
26
|
+
|
|
27
|
+
interface ProjectInitArgs {
|
|
28
|
+
path: string;
|
|
29
|
+
name: string;
|
|
30
|
+
skills: string[];
|
|
31
|
+
clients: string[];
|
|
32
|
+
targets: string[];
|
|
33
|
+
yes: boolean;
|
|
34
|
+
sync: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function configuredTargetForSurface(
|
|
38
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
39
|
+
surface: { targetName: string; path: string },
|
|
40
|
+
): string | undefined {
|
|
41
|
+
if (manifest.targets[surface.targetName]) return surface.targetName;
|
|
42
|
+
return Object.entries(manifest.targets).find(
|
|
43
|
+
([, target]) => expandHome(target.dir) === surface.path,
|
|
44
|
+
)?.[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function configuredTargetsForClients(
|
|
48
|
+
manifest: ReturnType<typeof parseManifest>,
|
|
49
|
+
clients: readonly string[],
|
|
50
|
+
): string[] {
|
|
51
|
+
return planClientSurfaces(clients).surfaces.map((surface) => {
|
|
52
|
+
const target = configuredTargetForSurface(manifest, surface);
|
|
53
|
+
if (target) return target;
|
|
54
|
+
const client = surface.clients[0]!;
|
|
55
|
+
throw new Error(
|
|
56
|
+
`client target for "${client}" is not configured; run "skillmux init --client ${client} --yes" first`,
|
|
57
|
+
);
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function parseProjectInitArgs(args: string[]): ProjectInitArgs {
|
|
62
|
+
let projectPath: string | undefined;
|
|
63
|
+
let name: string | undefined;
|
|
64
|
+
const skills: string[] = [];
|
|
65
|
+
const clients: string[] = [];
|
|
66
|
+
const targets: string[] = [];
|
|
67
|
+
let yes = false;
|
|
68
|
+
let sync = true;
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < args.length; i++) {
|
|
71
|
+
const arg = args[i]!;
|
|
72
|
+
if (arg === "--name") {
|
|
73
|
+
name = args[++i];
|
|
74
|
+
if (!name) throw new Error("--name requires a group name");
|
|
75
|
+
} else if (arg === "--skill") {
|
|
76
|
+
const skill = args[++i];
|
|
77
|
+
if (!skill) throw new Error("--skill requires a skill_id");
|
|
78
|
+
skills.push(skill);
|
|
79
|
+
} else if (arg === "--target") {
|
|
80
|
+
const target = args[++i];
|
|
81
|
+
if (!target) throw new Error("--target requires a name");
|
|
82
|
+
targets.push(target);
|
|
83
|
+
} else if (arg === "--client") {
|
|
84
|
+
const client = args[++i];
|
|
85
|
+
if (!client) throw new Error("--client requires a name");
|
|
86
|
+
clients.push(client);
|
|
87
|
+
} else if (arg === "--yes") {
|
|
88
|
+
yes = true;
|
|
89
|
+
} else if (arg === "--no-sync") {
|
|
90
|
+
sync = false;
|
|
91
|
+
} else if (
|
|
92
|
+
arg === "--dry-run" ||
|
|
93
|
+
arg === "--json" ||
|
|
94
|
+
arg === "--interactive"
|
|
95
|
+
) {
|
|
96
|
+
continue;
|
|
97
|
+
} else if (arg.startsWith("-")) {
|
|
98
|
+
throw new Error(`unknown project init option: ${arg}`);
|
|
99
|
+
} else if (projectPath) {
|
|
100
|
+
throw new Error(PROJECT_INIT_USAGE);
|
|
101
|
+
} else {
|
|
102
|
+
projectPath = arg;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const path = resolveProjectDirectory(
|
|
107
|
+
projectPath ? expandHome(projectPath) : undefined,
|
|
108
|
+
);
|
|
109
|
+
return {
|
|
110
|
+
path,
|
|
111
|
+
name: name ?? suggestProjectName(basename(path)),
|
|
112
|
+
skills,
|
|
113
|
+
clients,
|
|
114
|
+
targets,
|
|
115
|
+
yes,
|
|
116
|
+
sync,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export async function runProject(
|
|
121
|
+
subCommand: string,
|
|
122
|
+
args: string[],
|
|
123
|
+
options: {
|
|
124
|
+
isJson: boolean;
|
|
125
|
+
dryRun: boolean;
|
|
126
|
+
sync: (args: string[]) => Promise<void>;
|
|
127
|
+
},
|
|
128
|
+
): Promise<void> {
|
|
129
|
+
if (subCommand === "list" || subCommand === "show") {
|
|
130
|
+
const { manifest } = await loadManifestContext();
|
|
131
|
+
const names =
|
|
132
|
+
subCommand === "show"
|
|
133
|
+
? [args[0] ?? ""]
|
|
134
|
+
: Object.keys(manifest.project ?? {});
|
|
135
|
+
if (subCommand === "show" && !manifest.project?.[names[0]!]) {
|
|
136
|
+
throw new Error(`[project.${names[0]}] does not exist`);
|
|
137
|
+
}
|
|
138
|
+
const projects = names.map((name) => ({
|
|
139
|
+
name,
|
|
140
|
+
paths: manifest.project?.[name]!.paths ?? [],
|
|
141
|
+
skills: manifest.project?.[name]!.skills ?? [],
|
|
142
|
+
targets: Object.entries(manifest.targets)
|
|
143
|
+
.filter(([, target]) => target.project_groups.includes(name))
|
|
144
|
+
.map(([target]) => target),
|
|
145
|
+
}));
|
|
146
|
+
emitSuccess({ isJson: options.isJson }, { projects }, () => {
|
|
147
|
+
if (projects.length === 0) {
|
|
148
|
+
console.log("no project groups configured");
|
|
149
|
+
} else {
|
|
150
|
+
for (const project of projects) {
|
|
151
|
+
console.log(`${project.name}:`);
|
|
152
|
+
console.log(` paths: ${project.paths.join(", ") || "(none)"}`);
|
|
153
|
+
console.log(` skills: ${project.skills.join(", ") || "(none)"}`);
|
|
154
|
+
console.log(` targets: ${project.targets.join(", ") || "(none)"}`);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (subCommand === "add-path" || subCommand === "remove-path") {
|
|
161
|
+
const group = args[0];
|
|
162
|
+
if (!group)
|
|
163
|
+
throw new Error(
|
|
164
|
+
`usage: skillmux project ${subCommand} <group> [path] --yes`,
|
|
165
|
+
);
|
|
166
|
+
const rawPath = args[1]?.startsWith("-") ? undefined : args[1];
|
|
167
|
+
const projectPath = resolveProjectDirectory(
|
|
168
|
+
rawPath ? expandHome(rawPath) : undefined,
|
|
169
|
+
);
|
|
170
|
+
const yes = args.includes("--yes");
|
|
171
|
+
if (!existsSync(projectPath) || !lstatSync(projectPath).isDirectory()) {
|
|
172
|
+
throw new Error(`project path is not a directory: ${projectPath}`);
|
|
173
|
+
}
|
|
174
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
175
|
+
await loadManifestContext();
|
|
176
|
+
const updated = updateProjectPaths(manifest, group, {
|
|
177
|
+
...(subCommand === "add-path"
|
|
178
|
+
? { add: [projectPath] }
|
|
179
|
+
: { remove: [projectPath] }),
|
|
180
|
+
});
|
|
181
|
+
validateManifest(
|
|
182
|
+
updated,
|
|
183
|
+
vaultPath,
|
|
184
|
+
config.local_vault_paths.map(expandHome),
|
|
185
|
+
);
|
|
186
|
+
if (options.dryRun) {
|
|
187
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath} (dry-run)`);
|
|
188
|
+
return;
|
|
189
|
+
}
|
|
190
|
+
if (
|
|
191
|
+
!(await confirmIfNeeded({
|
|
192
|
+
confirmed: yes,
|
|
193
|
+
isJson: options.isJson,
|
|
194
|
+
prompt: `${subCommand} ${projectPath} in [project.${group}]?`,
|
|
195
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
196
|
+
}))
|
|
197
|
+
)
|
|
198
|
+
return;
|
|
199
|
+
writeManifestAtomic(manifestPath, updated);
|
|
200
|
+
console.log(`${subCommand}: [project.${group}] ${projectPath}`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (subCommand === "pin" || subCommand === "unpin") {
|
|
204
|
+
const group = args[0];
|
|
205
|
+
const skills = args.slice(1).filter((arg) => !arg.startsWith("-"));
|
|
206
|
+
if (!group || skills.length === 0) {
|
|
207
|
+
throw new Error(
|
|
208
|
+
`usage: skillmux project ${subCommand} <group> <skill_id>... --yes`,
|
|
209
|
+
);
|
|
210
|
+
}
|
|
211
|
+
const yes = args.includes("--yes");
|
|
212
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
213
|
+
await loadManifestContext();
|
|
214
|
+
let updated = manifest;
|
|
215
|
+
for (const skill of skills) {
|
|
216
|
+
updated =
|
|
217
|
+
subCommand === "pin"
|
|
218
|
+
? pinProject(updated, skill, group)
|
|
219
|
+
: unpinProject(updated, skill, group);
|
|
220
|
+
}
|
|
221
|
+
validateManifest(
|
|
222
|
+
updated,
|
|
223
|
+
vaultPath,
|
|
224
|
+
config.local_vault_paths.map(expandHome),
|
|
225
|
+
);
|
|
226
|
+
if (options.dryRun) {
|
|
227
|
+
console.log(
|
|
228
|
+
`${subCommand}: [project.${group}] ${skills.join(", ")} (dry-run)`,
|
|
229
|
+
);
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
if (
|
|
233
|
+
!(await confirmIfNeeded({
|
|
234
|
+
confirmed: yes,
|
|
235
|
+
isJson: options.isJson,
|
|
236
|
+
prompt: `${subCommand} ${skills.join(", ")} in [project.${group}]?`,
|
|
237
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
238
|
+
}))
|
|
239
|
+
)
|
|
240
|
+
return;
|
|
241
|
+
writeManifestAtomic(manifestPath, updated);
|
|
242
|
+
console.log(`${subCommand}: [project.${group}] ${skills.join(", ")}`);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (subCommand === "attach" || subCommand === "detach") {
|
|
246
|
+
const group = args[0];
|
|
247
|
+
if (!group)
|
|
248
|
+
throw new Error(
|
|
249
|
+
`usage: skillmux project ${subCommand} <group> (--client <id>... | --target <name>...) --yes`,
|
|
250
|
+
);
|
|
251
|
+
const clients: string[] = [];
|
|
252
|
+
const requestedTargets: string[] = [];
|
|
253
|
+
for (let i = 1; i < args.length; i++) {
|
|
254
|
+
if (args[i] === "--client") {
|
|
255
|
+
const value = args[++i];
|
|
256
|
+
if (!value) throw new Error("--client requires a name");
|
|
257
|
+
clients.push(value);
|
|
258
|
+
} else if (args[i] === "--target") {
|
|
259
|
+
const value = args[++i];
|
|
260
|
+
if (!value) throw new Error("--target requires a name");
|
|
261
|
+
requestedTargets.push(value);
|
|
262
|
+
} else if (
|
|
263
|
+
args[i] !== "--yes" &&
|
|
264
|
+
args[i] !== "--dry-run" &&
|
|
265
|
+
args[i] !== "--json"
|
|
266
|
+
) {
|
|
267
|
+
throw new Error(`unknown project ${subCommand} option: ${args[i]}`);
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
271
|
+
await loadManifestContext();
|
|
272
|
+
const clientTargets = configuredTargetsForClients(manifest, clients);
|
|
273
|
+
const targets = [...new Set([...requestedTargets, ...clientTargets])];
|
|
274
|
+
if (targets.length === 0) {
|
|
275
|
+
throw new Error(`project ${subCommand} requires --client or --target`);
|
|
276
|
+
}
|
|
277
|
+
const updated = updateProjectTargets(manifest, group, {
|
|
278
|
+
...(subCommand === "attach" ? { attach: targets } : { detach: targets }),
|
|
279
|
+
});
|
|
280
|
+
validateManifest(
|
|
281
|
+
updated,
|
|
282
|
+
vaultPath,
|
|
283
|
+
config.local_vault_paths.map(expandHome),
|
|
284
|
+
);
|
|
285
|
+
if (options.dryRun) {
|
|
286
|
+
console.log(
|
|
287
|
+
`${subCommand}: [project.${group}] ${targets.join(", ")} (dry-run)`,
|
|
288
|
+
);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
if (
|
|
292
|
+
!(await confirmIfNeeded({
|
|
293
|
+
confirmed: args.includes("--yes"),
|
|
294
|
+
isJson: options.isJson,
|
|
295
|
+
prompt: `${subCommand} [project.${group}] to ${targets.join(", ")}?`,
|
|
296
|
+
nonInteractiveError: `skillmux project ${subCommand} requires --yes when run non-interactively`,
|
|
297
|
+
}))
|
|
298
|
+
)
|
|
299
|
+
return;
|
|
300
|
+
writeManifestAtomic(manifestPath, updated);
|
|
301
|
+
console.log(`${subCommand}: [project.${group}] ${targets.join(", ")}`);
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
if (subCommand !== "init") throw new Error(PROJECT_INIT_USAGE);
|
|
305
|
+
let request = parseProjectInitArgs(args);
|
|
306
|
+
const guided = shouldUseWizard(args, {
|
|
307
|
+
interactive: isInteractive(),
|
|
308
|
+
json: options.isJson,
|
|
309
|
+
dryRun: options.dryRun,
|
|
310
|
+
});
|
|
311
|
+
if (!existsSync(request.path))
|
|
312
|
+
throw new Error(`project path does not exist: ${request.path}`);
|
|
313
|
+
if (!lstatSync(request.path).isDirectory()) {
|
|
314
|
+
throw new Error(`project path is not a directory: ${request.path}`);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
const { config, vaultPath, manifestPath, manifest } =
|
|
318
|
+
await loadManifestContext();
|
|
319
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
320
|
+
if (guided) {
|
|
321
|
+
const name = await promptText("Project group", request.name);
|
|
322
|
+
const availableClients = SUPPORTED_CLIENT_IDS.filter((client) => {
|
|
323
|
+
const surface = planClientSurfaces([client]).surfaces[0];
|
|
324
|
+
return (
|
|
325
|
+
surface !== undefined &&
|
|
326
|
+
configuredTargetForSurface(manifest, surface) !== undefined
|
|
327
|
+
);
|
|
328
|
+
});
|
|
329
|
+
const clients = await promptMultiSelect(
|
|
330
|
+
"Which clients should receive project skills?",
|
|
331
|
+
availableClients.map((client) => ({
|
|
332
|
+
value: client,
|
|
333
|
+
label: client,
|
|
334
|
+
selected:
|
|
335
|
+
request.clients.length === 0 || request.clients.includes(client),
|
|
336
|
+
})),
|
|
337
|
+
);
|
|
338
|
+
const skills = parseCommaList(
|
|
339
|
+
await promptText(
|
|
340
|
+
"Project skill IDs, comma-separated",
|
|
341
|
+
request.skills.join(","),
|
|
342
|
+
),
|
|
343
|
+
);
|
|
344
|
+
request = { ...request, name, clients, skills };
|
|
345
|
+
}
|
|
346
|
+
const clientTargets = configuredTargetsForClients(manifest, request.clients);
|
|
347
|
+
const targets = [...new Set([...request.targets, ...clientTargets])];
|
|
348
|
+
const updated = upsertProject(manifest, {
|
|
349
|
+
name: request.name,
|
|
350
|
+
paths: [request.path],
|
|
351
|
+
skills: request.skills,
|
|
352
|
+
targets,
|
|
353
|
+
});
|
|
354
|
+
const { notes } = validateManifest(updated, vaultPath, localVaultPaths);
|
|
355
|
+
const plan = {
|
|
356
|
+
mode: "project",
|
|
357
|
+
project: request.name,
|
|
358
|
+
path: request.path,
|
|
359
|
+
skills: request.skills,
|
|
360
|
+
clients: request.clients,
|
|
361
|
+
targets,
|
|
362
|
+
sync: request.sync,
|
|
363
|
+
notes,
|
|
364
|
+
};
|
|
365
|
+
|
|
366
|
+
if (options.dryRun) {
|
|
367
|
+
emitSuccess({ isJson: options.isJson }, { plan }, () =>
|
|
368
|
+
console.log(`project plan: ${JSON.stringify(plan)}`),
|
|
369
|
+
);
|
|
370
|
+
return;
|
|
371
|
+
}
|
|
372
|
+
if (!request.yes) {
|
|
373
|
+
if (!options.isJson && isInteractive()) {
|
|
374
|
+
if (guided) {
|
|
375
|
+
console.log("\nReview");
|
|
376
|
+
console.log(` project: ${request.name}`);
|
|
377
|
+
console.log(` path: ${request.path}`);
|
|
378
|
+
console.log(` clients: ${request.clients.join(", ") || "(none)"}`);
|
|
379
|
+
console.log(` skills: ${request.skills.join(", ") || "(none)"}`);
|
|
380
|
+
console.log(` sync: ${request.sync ? "yes" : "no"}`);
|
|
381
|
+
}
|
|
382
|
+
if (
|
|
383
|
+
!(await confirmAction(
|
|
384
|
+
`Apply project setup for ${request.name} at ${request.path}?`,
|
|
385
|
+
))
|
|
386
|
+
) {
|
|
387
|
+
console.log("project setup cancelled");
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
} else {
|
|
391
|
+
throw new Error(
|
|
392
|
+
"skillmux project init requires --yes when run non-interactively",
|
|
393
|
+
);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
writeManifestAtomic(manifestPath, updated);
|
|
398
|
+
if (request.sync) {
|
|
399
|
+
try {
|
|
400
|
+
await options.sync([]);
|
|
401
|
+
} catch (error) {
|
|
402
|
+
throw new Error(
|
|
403
|
+
`project configuration was saved, but sync failed; fix the reported issue and run "skillmux sync": ${
|
|
404
|
+
error instanceof Error ? error.message : String(error)
|
|
405
|
+
}`,
|
|
406
|
+
);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
emitSuccess({ isJson: options.isJson }, { result: plan }, () =>
|
|
410
|
+
console.log(`project "${request.name}" ready at ${request.path}`),
|
|
411
|
+
);
|
|
412
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { createInterface } from "node:readline/promises";
|
|
2
|
+
import { expandHome, loadConfig } from "../config";
|
|
3
|
+
import { parseManifest, resolveManifestPath } from "../manifest";
|
|
4
|
+
import { isInteractive } from "../output";
|
|
5
|
+
|
|
6
|
+
export async function confirmAction(prompt: string): Promise<boolean> {
|
|
7
|
+
const readline = createInterface({
|
|
8
|
+
input: process.stdin,
|
|
9
|
+
output: process.stdout,
|
|
10
|
+
});
|
|
11
|
+
try {
|
|
12
|
+
const answer = (await readline.question(`${prompt} [y/N] `))
|
|
13
|
+
.trim()
|
|
14
|
+
.toLowerCase();
|
|
15
|
+
return answer === "y" || answer === "yes";
|
|
16
|
+
} finally {
|
|
17
|
+
readline.close();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function loadManifestContext() {
|
|
22
|
+
const config = await loadConfig();
|
|
23
|
+
const vaultPath = expandHome(config.vault_path);
|
|
24
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
25
|
+
if (!manifestPath) {
|
|
26
|
+
throw new Error(
|
|
27
|
+
`no skillmux.toml found at ${vaultPath}; run skillmux init first`,
|
|
28
|
+
);
|
|
29
|
+
}
|
|
30
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
31
|
+
return { config, vaultPath, manifestPath, manifest };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function confirmIfNeeded(opts: {
|
|
35
|
+
confirmed: boolean;
|
|
36
|
+
isJson: boolean;
|
|
37
|
+
prompt: string;
|
|
38
|
+
nonInteractiveError: string;
|
|
39
|
+
}): Promise<boolean> {
|
|
40
|
+
if (opts.confirmed) return true;
|
|
41
|
+
if (opts.isJson || !isInteractive()) {
|
|
42
|
+
throw new Error(opts.nonInteractiveError);
|
|
43
|
+
}
|
|
44
|
+
return confirmAction(opts.prompt);
|
|
45
|
+
}
|