@klhapp/skillmux 1.9.3 → 1.11.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 +46 -0
- package/README.md +19 -19
- package/docs/README.md +4 -4
- package/docs/assets/architecture-dark.svg +39 -32
- package/docs/assets/architecture-light.svg +25 -18
- package/docs/cli.md +147 -36
- package/docs/concepts.md +11 -11
- package/docs/configuration.md +7 -5
- package/docs/deployment.md +10 -6
- package/docs/getting-started.md +18 -14
- package/docs/mcp-routing.md +1 -1
- package/docs/skill-management.md +17 -11
- package/docs/troubleshooting.md +4 -4
- package/package.json +1 -1
- package/src/adapters.ts +157 -11
- package/src/cli.ts +396 -1319
- package/src/commands/audit.ts +53 -56
- package/src/commands/config.ts +33 -26
- package/src/commands/context.ts +104 -0
- package/src/commands/core.ts +7 -3
- package/src/commands/doctor.ts +97 -0
- package/src/commands/eval.ts +22 -15
- package/src/commands/init.ts +672 -0
- package/src/commands/install.ts +132 -0
- package/src/commands/local-vault.ts +60 -0
- package/src/commands/models.ts +10 -0
- package/src/commands/outdated.ts +2 -1
- package/src/commands/project.ts +194 -51
- package/src/commands/report.ts +66 -0
- package/src/commands/scan.ts +61 -0
- package/src/commands/shared.ts +7 -14
- package/src/commands/skill.ts +33 -0
- package/src/commands/sync.ts +232 -0
- package/src/commands/target.ts +45 -15
- package/src/commands/update.ts +2 -1
- package/src/completions.ts +41 -15
- package/src/config-service.ts +4 -54
- package/src/context.ts +8 -3
- package/src/db-audit.ts +286 -0
- package/src/db-index.ts +238 -0
- package/src/db.ts +3 -521
- package/src/global-flags.ts +46 -0
- package/src/init-agents.ts +329 -0
- package/src/init-instructions.ts +47 -28
- package/src/logger.ts +26 -0
- package/src/mcp-registration.ts +89 -0
- package/src/output.ts +80 -18
- package/src/prompts.ts +75 -20
- package/src/router-core.ts +8 -27
- package/src/scan.ts +19 -19
- package/src/server.ts +161 -14
- package/src/toml-writer.ts +51 -0
- package/src/init-clients.ts +0 -220
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { Database } from "bun:sqlite";
|
|
2
|
+
import type { ContextAdapter } from "../adapters";
|
|
3
|
+
import type { ResolvedContext } from "../context";
|
|
4
|
+
import { emitSuccess } from "../output";
|
|
5
|
+
import { getStats, renderStatsText } from "../stats";
|
|
6
|
+
import { isGlobalFlag, isGlobalFlagWithValue } from "../global-flags";
|
|
7
|
+
|
|
8
|
+
function parseReportArgs(args: string[]): {
|
|
9
|
+
db?: string;
|
|
10
|
+
since?: string;
|
|
11
|
+
} {
|
|
12
|
+
let db: string | undefined;
|
|
13
|
+
let since: string | undefined;
|
|
14
|
+
for (let i = 0; i < args.length; i++) {
|
|
15
|
+
const option = args[i];
|
|
16
|
+
const value = args[i + 1];
|
|
17
|
+
if (option === "--db") {
|
|
18
|
+
if (!value) throw new Error("--db requires a path");
|
|
19
|
+
db = value;
|
|
20
|
+
i++;
|
|
21
|
+
} else if (option === "--since") {
|
|
22
|
+
if (!value) throw new Error("--since requires a window");
|
|
23
|
+
since = value;
|
|
24
|
+
i++;
|
|
25
|
+
} else if (isGlobalFlag(option, "--json", "--allow-insecure")) {
|
|
26
|
+
// handled globally by main()'s isJson/allowInsecure flags; recognized here so it isn't rejected
|
|
27
|
+
} else if (isGlobalFlagWithValue(option)) {
|
|
28
|
+
// handled globally by main()'s resolveContext(); recognized here so it isn't rejected
|
|
29
|
+
i++;
|
|
30
|
+
} else {
|
|
31
|
+
throw new Error(`unknown report option: ${option}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
return { db, since };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function runReport(
|
|
38
|
+
args: string[],
|
|
39
|
+
options: { isJson: boolean; context: ResolvedContext; allowInsecure: boolean; adapter: ContextAdapter },
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const { db: dbPath, since } = parseReportArgs(args);
|
|
42
|
+
if (!since)
|
|
43
|
+
throw new Error(
|
|
44
|
+
"usage: skillmux report [--context <name> | --server <url> | --db <path>] --since <window> [--json]",
|
|
45
|
+
);
|
|
46
|
+
if (dbPath && options.context.type === "remote")
|
|
47
|
+
throw new Error("--db and --context/--server are mutually exclusive");
|
|
48
|
+
|
|
49
|
+
if (dbPath) {
|
|
50
|
+
const db = new Database(dbPath, { readonly: true });
|
|
51
|
+
try {
|
|
52
|
+
const stats = getStats(db, since);
|
|
53
|
+
emitSuccess({ isJson: options.isJson }, stats, () =>
|
|
54
|
+
console.log(renderStatsText(stats)),
|
|
55
|
+
);
|
|
56
|
+
} finally {
|
|
57
|
+
db.close();
|
|
58
|
+
}
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const stats = await options.adapter.getStats(since);
|
|
63
|
+
emitSuccess({ isJson: options.isJson }, stats, () =>
|
|
64
|
+
console.log(renderStatsText(stats)),
|
|
65
|
+
);
|
|
66
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { expandHome, loadConfig } from "../config";
|
|
2
|
+
import { emitSuccess } from "../output";
|
|
3
|
+
import {
|
|
4
|
+
renderScanJson,
|
|
5
|
+
renderScanText,
|
|
6
|
+
scanExitCode,
|
|
7
|
+
scanPath,
|
|
8
|
+
type ScanSeverity,
|
|
9
|
+
} from "../scan";
|
|
10
|
+
import { isGlobalFlag } from "../global-flags";
|
|
11
|
+
|
|
12
|
+
function parseScanArgs(args: string[]): {
|
|
13
|
+
path?: string;
|
|
14
|
+
format: "text" | "json";
|
|
15
|
+
failOn?: ScanSeverity;
|
|
16
|
+
} {
|
|
17
|
+
let path: string | undefined;
|
|
18
|
+
let format: "text" | "json" = "text";
|
|
19
|
+
let failOn: ScanSeverity | undefined;
|
|
20
|
+
for (let i = 0; i < args.length; i++) {
|
|
21
|
+
const option = args[i];
|
|
22
|
+
if (option === "--format") {
|
|
23
|
+
const value = args[++i];
|
|
24
|
+
if (value !== "text" && value !== "json")
|
|
25
|
+
throw new Error("--format must be text or json");
|
|
26
|
+
format = value;
|
|
27
|
+
} else if (option === "--fail-on") {
|
|
28
|
+
const value = args[++i];
|
|
29
|
+
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
30
|
+
throw new Error("--fail-on must be low, medium, or high");
|
|
31
|
+
}
|
|
32
|
+
failOn = value;
|
|
33
|
+
} else if (isGlobalFlag(option, "--json")) {
|
|
34
|
+
// handled globally by main()'s isJson flag; recognized here so it isn't rejected
|
|
35
|
+
} else if (option?.startsWith("--")) {
|
|
36
|
+
throw new Error(`unknown scan option: ${option}`);
|
|
37
|
+
} else if (path !== undefined) {
|
|
38
|
+
throw new Error("skillmux scan accepts at most one <path> argument");
|
|
39
|
+
} else {
|
|
40
|
+
path = option;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { path, format, failOn };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export async function runScan(
|
|
47
|
+
args: string[],
|
|
48
|
+
options: { isJson: boolean },
|
|
49
|
+
): Promise<void> {
|
|
50
|
+
const { path, format, failOn } = parseScanArgs(args);
|
|
51
|
+
const rootPath = path
|
|
52
|
+
? expandHome(path)
|
|
53
|
+
: expandHome((await loadConfig()).vault_path);
|
|
54
|
+
const result = await scanPath(rootPath);
|
|
55
|
+
emitSuccess({ isJson: options.isJson }, result, () => {
|
|
56
|
+
console.log(
|
|
57
|
+
format === "json" ? renderScanJson(result) : renderScanText(result),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
process.exitCode = scanExitCode(result.findings, failOn);
|
|
61
|
+
}
|
package/src/commands/shared.ts
CHANGED
|
@@ -1,21 +1,14 @@
|
|
|
1
|
-
import { createInterface } from "node:readline/promises";
|
|
2
1
|
import { expandHome, loadConfig } from "../config";
|
|
3
2
|
import { parseManifest, resolveManifestPath } from "../manifest";
|
|
4
3
|
import { isInteractive } from "../output";
|
|
4
|
+
import { askQuestion, type PromptIO } from "../prompts";
|
|
5
5
|
|
|
6
|
-
export async function confirmAction(
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
});
|
|
11
|
-
|
|
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
|
-
}
|
|
6
|
+
export async function confirmAction(
|
|
7
|
+
prompt: string,
|
|
8
|
+
io: PromptIO = {},
|
|
9
|
+
): Promise<boolean> {
|
|
10
|
+
const answer = (await askQuestion(`${prompt} [y/N] `, io)).trim().toLowerCase();
|
|
11
|
+
return answer === "y" || answer === "yes";
|
|
19
12
|
}
|
|
20
13
|
|
|
21
14
|
export async function loadManifestContext() {
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import { unknownSubcommandError } from "../output";
|
|
5
|
+
import { vaultResolutionOrder } from "../vault";
|
|
6
|
+
|
|
7
|
+
export async function runSkill(subCommand: string, args: string[]): Promise<void> {
|
|
8
|
+
if (subCommand !== "which") throw unknownSubcommandError("skill", subCommand, ["which"]);
|
|
9
|
+
await runWhich(args);
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
async function runWhich(args: string[]): Promise<void> {
|
|
13
|
+
const skillId = args[0];
|
|
14
|
+
if (!skillId) {
|
|
15
|
+
throw new Error(
|
|
16
|
+
"usage: skillmux skill which <skill_id> (local vault shadow resolution; unrelated to MCP routing)",
|
|
17
|
+
);
|
|
18
|
+
}
|
|
19
|
+
const config = await loadConfig();
|
|
20
|
+
const vaultPath = expandHome(config.vault_path);
|
|
21
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
22
|
+
const roots = vaultResolutionOrder(vaultPath, localVaultPaths).filter(
|
|
23
|
+
(root) => existsSync(join(root, skillId, "SKILL.md")),
|
|
24
|
+
);
|
|
25
|
+
if (roots.length === 0) {
|
|
26
|
+
console.log(`${skillId}: not found in vault_path or local_vault_paths`);
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
console.log(`${skillId}: serving from ${roots[0]}`);
|
|
31
|
+
for (const shadowedRoot of roots.slice(1))
|
|
32
|
+
console.log(` shadows: ${shadowedRoot}`);
|
|
33
|
+
}
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { hostname } from "node:os";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
parseManifest,
|
|
6
|
+
resolveManifestPath,
|
|
7
|
+
validateManifest,
|
|
8
|
+
} from "../manifest";
|
|
9
|
+
import { emitSuccess, isInteractive, warn } from "../output";
|
|
10
|
+
import {
|
|
11
|
+
installPostMergeHook,
|
|
12
|
+
resolveProjectPinDir,
|
|
13
|
+
restoreMonolith as restoreMonolithTarget,
|
|
14
|
+
syncProjectTargets,
|
|
15
|
+
syncTarget,
|
|
16
|
+
type ProjectGroupInput,
|
|
17
|
+
} from "../sync";
|
|
18
|
+
import { confirmAction } from "./shared";
|
|
19
|
+
|
|
20
|
+
function parseSyncArgs(args: string[]): {
|
|
21
|
+
dryRun: boolean;
|
|
22
|
+
restoreMonolith: boolean;
|
|
23
|
+
installHook: boolean;
|
|
24
|
+
yes: boolean;
|
|
25
|
+
isJson: boolean;
|
|
26
|
+
} {
|
|
27
|
+
let dryRun = false;
|
|
28
|
+
let restoreMonolith = false;
|
|
29
|
+
let installHook = false;
|
|
30
|
+
let yes = false;
|
|
31
|
+
let isJson = false;
|
|
32
|
+
for (const arg of args) {
|
|
33
|
+
if (arg === "--dry-run") dryRun = true;
|
|
34
|
+
else if (arg === "--restore-monolith") restoreMonolith = true;
|
|
35
|
+
else if (arg === "--install-hook") installHook = true;
|
|
36
|
+
else if (arg === "--yes") yes = true;
|
|
37
|
+
else if (arg === "--json") isJson = true;
|
|
38
|
+
else throw new Error(`unknown sync option: ${arg}`);
|
|
39
|
+
}
|
|
40
|
+
return { dryRun, restoreMonolith, installHook, yes, isJson };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* A target directory that doesn't exist yet is about to be created by `sync`.
|
|
45
|
+
* `manifest.targets[*].dir` is vault content — readable and writable by whatever
|
|
46
|
+
* populated the vault (a shared git-backed vault pulled in, or a hand-edit) — and
|
|
47
|
+
* `sync` can run unattended via the `--install-hook` post-merge hook. Without this
|
|
48
|
+
* gate, a tampered manifest naming a brand-new path gets that directory silently
|
|
49
|
+
* created (and populated with symlinks) the next time anyone pulls. Creation for
|
|
50
|
+
* an as-yet-unseen directory therefore requires either `--yes` or an interactive
|
|
51
|
+
* confirmation; once the directory exists, later syncs never hit this path again.
|
|
52
|
+
*/
|
|
53
|
+
async function confirmNewSyncTarget(
|
|
54
|
+
label: string,
|
|
55
|
+
dir: string,
|
|
56
|
+
yes: boolean,
|
|
57
|
+
isJson: boolean,
|
|
58
|
+
): Promise<boolean> {
|
|
59
|
+
if (yes) return true;
|
|
60
|
+
if (!isInteractive()) {
|
|
61
|
+
if (!isJson) {
|
|
62
|
+
console.log(
|
|
63
|
+
`${label}: skipped — ${dir} does not exist yet; creating it requires approval. Re-run "skillmux sync --yes", or run "skillmux sync" interactively, once you've confirmed this target is expected.`,
|
|
64
|
+
);
|
|
65
|
+
}
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
return confirmAction(`${label}: create new target directory ${dir}?`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
interface SyncTargetSummary {
|
|
72
|
+
target: string;
|
|
73
|
+
status:
|
|
74
|
+
| "synced"
|
|
75
|
+
| "skipped_host_mismatch"
|
|
76
|
+
| "restored"
|
|
77
|
+
| "not_owned"
|
|
78
|
+
| "skipped_not_approved";
|
|
79
|
+
added?: string[];
|
|
80
|
+
removed?: string[];
|
|
81
|
+
skipped?: string[];
|
|
82
|
+
projects?: {
|
|
83
|
+
group: string;
|
|
84
|
+
pin_dir: string;
|
|
85
|
+
added: string[];
|
|
86
|
+
removed: string[];
|
|
87
|
+
skipped: string[];
|
|
88
|
+
}[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runSync(args: string[]): Promise<void> {
|
|
92
|
+
const { dryRun, restoreMonolith, installHook, yes, isJson } = parseSyncArgs(args);
|
|
93
|
+
const config = await loadConfig();
|
|
94
|
+
const vaultPath = expandHome(config.vault_path);
|
|
95
|
+
const log = (line: string) => {
|
|
96
|
+
if (!isJson) console.log(line);
|
|
97
|
+
};
|
|
98
|
+
const warnLine = (line: string) => {
|
|
99
|
+
if (!isJson) warn(line);
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
let hookInstalled: boolean | undefined;
|
|
103
|
+
if (installHook) {
|
|
104
|
+
const result = installPostMergeHook(vaultPath);
|
|
105
|
+
hookInstalled = result.installed;
|
|
106
|
+
log(result.installed ? "installed post-merge hook" : "post-merge hook already installed");
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const manifestPath = resolveManifestPath(vaultPath);
|
|
110
|
+
if (!manifestPath) {
|
|
111
|
+
emitSuccess({ isJson }, { hook_installed: hookInstalled ?? null, targets: [] }, () =>
|
|
112
|
+
console.log("no skillmux.toml found at vault root — nothing to sync"),
|
|
113
|
+
);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const manifest = parseManifest(await Bun.file(manifestPath).text());
|
|
118
|
+
const localVaultPaths = config.local_vault_paths.map(expandHome);
|
|
119
|
+
const { notes } = validateManifest(manifest, vaultPath, localVaultPaths);
|
|
120
|
+
for (const note of notes) log(`note: ${note}`);
|
|
121
|
+
|
|
122
|
+
const currentHost = hostname();
|
|
123
|
+
const targetSummaries: SyncTargetSummary[] = [];
|
|
124
|
+
for (const [targetName, target] of Object.entries(manifest.targets)) {
|
|
125
|
+
if (target.host !== undefined && target.host !== currentHost) {
|
|
126
|
+
log(
|
|
127
|
+
`${targetName}: skipped (host ${target.host} does not match current host ${currentHost})`,
|
|
128
|
+
);
|
|
129
|
+
targetSummaries.push({ target: targetName, status: "skipped_host_mismatch" });
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
const targetDir = expandHome(target.dir);
|
|
133
|
+
|
|
134
|
+
if (restoreMonolith) {
|
|
135
|
+
const result = restoreMonolithTarget(targetDir, vaultPath);
|
|
136
|
+
log(
|
|
137
|
+
result.restored
|
|
138
|
+
? `${targetName}: restored to a vault symlink`
|
|
139
|
+
: `${targetName}: not owned by skillmux, left untouched`,
|
|
140
|
+
);
|
|
141
|
+
targetSummaries.push({
|
|
142
|
+
target: targetName,
|
|
143
|
+
status: result.restored ? "restored" : "not_owned",
|
|
144
|
+
});
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!dryRun && !existsSync(targetDir)) {
|
|
149
|
+
const approved = await confirmNewSyncTarget(targetName, targetDir, yes, isJson);
|
|
150
|
+
if (!approved) {
|
|
151
|
+
if (isInteractive()) {
|
|
152
|
+
log(`${targetName}: skipped — creating ${targetDir} was not approved`);
|
|
153
|
+
}
|
|
154
|
+
targetSummaries.push({ target: targetName, status: "skipped_not_approved" });
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const suffix = dryRun ? " (dry-run)" : "";
|
|
160
|
+
const result = syncTarget(
|
|
161
|
+
{
|
|
162
|
+
vaultPath,
|
|
163
|
+
targetDir,
|
|
164
|
+
targetName,
|
|
165
|
+
coreSkillIds: manifest.core.skills,
|
|
166
|
+
localVaultPaths,
|
|
167
|
+
},
|
|
168
|
+
{ dryRun },
|
|
169
|
+
);
|
|
170
|
+
log(`${targetName}: +${result.added.length} -${result.removed.length}${suffix}`);
|
|
171
|
+
if (result.skipped.length > 0) {
|
|
172
|
+
warnLine(`refused to sync ${result.skipped.join(", ")} — skill directory contains a symlink`);
|
|
173
|
+
}
|
|
174
|
+
const summary: SyncTargetSummary = {
|
|
175
|
+
target: targetName,
|
|
176
|
+
status: "synced",
|
|
177
|
+
added: result.added,
|
|
178
|
+
removed: result.removed,
|
|
179
|
+
skipped: result.skipped,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
if (target.project_groups.length > 0) {
|
|
183
|
+
const allGroups = manifest.project ?? {};
|
|
184
|
+
const projectGroups: Record<string, ProjectGroupInput> = {};
|
|
185
|
+
for (const groupName of target.project_groups) {
|
|
186
|
+
const group = allGroups[groupName]!;
|
|
187
|
+
const approvedPaths: string[] = [];
|
|
188
|
+
for (const path of group.paths) {
|
|
189
|
+
// Mirror syncProjectTargets' own `if (!existsSync(path)) continue` so we
|
|
190
|
+
// never prompt for a project path it would silently skip anyway.
|
|
191
|
+
if (!existsSync(path)) continue;
|
|
192
|
+
const pinDir = resolveProjectPinDir(targetDir, path);
|
|
193
|
+
if (dryRun || existsSync(pinDir)) {
|
|
194
|
+
approvedPaths.push(path);
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const approved = await confirmNewSyncTarget(`${targetName}/${groupName}`, pinDir, yes, isJson);
|
|
198
|
+
if (approved) approvedPaths.push(path);
|
|
199
|
+
}
|
|
200
|
+
projectGroups[groupName] = { ...group, paths: approvedPaths };
|
|
201
|
+
}
|
|
202
|
+
const projectResults = syncProjectTargets(
|
|
203
|
+
{ vaultPath, targetDir, targetName, projectGroups, localVaultPaths },
|
|
204
|
+
{ dryRun },
|
|
205
|
+
);
|
|
206
|
+
summary.projects = projectResults.map((projectResult) => ({
|
|
207
|
+
group: projectResult.group,
|
|
208
|
+
pin_dir: projectResult.pinDir,
|
|
209
|
+
added: projectResult.added,
|
|
210
|
+
removed: projectResult.removed,
|
|
211
|
+
skipped: projectResult.skipped,
|
|
212
|
+
}));
|
|
213
|
+
for (const projectResult of projectResults) {
|
|
214
|
+
log(
|
|
215
|
+
` ${projectResult.group} -> ${projectResult.pinDir}: +${projectResult.added.length} -${projectResult.removed.length}${suffix}`,
|
|
216
|
+
);
|
|
217
|
+
if (projectResult.skipped.length > 0) {
|
|
218
|
+
warnLine(
|
|
219
|
+
`refused to sync ${projectResult.skipped.join(", ")} — skill directory contains a symlink`,
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
targetSummaries.push(summary);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
emitSuccess(
|
|
228
|
+
{ isJson },
|
|
229
|
+
{ hook_installed: hookInstalled ?? null, notes, targets: targetSummaries },
|
|
230
|
+
() => {},
|
|
231
|
+
);
|
|
232
|
+
}
|
package/src/commands/target.ts
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { expandHome } from "../config";
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
BUILT_IN_TARGET_NAMES,
|
|
4
|
+
planAgentSurfaces,
|
|
5
|
+
resolveBuiltInTarget,
|
|
6
|
+
SUPPORTED_AGENT_IDS,
|
|
7
|
+
} from "../init-agents";
|
|
3
8
|
import { planInitManifest, applyInit } from "../init";
|
|
4
9
|
import { writeManifestAtomic } from "../manifest";
|
|
5
|
-
import { emitSuccess } from "../output";
|
|
10
|
+
import { emitSuccess, unknownSubcommandError } from "../output";
|
|
6
11
|
import { confirmIfNeeded, loadManifestContext } from "./shared";
|
|
12
|
+
|
|
7
13
|
export async function runTarget(
|
|
8
14
|
subCommand: string,
|
|
9
15
|
args: string[],
|
|
@@ -19,11 +25,11 @@ export async function runTarget(
|
|
|
19
25
|
}
|
|
20
26
|
const targets = names.map((name) => {
|
|
21
27
|
const target = manifest.targets[name]!;
|
|
22
|
-
const
|
|
23
|
-
const surface =
|
|
28
|
+
const agents = SUPPORTED_AGENT_IDS.filter((agent) => {
|
|
29
|
+
const surface = planAgentSurfaces([agent]).surfaces[0];
|
|
24
30
|
return surface !== undefined && surface.path === expandHome(target.dir);
|
|
25
31
|
});
|
|
26
|
-
return { name, ...target,
|
|
32
|
+
return { name, ...target, agents };
|
|
27
33
|
});
|
|
28
34
|
emitSuccess({ isJson: options.isJson }, { targets }, () => {
|
|
29
35
|
if (targets.length === 0) {
|
|
@@ -33,7 +39,7 @@ export async function runTarget(
|
|
|
33
39
|
console.log(`${target.name}:`);
|
|
34
40
|
console.log(` dir: ${target.dir}`);
|
|
35
41
|
console.log(` host: ${target.host ?? "(global)"}`);
|
|
36
|
-
console.log(`
|
|
42
|
+
console.log(` agents: ${target.agents.join(", ") || "(custom)"}`);
|
|
37
43
|
console.log(
|
|
38
44
|
` projects: ${target.project_groups.join(", ") || "(none)"}`,
|
|
39
45
|
);
|
|
@@ -47,9 +53,21 @@ export async function runTarget(
|
|
|
47
53
|
const name = args[0];
|
|
48
54
|
const dirIndex = args.indexOf("--dir");
|
|
49
55
|
const rawPath = dirIndex === -1 ? undefined : args[dirIndex + 1];
|
|
50
|
-
if (!name
|
|
56
|
+
if (!name)
|
|
51
57
|
throw new Error("usage: skillmux target add <name> --dir <dir> --yes");
|
|
52
|
-
|
|
58
|
+
|
|
59
|
+
let path: string;
|
|
60
|
+
if (rawPath) {
|
|
61
|
+
path = expandHome(rawPath);
|
|
62
|
+
} else if (BUILT_IN_TARGET_NAMES.has(name)) {
|
|
63
|
+
path = resolveBuiltInTarget(name, {
|
|
64
|
+
codexHome: process.env.CODEX_HOME ? expandHome(process.env.CODEX_HOME) : undefined,
|
|
65
|
+
}).path;
|
|
66
|
+
} else {
|
|
67
|
+
throw new Error(
|
|
68
|
+
"usage: skillmux target add <name> --dir <dir> --yes (--dir may be omitted for built-in target names: agent-skills, claude-code, codex)",
|
|
69
|
+
);
|
|
70
|
+
}
|
|
53
71
|
if (options.dryRun) {
|
|
54
72
|
const planned = planInitManifest(vaultPath, [{ name, dir: path }], []);
|
|
55
73
|
emitSuccess(
|
|
@@ -63,14 +81,16 @@ export async function runTarget(
|
|
|
63
81
|
!(await confirmIfNeeded({
|
|
64
82
|
confirmed: args.includes("--yes"),
|
|
65
83
|
isJson: options.isJson,
|
|
66
|
-
prompt: `
|
|
84
|
+
prompt: `adopt target ${name} at ${path}?`,
|
|
67
85
|
nonInteractiveError:
|
|
68
86
|
"skillmux target add requires --yes when run non-interactively",
|
|
69
87
|
}))
|
|
70
88
|
)
|
|
71
89
|
return;
|
|
72
90
|
applyInit(vaultPath, [{ name, dir: path }]);
|
|
73
|
-
|
|
91
|
+
emitSuccess({ isJson: options.isJson }, { name, dir: path }, () =>
|
|
92
|
+
console.log(`target "${name}" added at ${path}`),
|
|
93
|
+
);
|
|
74
94
|
return;
|
|
75
95
|
}
|
|
76
96
|
|
|
@@ -84,27 +104,37 @@ export async function runTarget(
|
|
|
84
104
|
);
|
|
85
105
|
}
|
|
86
106
|
if (options.dryRun) {
|
|
87
|
-
|
|
107
|
+
emitSuccess(
|
|
108
|
+
{ isJson: options.isJson },
|
|
109
|
+
{ name, preserved_dir: manifest.targets[name]!.dir },
|
|
110
|
+
() => console.log(`target remove: ${name} (files preserved, dry-run)`),
|
|
111
|
+
);
|
|
88
112
|
return;
|
|
89
113
|
}
|
|
90
114
|
if (
|
|
91
115
|
!(await confirmIfNeeded({
|
|
92
116
|
confirmed: args.includes("--yes"),
|
|
93
117
|
isJson: options.isJson,
|
|
94
|
-
prompt: `
|
|
118
|
+
prompt: `remove target ${name} from the manifest and preserve its files?`,
|
|
95
119
|
nonInteractiveError:
|
|
96
120
|
"skillmux target remove requires --yes when run non-interactively",
|
|
97
121
|
}))
|
|
98
122
|
)
|
|
99
123
|
return;
|
|
100
124
|
const targets = { ...manifest.targets };
|
|
125
|
+
const removedDir = manifest.targets[name]!.dir;
|
|
101
126
|
delete targets[name];
|
|
102
127
|
writeManifestAtomic(manifestPath, { ...manifest, targets });
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
emitSuccess(
|
|
129
|
+
{ isJson: options.isJson },
|
|
130
|
+
{ name, preserved_dir: removedDir },
|
|
131
|
+
() =>
|
|
132
|
+
console.log(
|
|
133
|
+
`target "${name}" removed from the manifest; files preserved at ${removedDir}`,
|
|
134
|
+
),
|
|
105
135
|
);
|
|
106
136
|
return;
|
|
107
137
|
}
|
|
108
138
|
|
|
109
|
-
throw
|
|
139
|
+
throw unknownSubcommandError("target", subCommand, ["list", "show", "add", "remove"]);
|
|
110
140
|
}
|
package/src/commands/update.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
|
|
|
18
18
|
import { SKILL_ID_PATTERN } from "../vault";
|
|
19
19
|
import { confirmIfNeeded } from "./shared";
|
|
20
20
|
import { checkOutdated } from "./outdated";
|
|
21
|
+
import { isGlobalFlag } from "../global-flags";
|
|
21
22
|
|
|
22
23
|
type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
|
|
23
24
|
|
|
@@ -195,7 +196,7 @@ function parseUpdateArgs(args: string[]): {
|
|
|
195
196
|
throw new Error("--fail-on must be low, medium, or high");
|
|
196
197
|
}
|
|
197
198
|
failOn = value;
|
|
198
|
-
} else if (arg
|
|
199
|
+
} else if (isGlobalFlag(arg, "--json")) {
|
|
199
200
|
// handled globally
|
|
200
201
|
} else if (arg?.startsWith("--")) {
|
|
201
202
|
throw new Error(`unknown update option: ${arg}`);
|