@klhapp/skillmux 1.7.1 → 1.9.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 +35 -0
- package/README.md +15 -5
- package/docs/assets/architecture-dark.svg +160 -0
- package/docs/assets/{architecture.svg → architecture-light.svg} +40 -34
- package/docs/assets/logo-dark.png +0 -0
- package/docs/assets/logo-light.png +0 -0
- package/docs/cli.md +63 -5
- package/docs/concepts.md +10 -0
- package/docs/configuration.md +20 -20
- package/docs/deployment.md +20 -7
- package/docs/getting-started.md +11 -0
- package/docs/mcp-routing.md +41 -7
- package/docs/schema.json +31 -4
- package/docs/skill-management.md +32 -0
- package/package.json +1 -1
- package/src/audit.ts +1 -0
- package/src/cli.ts +62 -8
- package/src/commands/audit.ts +82 -0
- package/src/commands/eval.ts +81 -0
- package/src/commands/outdated.ts +112 -0
- package/src/commands/update.ts +253 -0
- package/src/config.ts +6 -0
- package/src/db.ts +152 -45
- package/src/eval.ts +69 -0
- package/src/install.ts +82 -3
- package/src/provenance.ts +99 -0
- package/src/router-core.ts +109 -26
- package/src/scan.ts +7 -1
- package/src/server.ts +21 -6
- package/src/stats.ts +119 -13
- package/src/sync.ts +38 -8
- package/src/types.ts +22 -0
- package/src/vault.ts +44 -4
- package/docs/assets/logo.png +0 -0
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { expandHome, loadConfig } from "../config";
|
|
2
|
+
import { countPrunable, openAudit, pruneAuditBefore } from "../db";
|
|
3
|
+
import { parseSince } from "../stats";
|
|
4
|
+
import { emitSuccess } from "../output";
|
|
5
|
+
import { confirmIfNeeded } from "./shared";
|
|
6
|
+
|
|
7
|
+
export async function runAudit(
|
|
8
|
+
subCommand: string,
|
|
9
|
+
args: string[],
|
|
10
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
11
|
+
): Promise<void> {
|
|
12
|
+
if (subCommand !== "prune") {
|
|
13
|
+
throw new Error("usage: skillmux audit prune [--older-than <window>] [--dry-run] [--yes] [--json]");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
let olderThan: string | undefined;
|
|
17
|
+
let dryRun = options.dryRun;
|
|
18
|
+
let yes = false;
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
const arg = args[i];
|
|
21
|
+
if (arg === "--older-than") olderThan = args[++i];
|
|
22
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
23
|
+
else if (arg === "--yes") yes = true;
|
|
24
|
+
else if (arg === "--json") {
|
|
25
|
+
// handled globally by main()'s isJson flag; recognized here so it isn't rejected
|
|
26
|
+
} else if (arg?.startsWith("--")) {
|
|
27
|
+
throw new Error(`unknown audit prune option: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const config = await loadConfig();
|
|
32
|
+
const stateDir = expandHome(config.state_dir);
|
|
33
|
+
|
|
34
|
+
let cutoff: Date;
|
|
35
|
+
if (olderThan) {
|
|
36
|
+
cutoff = parseSince(olderThan);
|
|
37
|
+
} else {
|
|
38
|
+
const retentionDays = config.audit?.retention_days ?? 90;
|
|
39
|
+
if (retentionDays <= 0) {
|
|
40
|
+
emitSuccess(
|
|
41
|
+
{ isJson: options.isJson },
|
|
42
|
+
{ audit_deleted: 0, fetch_deleted: 0, dry_run: dryRun, cutoff: null },
|
|
43
|
+
() => console.log("prune: audit.retention_days is 0 (pruning disabled); nothing to do"),
|
|
44
|
+
);
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
cutoff = new Date(Date.now() - retentionDays * 86_400_000);
|
|
48
|
+
}
|
|
49
|
+
const cutoffIso = cutoff.toISOString();
|
|
50
|
+
|
|
51
|
+
const db = openAudit(stateDir);
|
|
52
|
+
try {
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
const counts = countPrunable(db, cutoffIso);
|
|
55
|
+
emitSuccess(
|
|
56
|
+
{ isJson: options.isJson },
|
|
57
|
+
{ ...counts, dry_run: true, cutoff: cutoffIso },
|
|
58
|
+
() => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted} (dry-run)`),
|
|
59
|
+
);
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (
|
|
64
|
+
!(await confirmIfNeeded({
|
|
65
|
+
confirmed: yes,
|
|
66
|
+
isJson: options.isJson,
|
|
67
|
+
prompt: `prune audit rows older than ${cutoffIso}?`,
|
|
68
|
+
nonInteractiveError: "skillmux audit prune requires --yes when run non-interactively",
|
|
69
|
+
}))
|
|
70
|
+
)
|
|
71
|
+
return;
|
|
72
|
+
|
|
73
|
+
const counts = pruneAuditBefore(db, cutoffIso);
|
|
74
|
+
emitSuccess(
|
|
75
|
+
{ isJson: options.isJson },
|
|
76
|
+
{ ...counts, dry_run: false, cutoff: cutoffIso },
|
|
77
|
+
() => console.log(`prune: audit=${counts.audit_deleted} fetch=${counts.fetch_deleted}`),
|
|
78
|
+
);
|
|
79
|
+
} finally {
|
|
80
|
+
db.close();
|
|
81
|
+
}
|
|
82
|
+
}
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import { openAudit } from "../db";
|
|
5
|
+
import { buildPromotedCases, excludeExistingCases, parseEvalCases, queryPromotableFetches } from "../eval";
|
|
6
|
+
import { emitSuccess } from "../output";
|
|
7
|
+
import { parseSince } from "../stats";
|
|
8
|
+
import { confirmIfNeeded } from "./shared";
|
|
9
|
+
|
|
10
|
+
export async function runEvalPromote(
|
|
11
|
+
args: string[],
|
|
12
|
+
options: { isJson: boolean; dryRun: boolean },
|
|
13
|
+
): Promise<void> {
|
|
14
|
+
let since: string | undefined;
|
|
15
|
+
let target: string | undefined;
|
|
16
|
+
let dryRun = options.dryRun;
|
|
17
|
+
let yes = false;
|
|
18
|
+
for (let i = 0; i < args.length; i++) {
|
|
19
|
+
const arg = args[i];
|
|
20
|
+
if (arg === "--since") since = args[++i];
|
|
21
|
+
else if (arg === "--target") target = args[++i];
|
|
22
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
23
|
+
else if (arg === "--yes") yes = true;
|
|
24
|
+
else if (arg === "--json") {
|
|
25
|
+
// handled globally by main()'s isJson flag; recognized here so it isn't rejected
|
|
26
|
+
} else if (arg?.startsWith("--")) {
|
|
27
|
+
throw new Error(`unknown eval promote option: ${arg}`);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
if (!since) {
|
|
31
|
+
throw new Error("usage: skillmux eval promote --since <window> [--target <path>] [--dry-run] [--yes] [--json]");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const config = await loadConfig();
|
|
35
|
+
const stateDir = expandHome(config.state_dir);
|
|
36
|
+
const targetPath = target ?? join(stateDir, "eval-observed.json");
|
|
37
|
+
const sinceDate = parseSince(since);
|
|
38
|
+
const sinceIso = sinceDate.toISOString();
|
|
39
|
+
|
|
40
|
+
const db = openAudit(stateDir);
|
|
41
|
+
let candidates: ReturnType<typeof buildPromotedCases>;
|
|
42
|
+
try {
|
|
43
|
+
candidates = buildPromotedCases(queryPromotableFetches(db, sinceIso));
|
|
44
|
+
} finally {
|
|
45
|
+
db.close();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const existing = existsSync(targetPath) ? parseEvalCases(JSON.parse(readFileSync(targetPath, "utf-8"))) : [];
|
|
49
|
+
const { cases: newCases, skipped } = excludeExistingCases(candidates, existing);
|
|
50
|
+
|
|
51
|
+
console.error("warning: promoted eval cases contain raw user queries");
|
|
52
|
+
|
|
53
|
+
if (dryRun) {
|
|
54
|
+
emitSuccess(
|
|
55
|
+
{ isJson: options.isJson },
|
|
56
|
+
{ dry_run: true, since: sinceIso, target_path: targetPath, promoted: newCases.length, skipped_existing: skipped },
|
|
57
|
+
() => console.log(`promote: would write ${newCases.length} case(s) to ${targetPath} (skipped_existing=${skipped})`),
|
|
58
|
+
);
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (
|
|
63
|
+
!(await confirmIfNeeded({
|
|
64
|
+
confirmed: yes,
|
|
65
|
+
isJson: options.isJson,
|
|
66
|
+
prompt: `promote ${newCases.length} eval case(s) to ${targetPath}?`,
|
|
67
|
+
nonInteractiveError: "skillmux eval promote requires --yes when run non-interactively",
|
|
68
|
+
}))
|
|
69
|
+
)
|
|
70
|
+
return;
|
|
71
|
+
|
|
72
|
+
if (newCases.length > 0) {
|
|
73
|
+
writeFileSync(targetPath, JSON.stringify([...existing, ...newCases], null, 2) + "\n");
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
emitSuccess(
|
|
77
|
+
{ isJson: options.isJson },
|
|
78
|
+
{ dry_run: false, since: sinceIso, target_path: targetPath, promoted: newCases.length, skipped_existing: skipped },
|
|
79
|
+
() => console.log(`promote: wrote ${newCases.length} case(s) to ${targetPath} (skipped_existing=${skipped})`),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { readdirSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import { isLocalFileUrl, remoteHeadCommit } from "../install";
|
|
5
|
+
import { emitSuccess } from "../output";
|
|
6
|
+
import { readSkillOrigin } from "../provenance";
|
|
7
|
+
import { SKILL_ID_PATTERN } from "../vault";
|
|
8
|
+
|
|
9
|
+
export interface OutdatedCheckResult {
|
|
10
|
+
skill_id: string;
|
|
11
|
+
source_url: string;
|
|
12
|
+
recorded_commit: string;
|
|
13
|
+
remote_commit: string | null;
|
|
14
|
+
status: "up_to_date" | "outdated" | "check_failed" | "local_source_skipped";
|
|
15
|
+
reason: string | null;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function vaultSkillIds(vaultPath: string): string[] {
|
|
19
|
+
return readdirSync(vaultPath, { withFileTypes: true })
|
|
20
|
+
.filter((entry) => entry.isDirectory() && SKILL_ID_PATTERN.test(entry.name))
|
|
21
|
+
.map((entry) => entry.name)
|
|
22
|
+
.sort();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export async function checkOutdated(
|
|
26
|
+
vaultPath: string,
|
|
27
|
+
options: { allowLocalSource?: boolean } = {},
|
|
28
|
+
): Promise<OutdatedCheckResult[]> {
|
|
29
|
+
const results: OutdatedCheckResult[] = [];
|
|
30
|
+
for (const skillId of vaultSkillIds(vaultPath)) {
|
|
31
|
+
let origin: ReturnType<typeof readSkillOrigin>;
|
|
32
|
+
try {
|
|
33
|
+
origin = readSkillOrigin(join(vaultPath, skillId));
|
|
34
|
+
} catch (error) {
|
|
35
|
+
// A corrupt or unreadable sidecar is this skill's problem alone — never
|
|
36
|
+
// let it abort the check for every other skill in the vault (AC3/AC4's
|
|
37
|
+
// per-skill isolation applies to local parse failures too, not just
|
|
38
|
+
// unreachable remotes).
|
|
39
|
+
results.push({
|
|
40
|
+
skill_id: skillId,
|
|
41
|
+
source_url: "",
|
|
42
|
+
recorded_commit: "",
|
|
43
|
+
remote_commit: null,
|
|
44
|
+
status: "check_failed",
|
|
45
|
+
reason: `.skillmux-origin: ${error instanceof Error ? error.message : String(error)}`,
|
|
46
|
+
});
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
if (!origin) continue;
|
|
50
|
+
|
|
51
|
+
if (!options.allowLocalSource && isLocalFileUrl(origin.source_url)) {
|
|
52
|
+
results.push({
|
|
53
|
+
skill_id: skillId,
|
|
54
|
+
source_url: origin.source_url,
|
|
55
|
+
recorded_commit: origin.commit,
|
|
56
|
+
remote_commit: null,
|
|
57
|
+
status: "local_source_skipped",
|
|
58
|
+
reason: "source_url is a local file:// path — skipped by default; pass --allow-local-source to check it",
|
|
59
|
+
});
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
let remoteCommit: string | null = null;
|
|
64
|
+
let status: OutdatedCheckResult["status"];
|
|
65
|
+
let reason: string | null = null;
|
|
66
|
+
try {
|
|
67
|
+
remoteCommit = await remoteHeadCommit(origin.source_url);
|
|
68
|
+
status = remoteCommit === origin.commit ? "up_to_date" : "outdated";
|
|
69
|
+
} catch (error) {
|
|
70
|
+
status = "check_failed";
|
|
71
|
+
reason = error instanceof Error ? error.message : String(error);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
results.push({
|
|
75
|
+
skill_id: skillId,
|
|
76
|
+
source_url: origin.source_url,
|
|
77
|
+
recorded_commit: origin.commit,
|
|
78
|
+
remote_commit: remoteCommit,
|
|
79
|
+
status,
|
|
80
|
+
reason,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return results;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function runOutdated(args: string[], options: { isJson: boolean }): Promise<void> {
|
|
87
|
+
let allowLocalSource = false;
|
|
88
|
+
for (const arg of args) {
|
|
89
|
+
if (arg === "--json") continue;
|
|
90
|
+
if (arg === "--allow-local-source") {
|
|
91
|
+
allowLocalSource = true;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
throw new Error(`unknown outdated option: ${arg}`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const vaultPath = expandHome((await loadConfig()).vault_path);
|
|
98
|
+
const skills = await checkOutdated(vaultPath, { allowLocalSource });
|
|
99
|
+
const checksFailed = skills.filter((s) => s.status === "check_failed").length;
|
|
100
|
+
process.exitCode = checksFailed > 0 ? 1 : 0;
|
|
101
|
+
|
|
102
|
+
emitSuccess({ isJson: options.isJson }, { skills, checks_failed: checksFailed }, () => {
|
|
103
|
+
if (skills.length === 0) {
|
|
104
|
+
console.log("outdated: no vault skills carry provenance");
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
for (const s of skills) {
|
|
108
|
+
const suffix = s.status === "check_failed" ? ` — ${s.reason}` : "";
|
|
109
|
+
console.log(`[${s.status}] ${s.skill_id}${suffix}`);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
}
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
import { rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { expandHome, loadConfig } from "../config";
|
|
4
|
+
import {
|
|
5
|
+
cloneToTemp,
|
|
6
|
+
installIntoVault,
|
|
7
|
+
isLocalFileUrl,
|
|
8
|
+
remoteHeadCommit,
|
|
9
|
+
resolveCloneCommit,
|
|
10
|
+
resolveSkillDir,
|
|
11
|
+
validateSkillCandidate,
|
|
12
|
+
} from "../install";
|
|
13
|
+
import { emitSuccess } from "../output";
|
|
14
|
+
import { hashSkillContent, readSkillOrigin, writeSkillOrigin } from "../provenance";
|
|
15
|
+
import type { SkillOrigin } from "../provenance";
|
|
16
|
+
import { type ScanFinding, type ScanSeverity, scanExitCode } from "../scan";
|
|
17
|
+
import { confirmIfNeeded } from "./shared";
|
|
18
|
+
import { checkOutdated } from "./outdated";
|
|
19
|
+
|
|
20
|
+
type UpdateKind = "update" | "up_to_date" | "skip_drift" | "skip_scan_failed" | "skip_read_error";
|
|
21
|
+
|
|
22
|
+
interface UpdatePlanItem {
|
|
23
|
+
skillId: string;
|
|
24
|
+
oldCommit: string;
|
|
25
|
+
newCommit: string;
|
|
26
|
+
contentChanged: boolean;
|
|
27
|
+
kind: UpdateKind;
|
|
28
|
+
findings?: ScanFinding[];
|
|
29
|
+
reason?: string;
|
|
30
|
+
cloneDir: string | null;
|
|
31
|
+
fetchedDir: string | null;
|
|
32
|
+
origin: SkillOrigin;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async function resolveCandidateOrigins(
|
|
36
|
+
vaultPath: string,
|
|
37
|
+
skillId: string | undefined,
|
|
38
|
+
allowLocalSource: boolean,
|
|
39
|
+
): Promise<{ skillId: string; origin: SkillOrigin }[]> {
|
|
40
|
+
if (skillId) {
|
|
41
|
+
let origin: SkillOrigin | null;
|
|
42
|
+
try {
|
|
43
|
+
origin = readSkillOrigin(join(vaultPath, skillId));
|
|
44
|
+
} catch (error) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`"${skillId}" has a corrupt .skillmux-origin sidecar: ${error instanceof Error ? error.message : String(error)}`,
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
if (!origin) {
|
|
50
|
+
throw new Error(`"${skillId}" has no origin recorded — was this skill installed via "skillmux install"?`);
|
|
51
|
+
}
|
|
52
|
+
if (!allowLocalSource && isLocalFileUrl(origin.source_url)) {
|
|
53
|
+
throw new Error(`"${skillId}" has a local (file://) source — pass --allow-local-source to update it`);
|
|
54
|
+
}
|
|
55
|
+
return [{ skillId, origin }];
|
|
56
|
+
}
|
|
57
|
+
const outdated = await checkOutdated(vaultPath, { allowLocalSource });
|
|
58
|
+
return outdated
|
|
59
|
+
.filter((result) => result.status === "outdated")
|
|
60
|
+
.map((result) => ({ skillId: result.skill_id, origin: readSkillOrigin(join(vaultPath, result.skill_id))! }));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
async function buildPlan(
|
|
64
|
+
vaultPath: string,
|
|
65
|
+
candidates: { skillId: string; origin: SkillOrigin }[],
|
|
66
|
+
failOn: ScanSeverity | undefined,
|
|
67
|
+
force: boolean,
|
|
68
|
+
): Promise<UpdatePlanItem[]> {
|
|
69
|
+
const plan: UpdatePlanItem[] = [];
|
|
70
|
+
for (const { skillId, origin } of candidates) {
|
|
71
|
+
const skillDir = join(vaultPath, skillId);
|
|
72
|
+
let currentHash: string;
|
|
73
|
+
try {
|
|
74
|
+
currentHash = hashSkillContent(skillDir);
|
|
75
|
+
} catch (error) {
|
|
76
|
+
// A skill whose on-disk content can't be safely read (e.g. SKILL.md was
|
|
77
|
+
// swapped for a symlink after install — same threat model as AC7's drift
|
|
78
|
+
// check) is this skill's problem alone, matching checkOutdated's per-skill
|
|
79
|
+
// isolation: never let it abort the batch for every other candidate.
|
|
80
|
+
plan.push({
|
|
81
|
+
skillId,
|
|
82
|
+
oldCommit: origin.commit,
|
|
83
|
+
newCommit: origin.commit,
|
|
84
|
+
contentChanged: false,
|
|
85
|
+
kind: "skip_read_error",
|
|
86
|
+
reason: error instanceof Error ? error.message : String(error),
|
|
87
|
+
cloneDir: null,
|
|
88
|
+
fetchedDir: null,
|
|
89
|
+
origin,
|
|
90
|
+
});
|
|
91
|
+
continue;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Drift is a purely local comparison — check it before fetching anything,
|
|
95
|
+
// so a drifted (and therefore skipped) skill never pays for a clone.
|
|
96
|
+
if (currentHash !== origin.content_hash && !force) {
|
|
97
|
+
plan.push({
|
|
98
|
+
skillId,
|
|
99
|
+
oldCommit: origin.commit,
|
|
100
|
+
newCommit: await remoteHeadCommit(origin.source_url),
|
|
101
|
+
contentChanged: false,
|
|
102
|
+
kind: "skip_drift",
|
|
103
|
+
cloneDir: null,
|
|
104
|
+
fetchedDir: null,
|
|
105
|
+
origin,
|
|
106
|
+
});
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const cloneDir = await cloneToTemp(origin.source_url);
|
|
111
|
+
const resolved = resolveSkillDir(cloneDir, skillId, origin.skill_path);
|
|
112
|
+
const base = {
|
|
113
|
+
skillId,
|
|
114
|
+
oldCommit: origin.commit,
|
|
115
|
+
newCommit: resolveCloneCommit(cloneDir),
|
|
116
|
+
cloneDir,
|
|
117
|
+
fetchedDir: resolved.dir,
|
|
118
|
+
origin,
|
|
119
|
+
};
|
|
120
|
+
|
|
121
|
+
const { findings } = await validateSkillCandidate(skillId, resolved.dir);
|
|
122
|
+
if (scanExitCode(findings, failOn) !== 0) {
|
|
123
|
+
plan.push({ ...base, contentChanged: false, kind: "skip_scan_failed", findings });
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const contentChanged = hashSkillContent(resolved.dir) !== currentHash;
|
|
128
|
+
plan.push({
|
|
129
|
+
...base,
|
|
130
|
+
contentChanged,
|
|
131
|
+
kind: base.newCommit === origin.commit && !contentChanged ? "up_to_date" : "update",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return plan;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function buildConfirmPrompt(toWrite: Pick<UpdatePlanItem, "skillId" | "origin">[]): string {
|
|
138
|
+
const lines = toWrite.map((item) => ` ${item.skillId} <- ${item.origin.source_url}`);
|
|
139
|
+
return `update:\n${lines.join("\n")}\n?`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function statusFor(kind: UpdateKind, dryRun: boolean): string {
|
|
143
|
+
switch (kind) {
|
|
144
|
+
case "update":
|
|
145
|
+
return dryRun ? "would_update" : "updated";
|
|
146
|
+
case "skip_drift":
|
|
147
|
+
return dryRun ? "would_skip_drift" : "skipped_drift";
|
|
148
|
+
case "skip_scan_failed":
|
|
149
|
+
return dryRun ? "would_skip_scan_failed" : "skipped_scan_failed";
|
|
150
|
+
case "skip_read_error":
|
|
151
|
+
return "skipped_read_error";
|
|
152
|
+
default:
|
|
153
|
+
return "up_to_date";
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function parseUpdateArgs(args: string[]): {
|
|
158
|
+
skillId?: string;
|
|
159
|
+
yes: boolean;
|
|
160
|
+
dryRun: boolean;
|
|
161
|
+
force: boolean;
|
|
162
|
+
failOn?: ScanSeverity;
|
|
163
|
+
allowLocalSource: boolean;
|
|
164
|
+
} {
|
|
165
|
+
let skillId: string | undefined;
|
|
166
|
+
let yes = false;
|
|
167
|
+
let dryRun = false;
|
|
168
|
+
let force = false;
|
|
169
|
+
let failOn: ScanSeverity | undefined;
|
|
170
|
+
let allowLocalSource = false;
|
|
171
|
+
for (let i = 0; i < args.length; i++) {
|
|
172
|
+
const arg = args[i];
|
|
173
|
+
if (arg === "--yes") yes = true;
|
|
174
|
+
else if (arg === "--dry-run") dryRun = true;
|
|
175
|
+
else if (arg === "--force") force = true;
|
|
176
|
+
else if (arg === "--allow-local-source") allowLocalSource = true;
|
|
177
|
+
else if (arg === "--fail-on") {
|
|
178
|
+
const value = args[++i];
|
|
179
|
+
if (value !== "low" && value !== "medium" && value !== "high") {
|
|
180
|
+
throw new Error("--fail-on must be low, medium, or high");
|
|
181
|
+
}
|
|
182
|
+
failOn = value;
|
|
183
|
+
} else if (arg === "--json") {
|
|
184
|
+
// handled globally
|
|
185
|
+
} else if (arg?.startsWith("--")) {
|
|
186
|
+
throw new Error(`unknown update option: ${arg}`);
|
|
187
|
+
} else if (skillId !== undefined) {
|
|
188
|
+
throw new Error("skillmux update accepts at most one <skill-id> argument");
|
|
189
|
+
} else {
|
|
190
|
+
skillId = arg;
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
return { skillId, yes, dryRun, force, failOn, allowLocalSource };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export async function runUpdate(args: string[], options: { isJson: boolean }): Promise<void> {
|
|
197
|
+
const { skillId, yes, dryRun, force, failOn, allowLocalSource } = parseUpdateArgs(args);
|
|
198
|
+
const vaultPath = expandHome((await loadConfig()).vault_path);
|
|
199
|
+
|
|
200
|
+
const candidates = await resolveCandidateOrigins(vaultPath, skillId, allowLocalSource);
|
|
201
|
+
const plan = await buildPlan(vaultPath, candidates, failOn, force);
|
|
202
|
+
try {
|
|
203
|
+
const toWrite = plan.filter((item) => item.kind === "update");
|
|
204
|
+
|
|
205
|
+
if (!dryRun && toWrite.length > 0) {
|
|
206
|
+
const proceed = await confirmIfNeeded({
|
|
207
|
+
confirmed: yes,
|
|
208
|
+
isJson: options.isJson,
|
|
209
|
+
prompt: buildConfirmPrompt(toWrite),
|
|
210
|
+
nonInteractiveError: "skillmux update requires --yes when run non-interactively",
|
|
211
|
+
});
|
|
212
|
+
if (!proceed) return;
|
|
213
|
+
|
|
214
|
+
for (const item of toWrite) {
|
|
215
|
+
// toWrite is filtered to kind === "update", which is only ever set after
|
|
216
|
+
// a successful clone above, so fetchedDir is always populated here.
|
|
217
|
+
const targetDir = installIntoVault(vaultPath, item.skillId, item.fetchedDir as string, true);
|
|
218
|
+
writeSkillOrigin(targetDir, {
|
|
219
|
+
source_url: item.origin.source_url,
|
|
220
|
+
skill_path: item.origin.skill_path,
|
|
221
|
+
commit: item.newCommit,
|
|
222
|
+
installed_at: new Date().toISOString(),
|
|
223
|
+
content_hash: hashSkillContent(targetDir),
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
const skills = plan.map((item) => ({
|
|
229
|
+
skill_id: item.skillId,
|
|
230
|
+
source_url: item.origin.source_url,
|
|
231
|
+
old_commit: item.oldCommit,
|
|
232
|
+
new_commit: item.newCommit,
|
|
233
|
+
content_changed: item.contentChanged,
|
|
234
|
+
status: statusFor(item.kind, dryRun),
|
|
235
|
+
...(item.findings ? { findings: item.findings } : {}),
|
|
236
|
+
...(item.reason ? { reason: item.reason } : {}),
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
emitSuccess({ isJson: options.isJson }, { dry_run: dryRun, skills }, () => {
|
|
240
|
+
if (skills.length === 0) {
|
|
241
|
+
console.log("update: nothing to do");
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
for (const s of skills) {
|
|
245
|
+
console.log(`[${s.status}] ${s.skill_id}`);
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
} finally {
|
|
249
|
+
for (const item of plan) {
|
|
250
|
+
if (item.cloneDir) rmSync(item.cloneDir, { recursive: true, force: true });
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -88,6 +88,9 @@ const configSchema = z.object({
|
|
|
88
88
|
token_env: z.string().min(1),
|
|
89
89
|
}).strict().optional(),
|
|
90
90
|
}).strict().optional(),
|
|
91
|
+
audit: z.object({
|
|
92
|
+
retention_days: z.number().int().min(0).default(90),
|
|
93
|
+
}).strict().default({ retention_days: 90 }),
|
|
91
94
|
}).strict().refine((cfg) => {
|
|
92
95
|
const hasReranker = cfg.inference.mode === "remote" && !!cfg.inference.reranker;
|
|
93
96
|
if (hasReranker && cfg.output.max_top_k > cfg.recall.k_rerank) {
|
|
@@ -139,6 +142,9 @@ const DEFAULTS: Config = {
|
|
|
139
142
|
requests_per_minute: 60,
|
|
140
143
|
},
|
|
141
144
|
},
|
|
145
|
+
audit: {
|
|
146
|
+
retention_days: 90,
|
|
147
|
+
},
|
|
142
148
|
};
|
|
143
149
|
|
|
144
150
|
export const DEFAULT_CONFIG_PATH = "~/.config/skillmux/config.toml";
|