skillrepo 4.9.2 → 4.10.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/README.md +17 -3
- package/package.json +1 -1
- package/src/lib/artifact-registry.mjs +28 -10
- package/src/lib/constants.mjs +11 -0
- package/src/lib/file-write.mjs +32 -47
- package/src/lib/foreign-content.mjs +461 -0
- package/src/lib/gitignore-section.mjs +223 -0
- package/src/lib/mergers/gitignore.mjs +22 -65
- package/src/lib/paths.mjs +11 -0
- package/src/lib/removers/gitignore.mjs +38 -3
- package/src/lib/sync.mjs +117 -0
- package/src/test/commands/update.test.mjs +51 -0
- package/src/test/e2e/cli-commands.test.mjs +63 -0
- package/src/test/lib/file-write.test.mjs +1 -1
- package/src/test/lib/foreign-content.test.mjs +522 -0
- package/src/test/lib/gitignore-section.test.mjs +213 -0
- package/src/test/lib/sync.test.mjs +349 -0
- package/src/test/mergers/uninstall-gitignore.test.mjs +65 -0
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Merger for .gitignore —
|
|
3
|
-
*
|
|
2
|
+
* Merger for .gitignore — the init-facing entry point over the CLI's
|
|
3
|
+
* single marked section (core writer: `../gitignore-section.mjs`).
|
|
4
4
|
*
|
|
5
|
-
* Always added
|
|
5
|
+
* Always added by `skillrepo init`:
|
|
6
6
|
* - `.env.local` — contains SKILLREPO_ACCESS_KEY, a live credential
|
|
7
7
|
* - `.claude/settings.local.json` — Claude Code per-user settings
|
|
8
8
|
*
|
|
@@ -10,16 +10,20 @@
|
|
|
10
10
|
* - `.claude/skills/` — when any vendor maps to `claudeProject`
|
|
11
11
|
* - `.agents/skills/` — when any vendor maps to `agentsProject`
|
|
12
12
|
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
13
|
+
* The sync-time pre-flight (`file-write.mjs`) manages the managed-root
|
|
14
|
+
* entries through the same core, so every CLI-authored gitignore line
|
|
15
|
+
* lives in the one marked section the uninstall remover strips (#2361).
|
|
16
|
+
*
|
|
17
|
+
* Idempotent: entries already present are skipped; a legacy sync-time
|
|
18
|
+
* section is healed into the marked section as a side effect.
|
|
16
19
|
*/
|
|
17
20
|
|
|
18
|
-
import { readFileSafe, writeFileSafe } from "../fs-utils.mjs";
|
|
19
|
-
import { gitignorePath } from "../paths.mjs";
|
|
20
21
|
import { placementTargetsFor } from "../file-write.mjs";
|
|
22
|
+
import {
|
|
23
|
+
ensureGitignoreEntries,
|
|
24
|
+
MANAGED_ROOT_ENTRIES,
|
|
25
|
+
} from "../gitignore-section.mjs";
|
|
21
26
|
|
|
22
|
-
const SECTION_HEADER = "# SkillRepo CLI (added by `skillrepo init`)";
|
|
23
27
|
const ALWAYS_ENTRIES = Object.freeze([
|
|
24
28
|
".env.local",
|
|
25
29
|
".claude/settings.local.json",
|
|
@@ -38,7 +42,7 @@ const ALWAYS_ENTRIES = Object.freeze([
|
|
|
38
42
|
function entriesFor(options) {
|
|
39
43
|
const entries = [...ALWAYS_ENTRIES];
|
|
40
44
|
if (!Array.isArray(options.vendors) || options.vendors.length === 0) {
|
|
41
|
-
entries.push(
|
|
45
|
+
entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
|
|
42
46
|
return entries;
|
|
43
47
|
}
|
|
44
48
|
let targets;
|
|
@@ -50,72 +54,25 @@ function entriesFor(options) {
|
|
|
50
54
|
} catch {
|
|
51
55
|
// Caller passed an unknown vendor; fall back to the always-set
|
|
52
56
|
// entries plus `.claude/skills/` so we never under-protect.
|
|
53
|
-
entries.push(
|
|
57
|
+
entries.push(MANAGED_ROOT_ENTRIES.claudeProject);
|
|
54
58
|
return entries;
|
|
55
59
|
}
|
|
56
|
-
|
|
57
|
-
|
|
60
|
+
for (const [target, entry] of Object.entries(MANAGED_ROOT_ENTRIES)) {
|
|
61
|
+
if (targets.includes(target)) entries.push(entry);
|
|
62
|
+
}
|
|
58
63
|
return entries;
|
|
59
64
|
}
|
|
60
65
|
|
|
61
66
|
/**
|
|
62
67
|
* Ensure the init-required paths are in .gitignore. Creates the file
|
|
63
68
|
* if it doesn't exist. Idempotent — returns `"skipped"` if every
|
|
64
|
-
* required entry is already present
|
|
65
|
-
*
|
|
69
|
+
* required entry is already present and no legacy section needed
|
|
70
|
+
* healing, `"created"` if the file didn't exist, `"updated"` if it did
|
|
71
|
+
* and at least one entry was missing (or a legacy section was healed).
|
|
66
72
|
*
|
|
67
73
|
* @param {{ vendors?: string[], global?: boolean }} [options]
|
|
68
74
|
* @returns {{ path: string; action: "created" | "updated" | "skipped"; added: string[] }}
|
|
69
75
|
*/
|
|
70
76
|
export function mergeGitignore(options = {}) {
|
|
71
|
-
|
|
72
|
-
const filePath = gitignorePath();
|
|
73
|
-
const existing = readFileSafe(filePath);
|
|
74
|
-
|
|
75
|
-
if (existing === null) {
|
|
76
|
-
// Fresh .gitignore — write all required entries as one section.
|
|
77
|
-
const content = renderSection(requiredEntries);
|
|
78
|
-
writeFileSafe(filePath, content);
|
|
79
|
-
return {
|
|
80
|
-
path: ".gitignore",
|
|
81
|
-
action: "created",
|
|
82
|
-
added: [...requiredEntries],
|
|
83
|
-
};
|
|
84
|
-
}
|
|
85
|
-
|
|
86
|
-
// Line-exact match check. `.env.local` as a line is distinct from
|
|
87
|
-
// `.env.local.backup` or a comment `# .env.local` — split on
|
|
88
|
-
// newlines and trim so we match the entry literally.
|
|
89
|
-
const lines = existing.split(/\r?\n/).map((l) => l.trim());
|
|
90
|
-
const missing = requiredEntries.filter((entry) => !lines.includes(entry));
|
|
91
|
-
|
|
92
|
-
if (missing.length === 0) {
|
|
93
|
-
return {
|
|
94
|
-
path: ".gitignore",
|
|
95
|
-
action: "skipped",
|
|
96
|
-
added: [],
|
|
97
|
-
};
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
// Append only the missing entries as a new section. Preserve the
|
|
101
|
-
// original line-ending style.
|
|
102
|
-
const lineEnding = existing.includes("\r\n") ? "\r\n" : "\n";
|
|
103
|
-
const separator = existing.endsWith("\n") ? "" : lineEnding;
|
|
104
|
-
const block = renderSection(missing, lineEnding);
|
|
105
|
-
writeFileSafe(filePath, existing + separator + lineEnding + block);
|
|
106
|
-
|
|
107
|
-
return {
|
|
108
|
-
path: ".gitignore",
|
|
109
|
-
action: "updated",
|
|
110
|
-
added: missing,
|
|
111
|
-
};
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/**
|
|
115
|
-
* Render the SkillRepo section with the given entries. The leading
|
|
116
|
-
* blank line separates the section from whatever precedes it when
|
|
117
|
-
* appending, and the trailing newline keeps the file POSIX-clean.
|
|
118
|
-
*/
|
|
119
|
-
function renderSection(entries, lineEnding = "\n") {
|
|
120
|
-
return SECTION_HEADER + lineEnding + entries.join(lineEnding) + lineEnding;
|
|
77
|
+
return ensureGitignoreEntries(entriesFor(options));
|
|
121
78
|
}
|
package/src/lib/paths.mjs
CHANGED
|
@@ -38,6 +38,17 @@ export const globalLastSyncPath = () => join(homedir(), ".claude", "skillrepo",
|
|
|
38
38
|
export const globalNpmVersionCheckPath = () =>
|
|
39
39
|
join(homedir(), ".claude", "skillrepo", ".npm-version-check");
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Governance warn-on-new state (#2361 owner directive): which unmanaged
|
|
43
|
+
* dirs (per placement root) and gitignored declarations (per repo) the
|
|
44
|
+
* CLI has already warned about. Separate file from `.last-sync` —
|
|
45
|
+
* different concern (what the USER was told vs. library content state)
|
|
46
|
+
* and a schema bump in one must not gate the other. Same parent dir so
|
|
47
|
+
* uninstall's whole-directory removal covers it.
|
|
48
|
+
*/
|
|
49
|
+
export const globalGovernanceSeenPath = () =>
|
|
50
|
+
join(homedir(), ".claude", "skillrepo", ".governance-seen");
|
|
51
|
+
|
|
41
52
|
// ── Skill placement targets ────────────────────────────────────────────
|
|
42
53
|
//
|
|
43
54
|
// Per-vendor placement decisions live in `agent-registry.mjs`. This
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
|
|
23
23
|
import { existsSync, readFileSync } from "node:fs";
|
|
24
24
|
import { writeFileAtomic } from "../fs-utils.mjs";
|
|
25
|
-
import {
|
|
25
|
+
import {
|
|
26
|
+
GITIGNORE_SECTION_HEADER,
|
|
27
|
+
LEGACY_GITIGNORE_SECTION_HEADER,
|
|
28
|
+
} from "../artifact-registry.mjs";
|
|
26
29
|
import { gitignorePath } from "../paths.mjs";
|
|
27
30
|
|
|
28
31
|
/**
|
|
@@ -50,10 +53,41 @@ export function removeGitignore({ dryRun = false } = {}) {
|
|
|
50
53
|
// stays CRLF. `split(/\r?\n/)` discards the line-ending bytes, so
|
|
51
54
|
// we detect them explicitly and rebuild on that basis.
|
|
52
55
|
const lineEnding = original.includes("\r\n") ? "\r\n" : "\n";
|
|
53
|
-
|
|
56
|
+
let lines = original.split(/\r?\n/);
|
|
57
|
+
|
|
58
|
+
// Legacy sync-time section (#2361): pre-4.10.0 syncs wrote
|
|
59
|
+
// `.agents/skills/` under its own header, outside the marked
|
|
60
|
+
// section. If the merger's heal never ran (user uninstalls without
|
|
61
|
+
// syncing on 4.10.0+), strip the attributable shape here: the
|
|
62
|
+
// header line plus a directly-following `.agents/skills/` line.
|
|
63
|
+
// Any other line under the legacy header is user content and
|
|
64
|
+
// survives.
|
|
65
|
+
let legacyRemoved = 0;
|
|
66
|
+
const legacyIdx = lines.findIndex(
|
|
67
|
+
(l) => l === LEGACY_GITIGNORE_SECTION_HEADER,
|
|
68
|
+
);
|
|
69
|
+
if (legacyIdx !== -1) {
|
|
70
|
+
let legacyEnd = legacyIdx + 1;
|
|
71
|
+
if (legacyEnd < lines.length && lines[legacyEnd] === ".agents/skills/") {
|
|
72
|
+
legacyEnd += 1;
|
|
73
|
+
}
|
|
74
|
+
legacyRemoved = legacyEnd - legacyIdx;
|
|
75
|
+
lines = [...lines.slice(0, legacyIdx), ...lines.slice(legacyEnd)];
|
|
76
|
+
}
|
|
54
77
|
|
|
55
78
|
const headerIdx = lines.findIndex((l) => l === GITIGNORE_SECTION_HEADER);
|
|
56
79
|
if (headerIdx === -1) {
|
|
80
|
+
if (legacyRemoved > 0) {
|
|
81
|
+
if (dryRun) {
|
|
82
|
+
return {
|
|
83
|
+
path: ".gitignore",
|
|
84
|
+
action: "would-remove",
|
|
85
|
+
removed: legacyRemoved,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
writeFileAtomic(filePath, lines.join(lineEnding));
|
|
89
|
+
return { path: ".gitignore", action: "removed", removed: legacyRemoved };
|
|
90
|
+
}
|
|
57
91
|
return { path: ".gitignore", action: "skipped", removed: 0 };
|
|
58
92
|
}
|
|
59
93
|
|
|
@@ -89,7 +123,8 @@ export function removeGitignore({ dryRun = false } = {}) {
|
|
|
89
123
|
// ? 1 : 0)` gives the SkillRepo-line count.
|
|
90
124
|
const consumedTrailingBlank =
|
|
91
125
|
end > headerIdx + 1 && lines[end - 1] === "";
|
|
92
|
-
const reportedRemoved =
|
|
126
|
+
const reportedRemoved =
|
|
127
|
+
removedLines - (consumedTrailingBlank ? 1 : 0) + legacyRemoved;
|
|
93
128
|
|
|
94
129
|
if (dryRun) {
|
|
95
130
|
return {
|
package/src/lib/sync.mjs
CHANGED
|
@@ -99,6 +99,18 @@
|
|
|
99
99
|
* Consumers must distinguish these to
|
|
100
100
|
* render accurate user messages (see
|
|
101
101
|
* init.mjs step 7).
|
|
102
|
+
* @property {number} [unmanaged] - Unmanaged (non-CLI-managed) skill dirs
|
|
103
|
+
* detected in the managed placement roots
|
|
104
|
+
* this sync writes to (#2361). "unmanaged"
|
|
105
|
+
* is the one wire word for this concept —
|
|
106
|
+
* same as the receipts category and the
|
|
107
|
+
* warning copy; the design doc's internal
|
|
108
|
+
* term is "foreign content". Warn-only
|
|
109
|
+
* observability — NOT part of the change
|
|
110
|
+
* totals the hook printers gate on, so a
|
|
111
|
+
* 304 with unmanaged content stays silent
|
|
112
|
+
* in hook modes. Absent on a throttled
|
|
113
|
+
* skip (detection doesn't run there).
|
|
102
114
|
* @property {string} syncedAt - ISO timestamp of the sync: the server
|
|
103
115
|
* response `syncedAt` on a 200, or the
|
|
104
116
|
* previously-cached sync timestamp on a
|
|
@@ -152,6 +164,16 @@ import {
|
|
|
152
164
|
EXIT_VALIDATION,
|
|
153
165
|
} from "./errors.mjs";
|
|
154
166
|
import { computeSkillShas } from "./crypto-shas.mjs";
|
|
167
|
+
import {
|
|
168
|
+
scanForeignContent,
|
|
169
|
+
formatForeignWarnings,
|
|
170
|
+
checkDeclarationGitignored,
|
|
171
|
+
formatDeclarationIgnoredWarning,
|
|
172
|
+
readGovernanceSeen,
|
|
173
|
+
commitGovernanceSeen,
|
|
174
|
+
selectNewFindings,
|
|
175
|
+
normalizeStateKey,
|
|
176
|
+
} from "./foreign-content.mjs";
|
|
155
177
|
|
|
156
178
|
/**
|
|
157
179
|
* Current schema version of the .last-sync file. Bump on any
|
|
@@ -913,12 +935,26 @@ export async function runSync(options) {
|
|
|
913
935
|
`Next sync will not be throttled.\n`,
|
|
914
936
|
);
|
|
915
937
|
}
|
|
938
|
+
// Foreign-content detection runs on the 304 path too (#2361): the
|
|
939
|
+
// library being unchanged says nothing about what else sits in the
|
|
940
|
+
// managed roots, and a deliberately-stable library would otherwise
|
|
941
|
+
// never surface violations. The on-disk managed set on a 304 is
|
|
942
|
+
// exactly the prior `.last-sync` skills map.
|
|
943
|
+
const unmanaged = emitGovernanceWarnings({
|
|
944
|
+
vendors,
|
|
945
|
+
global,
|
|
946
|
+
managedSkills: lastSync?.skills,
|
|
947
|
+
stderr,
|
|
948
|
+
hookMode: throttle === true,
|
|
949
|
+
});
|
|
950
|
+
|
|
916
951
|
return {
|
|
917
952
|
added: 0,
|
|
918
953
|
updated: 0,
|
|
919
954
|
removed: 0,
|
|
920
955
|
notModified: true,
|
|
921
956
|
fullSync,
|
|
957
|
+
unmanaged,
|
|
922
958
|
syncedAt: lastSync?.syncedAt ?? new Date().toISOString(),
|
|
923
959
|
};
|
|
924
960
|
}
|
|
@@ -1130,6 +1166,17 @@ export async function runSync(options) {
|
|
|
1130
1166
|
}
|
|
1131
1167
|
}
|
|
1132
1168
|
|
|
1169
|
+
// Foreign-content detection (#2361): after writes and tombstones, so
|
|
1170
|
+
// skills delivered THIS round are already in `skillsMap` and never
|
|
1171
|
+
// misclassified as unmanaged. Warn-only; never affects the sync result.
|
|
1172
|
+
summary.unmanaged = emitGovernanceWarnings({
|
|
1173
|
+
vendors,
|
|
1174
|
+
global,
|
|
1175
|
+
managedSkills: skillsMap,
|
|
1176
|
+
stderr,
|
|
1177
|
+
hookMode: throttle === true,
|
|
1178
|
+
});
|
|
1179
|
+
|
|
1133
1180
|
// Re-assert the full on-disk state via a sync receipt (#1832). This
|
|
1134
1181
|
// confirmed-write signal — not the fetch itself — is what sets team
|
|
1135
1182
|
// sync state. We post the COMPLETE post-sync on-disk set (`skillsMap`:
|
|
@@ -1157,6 +1204,76 @@ export async function runSync(options) {
|
|
|
1157
1204
|
|
|
1158
1205
|
// ── Internals ──────────────────────────────────────────────────────────
|
|
1159
1206
|
|
|
1207
|
+
/**
|
|
1208
|
+
* Run the sync-time governance checks and write their warnings to the
|
|
1209
|
+
* caller's stderr (#2361): foreign-content detection over the managed
|
|
1210
|
+
* roots this sync writes to, plus the gitignored-declaration probe.
|
|
1211
|
+
* Returns the unmanaged-dir count for the summary.
|
|
1212
|
+
*
|
|
1213
|
+
* Warn-only by construction — never throws, never changes sync
|
|
1214
|
+
* behavior. Foreign dir NAMES appear only in these local warning
|
|
1215
|
+
* lines; anything that leaves the machine goes through
|
|
1216
|
+
* `receiptViolationSummary` (counts/category only — the H7 #2364
|
|
1217
|
+
* seam; see foreign-content.mjs for the privacy contract).
|
|
1218
|
+
*
|
|
1219
|
+
* WARN-ON-NEW (owner directive, 2026-08-07): a warning that repeats
|
|
1220
|
+
* unchanged findings on every sync trains users to ignore it. The
|
|
1221
|
+
* already-warned set persists in `.governance-seen`; only NEW findings
|
|
1222
|
+
* print, and a removed-then-reappearing dir re-warns. The returned
|
|
1223
|
+
* count is always the TOTAL currently on disk (state observability),
|
|
1224
|
+
* not just the new ones.
|
|
1225
|
+
*
|
|
1226
|
+
* HOOK SYNCS (`hookMode: true` — the --session-hook / --silent
|
|
1227
|
+
* branches) neither print warnings nor mark findings as seen: the
|
|
1228
|
+
* user isn't looking at those streams, and recording seen there would
|
|
1229
|
+
* permanently swallow the warning before an interactive sync ever
|
|
1230
|
+
* showed it. Governance warnings are an interactive-surface feature;
|
|
1231
|
+
* hook syncs still contribute the count. The throttled early-exit
|
|
1232
|
+
* returns before this runs at all (zero-work contract).
|
|
1233
|
+
*
|
|
1234
|
+
* @param {object} options
|
|
1235
|
+
* @param {string[]} [options.vendors]
|
|
1236
|
+
* @param {boolean} [options.global]
|
|
1237
|
+
* @param {Record<string, unknown> | null | undefined} options.managedSkills
|
|
1238
|
+
* @param {NodeJS.WritableStream} options.stderr
|
|
1239
|
+
* @param {boolean} [options.hookMode] - True for hook-triggered syncs.
|
|
1240
|
+
* @returns {number} Unmanaged-dir count across scanned roots (the
|
|
1241
|
+
* summary's `unmanaged` field — total, not just new).
|
|
1242
|
+
*/
|
|
1243
|
+
function emitGovernanceWarnings({ vendors, global, managedSkills, stderr, hookMode }) {
|
|
1244
|
+
let unmanagedCount = 0;
|
|
1245
|
+
try {
|
|
1246
|
+
const scan = scanForeignContent({ vendors, global, managedSkills });
|
|
1247
|
+
unmanagedCount = scan.foreignCount;
|
|
1248
|
+
if (hookMode) return unmanagedCount;
|
|
1249
|
+
|
|
1250
|
+
const seen = readGovernanceSeen();
|
|
1251
|
+
const newScan = selectNewFindings(scan, seen);
|
|
1252
|
+
for (const line of formatForeignWarnings(newScan)) {
|
|
1253
|
+
stderr.write(`${line}\n`);
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// Gitignored-declaration warning, gated the same way: once per
|
|
1257
|
+
// repo while the state holds; leaving the gitignored state re-arms
|
|
1258
|
+
// it. Keyed by the NORMALIZED repo path (realpath + win32
|
|
1259
|
+
// case-fold) so two spellings of one checkout share a flag.
|
|
1260
|
+
const repoKey = normalizeStateKey(process.cwd());
|
|
1261
|
+
const decl = checkDeclarationGitignored();
|
|
1262
|
+
const declarationIgnored = decl.present && decl.ignored;
|
|
1263
|
+
if (declarationIgnored && !seen.declarations[repoKey]) {
|
|
1264
|
+
stderr.write(`${formatDeclarationIgnoredWarning()}\n`);
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
// Narrow-merge commit: re-reads the file and rewrites ONLY this
|
|
1268
|
+
// sync's scanned roots + this repo's declaration flag, so a
|
|
1269
|
+
// concurrent sync in another repo keeps its update.
|
|
1270
|
+
commitGovernanceSeen({ scan, repoKey, declarationIgnored });
|
|
1271
|
+
} catch {
|
|
1272
|
+
// Detection is best-effort; a probe failure must never fail a sync.
|
|
1273
|
+
}
|
|
1274
|
+
return unmanagedCount;
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1160
1277
|
/**
|
|
1161
1278
|
* Build sync-receipt entries from a `.last-sync` skills map (#1832).
|
|
1162
1279
|
*
|
|
@@ -261,6 +261,25 @@ describe("runUpdate — --session-hook contract", () => {
|
|
|
261
261
|
);
|
|
262
262
|
});
|
|
263
263
|
|
|
264
|
+
it("stays silent on a zero-change sync even when foreign content is present (#2361)", async () => {
|
|
265
|
+
// Foreign dirs are observability, not changes: the hook-mode
|
|
266
|
+
// output gate keys on added+updated+removed+skipped, and the
|
|
267
|
+
// foreign count must never trip it. Both streams are black-holed
|
|
268
|
+
// in this mode, so the warning lines vanish too — session start
|
|
269
|
+
// stays clean.
|
|
270
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
271
|
+
const { join } = await import("node:path");
|
|
272
|
+
const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
|
|
273
|
+
mkdirSync(dir, { recursive: true });
|
|
274
|
+
writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
|
|
275
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
276
|
+
await runUpdate(
|
|
277
|
+
["--key", VALID_KEY, "--url", serverUrl, "--session-hook"],
|
|
278
|
+
{ stdout },
|
|
279
|
+
);
|
|
280
|
+
assert.equal(stdout.text(), "", "foreign content must not break hook silence");
|
|
281
|
+
});
|
|
282
|
+
|
|
264
283
|
it("is silent on 304 / up-to-date (no 'Syncing' output every session)", async () => {
|
|
265
284
|
// INTENT: 304 means nothing changed. Printing "Syncing..." on
|
|
266
285
|
// every session start with no value to show would clutter the
|
|
@@ -473,6 +492,38 @@ describe("runUpdate — --silent contract", () => {
|
|
|
473
492
|
assert.equal(stdout.text(), "{}\n");
|
|
474
493
|
});
|
|
475
494
|
|
|
495
|
+
it("emits no governance warning under --silent, and does not consume it (#2361)", async () => {
|
|
496
|
+
// Governance warnings are an interactive-surface feature: hook
|
|
497
|
+
// syncs print nothing (the user is not looking at vendor hook
|
|
498
|
+
// logs) and must NOT mark the finding as seen — the next
|
|
499
|
+
// interactive `update` is the first to warn. Stdout stays pure
|
|
500
|
+
// JSON either way (Gemini contract).
|
|
501
|
+
const { mkdirSync, writeFileSync } = await import("node:fs");
|
|
502
|
+
const { join } = await import("node:path");
|
|
503
|
+
const { createCaptureStream } = await import("../helpers/capture-stream.mjs");
|
|
504
|
+
const dir = join(process.cwd(), ".claude", "skills", "hand-rolled");
|
|
505
|
+
mkdirSync(dir, { recursive: true });
|
|
506
|
+
writeFileSync(join(dir, "SKILL.md"), "---\nname: hand-rolled\n---\n");
|
|
507
|
+
const stderr = createCaptureStream();
|
|
508
|
+
server.setLibraryResponse({ skills: [], removals: [], syncedAt: "x" });
|
|
509
|
+
await runUpdate(
|
|
510
|
+
["--key", VALID_KEY, "--url", serverUrl, "--silent"],
|
|
511
|
+
{ stdout, stderr },
|
|
512
|
+
);
|
|
513
|
+
assert.equal(stdout.text(), "{}\n", "stdout must stay pure JSON");
|
|
514
|
+
assert.ok(!stderr.text().includes("not from your library"), "hook sync must not warn");
|
|
515
|
+
|
|
516
|
+
// Interactive update afterwards: the warning is still available.
|
|
517
|
+
const stdout2 = createCaptureStream();
|
|
518
|
+
const stderr2 = createCaptureStream();
|
|
519
|
+
await runUpdate(
|
|
520
|
+
["--key", VALID_KEY, "--url", serverUrl],
|
|
521
|
+
{ stdout: stdout2, stderr: stderr2 },
|
|
522
|
+
);
|
|
523
|
+
assert.match(stderr2.text(), /hand-rolled/);
|
|
524
|
+
assert.match(stderr2.text(), /skillrepo push/);
|
|
525
|
+
});
|
|
526
|
+
|
|
476
527
|
it("emits `{}\\n` even when the sync produced changes (no progress lines on stdout)", async () => {
|
|
477
528
|
server.setLibraryResponse({
|
|
478
529
|
skills: [makeSkill("silent-test")],
|
|
@@ -122,6 +122,69 @@ describe("CLI E2E — read commands", () => {
|
|
|
122
122
|
assert.match(r.stdout, /up to date/);
|
|
123
123
|
});
|
|
124
124
|
|
|
125
|
+
it("update warns about a foreign skill dir on stderr and exits 0 (#2361)", async () => {
|
|
126
|
+
const { writeFileSync } = await import("node:fs");
|
|
127
|
+
// Hand-authored dir the CLI never wrote — a governance violation,
|
|
128
|
+
// surfaced but never touched.
|
|
129
|
+
mkdirSync(join(tempDir, ".claude", "skills", "hand-rolled"), { recursive: true });
|
|
130
|
+
writeFileSync(
|
|
131
|
+
join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md"),
|
|
132
|
+
"---\nname: hand-rolled\n---\n",
|
|
133
|
+
);
|
|
134
|
+
server.setLibraryResponse({
|
|
135
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
136
|
+
removals: [],
|
|
137
|
+
syncedAt: "x",
|
|
138
|
+
});
|
|
139
|
+
const r = await runCli([
|
|
140
|
+
"update",
|
|
141
|
+
"--key", VALID_KEY,
|
|
142
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
143
|
+
]);
|
|
144
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
145
|
+
assert.match(r.stderr, /is not from your library/);
|
|
146
|
+
assert.match(r.stderr, /skillrepo push/);
|
|
147
|
+
assert.match(r.stderr, /\.claude\/skills\/hand-rolled\//);
|
|
148
|
+
// The foreign dir is surfaced, never destroyed.
|
|
149
|
+
assert.ok(existsSync(join(tempDir, ".claude", "skills", "hand-rolled", "SKILL.md")));
|
|
150
|
+
// The synced skill is managed — not warned about.
|
|
151
|
+
assert.ok(!r.stderr.includes("pdf-helper"));
|
|
152
|
+
|
|
153
|
+
// Warn-on-new: an identical second run repeats nothing.
|
|
154
|
+
const r2 = await runCli([
|
|
155
|
+
"update",
|
|
156
|
+
"--key", VALID_KEY,
|
|
157
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
158
|
+
]);
|
|
159
|
+
assert.equal(r2.status, 0, `stderr: ${r2.stderr}`);
|
|
160
|
+
assert.ok(
|
|
161
|
+
!r2.stderr.includes("hand-rolled"),
|
|
162
|
+
"unchanged finding must not re-warn on the next run",
|
|
163
|
+
);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("update --json carries the unmanaged count (#2361)", async () => {
|
|
167
|
+
const { writeFileSync } = await import("node:fs");
|
|
168
|
+
mkdirSync(join(tempDir, ".claude", "skills", "rogue"), { recursive: true });
|
|
169
|
+
writeFileSync(
|
|
170
|
+
join(tempDir, ".claude", "skills", "rogue", "SKILL.md"),
|
|
171
|
+
"---\nname: rogue\n---\n",
|
|
172
|
+
);
|
|
173
|
+
server.setLibraryResponse({
|
|
174
|
+
skills: [makeSkill("alice", "pdf-helper")],
|
|
175
|
+
removals: [],
|
|
176
|
+
syncedAt: "x",
|
|
177
|
+
});
|
|
178
|
+
const r = await runCli([
|
|
179
|
+
"update", "--json",
|
|
180
|
+
"--key", VALID_KEY,
|
|
181
|
+
"--url", `http://127.0.0.1:${port}`,
|
|
182
|
+
]);
|
|
183
|
+
assert.equal(r.status, 0, `stderr: ${r.stderr}`);
|
|
184
|
+
const json = JSON.parse(r.stdout);
|
|
185
|
+
assert.equal(json.unmanaged, 1);
|
|
186
|
+
});
|
|
187
|
+
|
|
125
188
|
it("update writes a single skill and prints the summary", async () => {
|
|
126
189
|
server.setLibraryResponse({
|
|
127
190
|
skills: [makeSkill("alice", "pdf-helper")],
|
|
@@ -956,7 +956,7 @@ describe("ensureFallbackGitignore — error surfacing", () => {
|
|
|
956
956
|
});
|
|
957
957
|
|
|
958
958
|
it("throws diskError when .gitignore cannot be READ (it is a directory → EISDIR)", () => {
|
|
959
|
-
// The read-error branch in
|
|
959
|
+
// The read-error branch in the gitignore-section core: readFileSafe
|
|
960
960
|
// re-throws any non-ENOENT error. Making `.gitignore` a DIRECTORY
|
|
961
961
|
// causes readFileSync to throw EISDIR, which surfaces as a typed
|
|
962
962
|
// diskError with a "Cannot read" message before any write is
|