skillrepo 4.4.0 → 4.5.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 +57 -3
- package/bin/skillrepo.mjs +45 -0
- package/package.json +3 -2
- package/src/commands/list.mjs +328 -56
- package/src/lib/crypto-shas.mjs +131 -0
- package/src/lib/drift.mjs +175 -0
- package/src/lib/file-write.mjs +16 -1
- package/src/lib/npm-update-check.mjs +366 -0
- package/src/lib/paths.mjs +10 -0
- package/src/lib/placement-walk.mjs +285 -0
- package/src/lib/sync.mjs +163 -17
- package/src/test/commands/list.test.mjs +510 -2
- package/src/test/lib/crypto-shas.test.mjs +172 -0
- package/src/test/lib/drift.test.mjs +289 -0
- package/src/test/lib/npm-update-check.test.mjs +670 -0
- package/src/test/lib/placement-walk.test.mjs +453 -0
- package/src/test/lib/sync.test.mjs +409 -1
package/README.md
CHANGED
|
@@ -179,14 +179,48 @@ One-shot fetch. Does NOT mutate your library or the server — just reads
|
|
|
179
179
|
`GET /api/v1/skills/{owner}/{name}` and writes the skill to disk. Use
|
|
180
180
|
this to preview or pin a specific skill without adding it to your library.
|
|
181
181
|
|
|
182
|
-
### `list` — show what's in your library
|
|
182
|
+
### `list` — show what's in your library and what needs syncing
|
|
183
183
|
|
|
184
184
|
```sh
|
|
185
185
|
skillrepo list [--json]
|
|
186
186
|
```
|
|
187
187
|
|
|
188
|
-
Renders your library as a table
|
|
189
|
-
|
|
188
|
+
Renders your library as a table with a per-row `Local` column showing
|
|
189
|
+
on-disk drift state for each detected vendor:
|
|
190
|
+
|
|
191
|
+
- `OK` — local copy matches the last sync.
|
|
192
|
+
- `STALE` — library has a newer version than what's on disk.
|
|
193
|
+
- `MISS` — library has the skill but no on-disk placement (or no sync
|
|
194
|
+
history yet — run `skillrepo update` to establish a baseline).
|
|
195
|
+
- `EDIT` — local files have been modified since the last sync (different
|
|
196
|
+
SHA than what was persisted).
|
|
197
|
+
|
|
198
|
+
When multiple vendors are detected, the column shows a worst-state-wins
|
|
199
|
+
rollup (`missing > edited > stale > current`). The `--json` output
|
|
200
|
+
includes a `placements[]` array per item with per-vendor states for
|
|
201
|
+
scripts that want the full breakdown.
|
|
202
|
+
|
|
203
|
+
A footer reports library-level sync state:
|
|
204
|
+
|
|
205
|
+
- `library in sync — local skills up to date` — everything is current.
|
|
206
|
+
- `library in sync — but N skills show local drift` — library hasn't
|
|
207
|
+
changed but some local placements need attention.
|
|
208
|
+
- `library has changed since last sync` — registry has new content; run
|
|
209
|
+
`skillrepo update`.
|
|
210
|
+
- `No sync history on this machine` — fresh install or
|
|
211
|
+
baseline-less state; run `skillrepo update`.
|
|
212
|
+
|
|
213
|
+
Glyphs (`✓` / `⚠`) are used in TTY contexts; ASCII fallbacks (`OK` /
|
|
214
|
+
`[!]` / `STALE` / `MISS` / `EDIT`) appear when stdout is not a TTY or
|
|
215
|
+
`NO_COLOR` is set, so piped output stays clean.
|
|
216
|
+
|
|
217
|
+
`--json` is a bare array of skill objects with the additional fields
|
|
218
|
+
`state` (rollup) and `placements[]`. Existing scripts that consume
|
|
219
|
+
the pre-#1555 `--json` shape keep working — the additions are purely
|
|
220
|
+
per-item, no top-level wrapper.
|
|
221
|
+
|
|
222
|
+
Uses the same cached ETag as `update` for the library-level footer
|
|
223
|
+
state.
|
|
190
224
|
|
|
191
225
|
### `search` — explore the registry
|
|
192
226
|
|
|
@@ -474,8 +508,28 @@ Two scenarios worth calling out:
|
|
|
474
508
|
| `SKILLREPO_ACCESS_KEY` | Access key for any command. Takes precedence over the config file but not CLI flags. |
|
|
475
509
|
| `SKILLREPO_URL` | Server URL. Same precedence as above. |
|
|
476
510
|
| `SKILLREPO_TIMEOUT_MS` | Per-request fetch timeout in milliseconds (default 30000). Set to `0` to disable. |
|
|
511
|
+
| `SKILLREPO_NO_UPDATE_CHECK` | Set to any non-empty truthy value (`1`, `yes`, `true`) to disable the post-command npm-registry self-staleness check. The check otherwise runs at most once per 24 hours, hits `registry.npmjs.org/skillrepo/latest`, and prints a one-line upgrade hint to stderr if a newer version is available. Auto-disabled when `CI=true`. |
|
|
477
512
|
| `NO_COLOR` | Set any non-empty value to disable ANSI color in CLI output. |
|
|
478
513
|
|
|
514
|
+
### Update nudge
|
|
515
|
+
|
|
516
|
+
After every command that isn't `--json`, the CLI does a best-effort
|
|
517
|
+
check against `https://registry.npmjs.org/skillrepo/latest` and prints
|
|
518
|
+
a one-line hint on stderr when a newer version is available:
|
|
519
|
+
|
|
520
|
+
```
|
|
521
|
+
A newer skillrepo is available: 4.3.0 → 4.5.0
|
|
522
|
+
Upgrade: npm install -g skillrepo@latest
|
|
523
|
+
```
|
|
524
|
+
|
|
525
|
+
The check is cached at `~/.claude/skillrepo/.npm-version-check` for
|
|
526
|
+
24 hours on success and 1 hour on failure, has a 2-second fetch
|
|
527
|
+
timeout, and is fire-and-forget — every failure mode (network error,
|
|
528
|
+
non-2xx, parse failure, read-only FS) is swallowed silently. Set
|
|
529
|
+
`SKILLREPO_NO_UPDATE_CHECK=1` to disable. `CI=true` auto-disables.
|
|
530
|
+
Output is suppressed entirely when `--json` is on the parent command,
|
|
531
|
+
so structured pipelines aren't disturbed.
|
|
532
|
+
|
|
479
533
|
### Skill placement
|
|
480
534
|
|
|
481
535
|
Skills land at one of two project paths, depending on the agent:
|
package/bin/skillrepo.mjs
CHANGED
|
@@ -35,6 +35,8 @@ import { runSearch } from "../src/commands/search.mjs";
|
|
|
35
35
|
import { runUninstall } from "../src/commands/uninstall.mjs";
|
|
36
36
|
import { runSessionSync } from "../src/commands/session-sync.mjs";
|
|
37
37
|
import { CliError, EXIT_OK, EXIT_VALIDATION } from "../src/lib/errors.mjs";
|
|
38
|
+
import { checkForCliUpdate } from "../src/lib/npm-update-check.mjs";
|
|
39
|
+
import { getCliVersion } from "../src/lib/cli-version.mjs";
|
|
38
40
|
|
|
39
41
|
// ── Command registry ────────────────────────────────────────────────────
|
|
40
42
|
|
|
@@ -151,6 +153,49 @@ async function main(command, fullArgv) {
|
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
await COMMANDS[command].run(rest);
|
|
156
|
+
|
|
157
|
+
// After the primary command completes, do a best-effort npm-registry
|
|
158
|
+
// staleness check (#1554). Never blocks exit beyond a single tick:
|
|
159
|
+
// `Promise.race` against `setTimeout(0)` means if the fetch hasn't
|
|
160
|
+
// completed yet, we drop it and the user gets the nudge on the next
|
|
161
|
+
// invocation (when the cache file is more likely to be fresh).
|
|
162
|
+
//
|
|
163
|
+
// Suppressed entirely when the parent argv carries `--json` so we
|
|
164
|
+
// never inject text into a structured stream — not even on stderr,
|
|
165
|
+
// because some pipelines tee both streams.
|
|
166
|
+
if (!rest.includes("--json")) {
|
|
167
|
+
await runUpdateCheck();
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Best-effort update check. On a CACHE HIT this resolves synchronously
|
|
173
|
+
* (within a microtask) and we emit the nudge before the 0ms timer
|
|
174
|
+
* fires — that's the steady-state behavior after the first invocation.
|
|
175
|
+
*
|
|
176
|
+
* On a cache miss we race against `setTimeout(0)` so the user's primary
|
|
177
|
+
* command exit isn't held up by the nudge logic when they don't have
|
|
178
|
+
* a cached answer yet. The in-flight fetch is INTENTIONALLY left
|
|
179
|
+
* running after the race resolves: it has its own 2s `AbortController`
|
|
180
|
+
* (inside `checkForCliUpdate`) and is silent on failure, so the worst
|
|
181
|
+
* case is the process appearing to take slightly longer on its very
|
|
182
|
+
* first invocation while the cache warms. Every subsequent invocation
|
|
183
|
+
* benefits from the 24h positive cache and exits immediately.
|
|
184
|
+
*
|
|
185
|
+
* Errors are swallowed at the source by `checkForCliUpdate` itself; the
|
|
186
|
+
* outer `.catch` is belt-and-braces for the rare case where loading
|
|
187
|
+
* the module synchronously throws.
|
|
188
|
+
*/
|
|
189
|
+
async function runUpdateCheck() {
|
|
190
|
+
let currentVersion;
|
|
191
|
+
try {
|
|
192
|
+
currentVersion = getCliVersion();
|
|
193
|
+
} catch {
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
const checker = checkForCliUpdate({ currentVersion }).catch(() => {});
|
|
197
|
+
const yieldOnce = new Promise((resolve) => setTimeout(resolve, 0));
|
|
198
|
+
await Promise.race([checker, yieldOnce]);
|
|
154
199
|
}
|
|
155
200
|
|
|
156
201
|
// ── Help printing ───────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "skillrepo",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.5.0",
|
|
4
4
|
"description": "Pull-based CLI for agent skills — init, sync, search, add, remove your library from any IDE",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -26,6 +26,7 @@
|
|
|
26
26
|
"license": "SEE LICENSE IN LICENSE",
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"cli-table3": "^0.6.5",
|
|
29
|
-
"gray-matter": "^4.0.3"
|
|
29
|
+
"gray-matter": "^4.0.3",
|
|
30
|
+
"semver": "^7.6.3"
|
|
30
31
|
}
|
|
31
32
|
}
|
package/src/commands/list.mjs
CHANGED
|
@@ -1,21 +1,36 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `skillrepo list` (#
|
|
2
|
+
* `skillrepo list` — list library skills with per-row local drift state (#1555).
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Historically (#679) this only showed the server-side library. As of
|
|
5
|
+
* #1555 the table adds a `Local` column showing how each skill's
|
|
6
|
+
* on-disk placement stacks up against the library + last-sync
|
|
7
|
+
* baseline: `current` / `stale` / `missing` / `edited`. A footer
|
|
8
|
+
* reports the library-level ETag state ("library in sync" vs
|
|
9
|
+
* "library has changed since last sync").
|
|
7
10
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
11
|
+
* Pipeline:
|
|
12
|
+
* 1. `getLibrary` returns the current registry skills + ETag.
|
|
13
|
+
* 2. `readLastSync` reads the v2 `.last-sync` map (per-skill SHAs +
|
|
14
|
+
* versions) for the on-disk-vs-synced comparison.
|
|
15
|
+
* 3. `detectAgents` identifies which vendors actually have footprint
|
|
16
|
+
* on this machine / in this project — we only report drift
|
|
17
|
+
* against vendors the user uses.
|
|
18
|
+
* 4. `walkDetectedPlacements` reads each detected vendor's
|
|
19
|
+
* placement dir and computes SHAs from disk.
|
|
20
|
+
* 5. `computeSkillState` + `rollupState` from `drift.mjs` turn the
|
|
21
|
+
* three axes into a per-vendor state and a per-skill rollup.
|
|
12
22
|
*
|
|
13
23
|
* Flags:
|
|
14
|
-
* --json Pipe-friendly JSON output
|
|
15
|
-
*
|
|
24
|
+
* --json Pipe-friendly JSON output (bare array preserved
|
|
25
|
+
* from #679 — purely additive: new `state` and
|
|
26
|
+
* `placements[]` fields per item, no top-level
|
|
27
|
+
* wrapper).
|
|
28
|
+
* --key/--url Override credentials.
|
|
16
29
|
*
|
|
17
|
-
* No
|
|
18
|
-
*
|
|
30
|
+
* No `--global`, `--detail`, `--vendor`, or `--include-extra` flags
|
|
31
|
+
* in v1 — YAGNI per the epic's decisions log. Project-scope
|
|
32
|
+
* placement only; extra-on-disk skills (in placement but not in
|
|
33
|
+
* library) are hidden by default.
|
|
19
34
|
*/
|
|
20
35
|
|
|
21
36
|
import Table from "cli-table3";
|
|
@@ -23,6 +38,11 @@ import Table from "cli-table3";
|
|
|
23
38
|
import { getLibrary } from "../lib/http.mjs";
|
|
24
39
|
import { resolveFlags } from "../lib/cli-config.mjs";
|
|
25
40
|
import { formatIdentifier } from "../lib/identifier.mjs";
|
|
41
|
+
import { readLastSync } from "../lib/sync.mjs";
|
|
42
|
+
import { detectAgents } from "../lib/detect-agents.mjs";
|
|
43
|
+
import { walkDetectedPlacements } from "../lib/placement-walk.mjs";
|
|
44
|
+
import { getAgentByKey } from "../lib/agent-registry.mjs";
|
|
45
|
+
import { computeSkillState, rollupState, SKILL_STATE } from "../lib/drift.mjs";
|
|
26
46
|
|
|
27
47
|
/**
|
|
28
48
|
* Run `list`. Throws CliError on any failure.
|
|
@@ -36,59 +56,165 @@ export async function runList(argv, io = {}) {
|
|
|
36
56
|
const stdout = io.stdout ?? process.stdout;
|
|
37
57
|
const flags = resolveFlags(argv);
|
|
38
58
|
|
|
39
|
-
const
|
|
59
|
+
const libraryResponse = await getLibrary(flags.serverUrl, flags.apiKey);
|
|
40
60
|
|
|
41
|
-
// `list` does not care about removals or sync state — we just want
|
|
42
|
-
// the current set. Skills are returned by the same endpoint as the
|
|
43
|
-
// sync, but with full file content we discard.
|
|
44
|
-
//
|
|
45
61
|
// Defensive guard: `list` calls getLibrary without an If-None-Match
|
|
46
62
|
// header so it can't legitimately receive a 304 today. But getLibrary's
|
|
47
63
|
// contract documents `notModified: true` as a possible return shape,
|
|
48
64
|
// and a future refactor that adds caching at this layer could
|
|
49
|
-
// accidentally start sending the header.
|
|
50
|
-
|
|
51
|
-
|
|
65
|
+
// accidentally start sending the header.
|
|
66
|
+
const skills = Array.isArray(libraryResponse.skills) ? libraryResponse.skills : [];
|
|
67
|
+
|
|
68
|
+
// Detect agents + walk placements regardless of skill count — we
|
|
69
|
+
// need this info for both the table and the JSON shape.
|
|
70
|
+
const detected = detectAgents().filter((d) => d.detected);
|
|
71
|
+
|
|
72
|
+
// Pre-resolve the detected vendor entries ONCE. `getAgentByKey` is
|
|
73
|
+
// O(N) over the registry; doing this per-skill across 50+ skills
|
|
74
|
+
// was wasted work. We also filter to vendors with a non-null
|
|
75
|
+
// projectTarget here so the per-skill loop only iterates vendors
|
|
76
|
+
// that actually contribute a placement.
|
|
77
|
+
const detectedVendorEntries = detected
|
|
78
|
+
.map((d) => getAgentByKey(d.key))
|
|
79
|
+
.filter((entry) => entry !== null && entry.projectTarget !== null);
|
|
80
|
+
const detectedKeys = detectedVendorEntries.map((e) => e.key);
|
|
81
|
+
|
|
82
|
+
const placementsMap = walkDetectedPlacements(detectedKeys);
|
|
83
|
+
|
|
84
|
+
// Read `.last-sync` ONCE. Direct map access on the parsed
|
|
85
|
+
// `skills` payload — no per-skill helper indirection — for both
|
|
86
|
+
// wasted-I/O reasons (N reads of the same file otherwise) AND
|
|
87
|
+
// a within-run snapshot-consistency window (a concurrent
|
|
88
|
+
// `skillrepo update` writing the file mid-list would otherwise
|
|
89
|
+
// produce inconsistent classifications across skills in the
|
|
90
|
+
// same render).
|
|
91
|
+
const lastSync = readLastSync();
|
|
92
|
+
const lastSyncSkillsMap =
|
|
93
|
+
lastSync && lastSync.skills && typeof lastSync.skills === "object"
|
|
94
|
+
? lastSync.skills
|
|
95
|
+
: {};
|
|
96
|
+
|
|
97
|
+
// Build the augmented per-skill state for every library skill. This
|
|
98
|
+
// is the canonical shape both the table renderer and JSON formatter
|
|
99
|
+
// project from.
|
|
100
|
+
const augmented = skills.map((skill) =>
|
|
101
|
+
augmentSkill(skill, detectedVendorEntries, placementsMap, lastSyncSkillsMap),
|
|
102
|
+
);
|
|
52
103
|
|
|
53
104
|
if (flags.json) {
|
|
54
|
-
stdout.write(JSON.stringify(formatJson(
|
|
105
|
+
stdout.write(JSON.stringify(formatJson(augmented), null, 2) + "\n");
|
|
55
106
|
return;
|
|
56
107
|
}
|
|
57
108
|
|
|
58
|
-
if (
|
|
109
|
+
if (augmented.length === 0) {
|
|
59
110
|
stdout.write(
|
|
60
111
|
"\n Your library is empty.\n Use `skillrepo add <@owner/name>` to add a skill.\n\n",
|
|
61
112
|
);
|
|
62
113
|
return;
|
|
63
114
|
}
|
|
64
115
|
|
|
65
|
-
printTable(
|
|
116
|
+
printTable(augmented, detected, stdout);
|
|
117
|
+
printFooter(augmented, libraryResponse.etag, lastSync, stdout, canUseGlyphs(stdout));
|
|
66
118
|
}
|
|
67
119
|
|
|
120
|
+
// ── Per-skill augmentation ─────────────────────────────────────────────
|
|
121
|
+
|
|
68
122
|
/**
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
123
|
+
* Compute `state` (rollup) + `placements[]` for a library skill.
|
|
124
|
+
*
|
|
125
|
+
* For each detected vendor (pre-resolved by the caller) look up the
|
|
126
|
+
* on-disk placement and the last-sync baseline, classify the state,
|
|
127
|
+
* and emit a placement entry. Then roll the per-vendor states up to
|
|
128
|
+
* a single `state` for the table column.
|
|
129
|
+
*
|
|
130
|
+
* Baseline lookups go through the caller-provided `lastSyncSkillsMap`
|
|
131
|
+
* to avoid re-reading `.last-sync` per skill — both a wasted-I/O
|
|
132
|
+
* concern and a within-run snapshot consistency concern (an
|
|
133
|
+
* in-flight `skillrepo update` could otherwise change the file
|
|
134
|
+
* between our N reads).
|
|
135
|
+
*
|
|
136
|
+
* @param {object} skill - Library skill from `getLibrary` response.
|
|
137
|
+
* @param {import("../lib/agent-registry.mjs").AgentEntry[]} detectedVendorEntries
|
|
138
|
+
* Pre-resolved registry entries for the detected vendors that
|
|
139
|
+
* contribute a project placement. Computed once in `runList` so
|
|
140
|
+
* the registry lookup doesn't happen per skill.
|
|
141
|
+
* @param {Map<string, import("../lib/placement-walk.mjs").LocalPlacement>} placementsMap
|
|
142
|
+
* @param {Record<string, import("../lib/sync.mjs").SyncedSkillEntry>} lastSyncSkillsMap
|
|
143
|
+
* The `skills` map from `.last-sync` v2, captured once before the
|
|
144
|
+
* per-skill loop. Lookup is a direct dictionary access, not a
|
|
145
|
+
* re-read of the file.
|
|
75
146
|
*/
|
|
76
|
-
function
|
|
77
|
-
|
|
78
|
-
|
|
147
|
+
function augmentSkill(skill, detectedVendorEntries, placementsMap, lastSyncSkillsMap) {
|
|
148
|
+
const baselineKey = `${skill.owner}/${skill.name}`;
|
|
149
|
+
const baseline = validateBaselineShape(lastSyncSkillsMap[baselineKey]);
|
|
150
|
+
const placements = [];
|
|
151
|
+
|
|
152
|
+
for (const entry of detectedVendorEntries) {
|
|
153
|
+
const placementKey = `${entry.key}::project::${skill.name}`;
|
|
154
|
+
const onDisk = placementsMap.get(placementKey) ?? null;
|
|
155
|
+
|
|
156
|
+
const localPlacement = onDisk
|
|
157
|
+
? {
|
|
158
|
+
present: true,
|
|
159
|
+
skillMdSha256: onDisk.skillMdSha256,
|
|
160
|
+
filesSha256: onDisk.filesSha256,
|
|
161
|
+
}
|
|
162
|
+
: { present: false, skillMdSha256: null, filesSha256: null };
|
|
163
|
+
|
|
164
|
+
const state = computeSkillState({
|
|
165
|
+
libraryVersion: skill.version ?? null,
|
|
166
|
+
lastSyncEntry: baseline,
|
|
167
|
+
localPlacement,
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
placements.push({
|
|
171
|
+
vendor: entry.key,
|
|
172
|
+
scope: "project",
|
|
173
|
+
state,
|
|
174
|
+
// `localVersion` is the version that was synced (from
|
|
175
|
+
// `.last-sync`) — NOT a version pulled from on-disk
|
|
176
|
+
// frontmatter, which the spec doesn't mandate. When there's no
|
|
177
|
+
// baseline, this is null and the `state` will already be
|
|
178
|
+
// `missing`, so the field is honest about what we know.
|
|
179
|
+
localVersion: baseline?.version ?? null,
|
|
180
|
+
});
|
|
79
181
|
}
|
|
80
|
-
|
|
81
|
-
|
|
182
|
+
|
|
183
|
+
const state = rollupState(placements.map((p) => p.state));
|
|
184
|
+
|
|
185
|
+
return { ...skill, state, placements };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Reject a `.last-sync` entry that's missing required string fields.
|
|
190
|
+
* A tampered or partially-written entry returns null, which
|
|
191
|
+
* `computeSkillState` treats as "no baseline → missing" — the
|
|
192
|
+
* conservative verdict for cache state we can't trust.
|
|
193
|
+
*/
|
|
194
|
+
function validateBaselineShape(entry) {
|
|
195
|
+
if (!entry || typeof entry !== "object") return null;
|
|
196
|
+
if (
|
|
197
|
+
typeof entry.version !== "string" ||
|
|
198
|
+
typeof entry.skillMdSha256 !== "string" ||
|
|
199
|
+
typeof entry.filesSha256 !== "string"
|
|
200
|
+
) {
|
|
201
|
+
return null;
|
|
82
202
|
}
|
|
83
|
-
return
|
|
203
|
+
return entry;
|
|
84
204
|
}
|
|
85
205
|
|
|
206
|
+
// ── JSON formatter ─────────────────────────────────────────────────────
|
|
207
|
+
|
|
86
208
|
/**
|
|
87
|
-
*
|
|
88
|
-
*
|
|
209
|
+
* Pipe-friendly bare array, additive on top of the #679 shape.
|
|
210
|
+
* Existing scripts that read `[0].owner`, etc. keep working. New
|
|
211
|
+
* fields: `state` (rollup) and `placements[]` (per-vendor truth).
|
|
212
|
+
*
|
|
213
|
+
* The footer never appears in JSON — `list --json` is consumed by
|
|
214
|
+
* scripts, and the ETag-state footer is a human-only signal.
|
|
89
215
|
*/
|
|
90
|
-
function formatJson(
|
|
91
|
-
return
|
|
216
|
+
function formatJson(augmented) {
|
|
217
|
+
return augmented
|
|
92
218
|
.slice()
|
|
93
219
|
.sort(sortByOwnerAndName)
|
|
94
220
|
.map((s) => ({
|
|
@@ -98,17 +224,34 @@ function formatJson(skills) {
|
|
|
98
224
|
description: s.description,
|
|
99
225
|
updatedAt: s.updatedAt,
|
|
100
226
|
filesIncomplete: s.filesIncomplete ?? false,
|
|
227
|
+
state: s.state,
|
|
228
|
+
placements: s.placements,
|
|
101
229
|
}));
|
|
102
230
|
}
|
|
103
231
|
|
|
104
|
-
|
|
105
|
-
|
|
232
|
+
// ── Human-output rendering ─────────────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
function printTable(augmented, detected, out) {
|
|
235
|
+
const useGlyphs = canUseGlyphs(out);
|
|
236
|
+
|
|
237
|
+
// "Detected:" line uses displayName for polish. Empty when no
|
|
238
|
+
// vendors detected; that situation also produces all-`missing`
|
|
239
|
+
// states, which the footer covers.
|
|
240
|
+
if (detected.length > 0) {
|
|
241
|
+
const detectedLabel = detected
|
|
242
|
+
.map((d) => `${d.displayName} (project)`)
|
|
243
|
+
.join(", ");
|
|
244
|
+
out.write(`\n Detected: ${detectedLabel}\n`);
|
|
245
|
+
} else {
|
|
246
|
+
out.write(
|
|
247
|
+
"\n No agents detected in this project — drift cannot be reported.\n",
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const sorted = augmented.slice().sort(sortByOwnerAndName);
|
|
106
252
|
|
|
107
|
-
// cli-table3 supports word wrapping but we manually truncate
|
|
108
|
-
// descriptions so the table stays readable on standard 80-col
|
|
109
|
-
// terminals. The full description is available via --json.
|
|
110
253
|
const table = new Table({
|
|
111
|
-
head: ["Skill", "
|
|
254
|
+
head: ["Skill", "Library", "Local", "Updated", "Description"],
|
|
112
255
|
colWidths: computeColWidths(streamColumns(out)),
|
|
113
256
|
wordWrap: true,
|
|
114
257
|
style: { head: ["bold"] },
|
|
@@ -118,26 +261,156 @@ function printTable(skills, out) {
|
|
|
118
261
|
table.push([
|
|
119
262
|
formatIdentifier(s),
|
|
120
263
|
s.version || "—",
|
|
264
|
+
renderLocalCell(s.state, useGlyphs),
|
|
121
265
|
formatRelativeDate(s.updatedAt),
|
|
122
266
|
truncate(s.description || "", 60),
|
|
123
267
|
]);
|
|
124
268
|
}
|
|
125
269
|
|
|
126
270
|
out.write("\n" + table.toString() + "\n\n");
|
|
127
|
-
|
|
271
|
+
|
|
272
|
+
const driftCount = sorted.filter((s) => s.state !== SKILL_STATE.CURRENT).length;
|
|
273
|
+
if (driftCount === 0) {
|
|
274
|
+
out.write(
|
|
275
|
+
` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library.\n`,
|
|
276
|
+
);
|
|
277
|
+
} else {
|
|
278
|
+
out.write(
|
|
279
|
+
` ${sorted.length} skill${sorted.length === 1 ? "" : "s"} in your library. ` +
|
|
280
|
+
`${driftCount} need${driftCount === 1 ? "s" : ""} attention.\n`,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* Library footer — answers "is my local copy in sync with the
|
|
287
|
+
* registry's manifest right now?" Distinct from per-row drift,
|
|
288
|
+
* which answers "is each individual skill's on-disk content
|
|
289
|
+
* current?"
|
|
290
|
+
*
|
|
291
|
+
* Four cases — order matters for the "least confusing precedence":
|
|
292
|
+
*
|
|
293
|
+
* - No `.last-sync` at all → "no sync history." The user has
|
|
294
|
+
* never run `update`; per-row drift is all `missing` anyway.
|
|
295
|
+
* A clear "you need to run update" line is the right message.
|
|
296
|
+
*
|
|
297
|
+
* - ETag matches AND every skill is current → fully in sync.
|
|
298
|
+
* The reassuring case.
|
|
299
|
+
*
|
|
300
|
+
* - ETag matches BUT some rows show drift → library hasn't
|
|
301
|
+
* changed but local placements have. User ran update at some
|
|
302
|
+
* point but then either edited files or has missing
|
|
303
|
+
* placements. "Library in sync, run update to refresh"
|
|
304
|
+
* reflects this without lying about what's wrong.
|
|
305
|
+
*
|
|
306
|
+
* - ETag differs → library has new content since last sync.
|
|
307
|
+
* "Library has changed, run update."
|
|
308
|
+
*
|
|
309
|
+
* The footer NEVER appears in `--json` output (caller already
|
|
310
|
+
* handles that in `runList`).
|
|
311
|
+
*/
|
|
312
|
+
function printFooter(augmented, libraryEtag, lastSync, out, useGlyphs) {
|
|
313
|
+
if (augmented.length === 0) return; // Empty-library branch already wrote its own message.
|
|
314
|
+
|
|
315
|
+
// Match the renderLocalCell glyph/ASCII policy. Pre-#1555 the footer
|
|
316
|
+
// used Unicode unconditionally even when the table fell back to
|
|
317
|
+
// ASCII for non-TTY / NO_COLOR. Inconsistency made piped output
|
|
318
|
+
// half-styled.
|
|
319
|
+
const ok = useGlyphs ? "✓" : "[ok]";
|
|
320
|
+
const warn = useGlyphs ? "⚠" : "[!]";
|
|
321
|
+
|
|
322
|
+
const driftCount = augmented.filter((s) => s.state !== SKILL_STATE.CURRENT).length;
|
|
323
|
+
|
|
324
|
+
if (!lastSync || !lastSync.etag) {
|
|
325
|
+
out.write(
|
|
326
|
+
"\n No sync history on this machine — run `skillrepo update` to fetch your skills.\n\n",
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const etagMatches =
|
|
332
|
+
typeof libraryEtag === "string" && libraryEtag !== "" && libraryEtag === lastSync.etag;
|
|
333
|
+
|
|
334
|
+
if (etagMatches && driftCount === 0) {
|
|
335
|
+
out.write(`\n ${ok} library in sync — local skills up to date.\n\n`);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
if (etagMatches && driftCount > 0) {
|
|
340
|
+
out.write(
|
|
341
|
+
`\n ${warn} library in sync — but ${driftCount} skill${
|
|
342
|
+
driftCount === 1 ? "" : "s"
|
|
343
|
+
} show${driftCount === 1 ? "s" : ""} local drift.\n` +
|
|
344
|
+
" Run `skillrepo update` to refresh.\n\n",
|
|
345
|
+
);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ETag differs (or one side is missing the etag — treat as differs,
|
|
350
|
+
// since we can't claim sync).
|
|
351
|
+
out.write(
|
|
352
|
+
`\n ${warn} library has changed since last sync — run \`skillrepo update\`.\n\n`,
|
|
353
|
+
);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
// ── Rendering helpers ──────────────────────────────────────────────────
|
|
357
|
+
|
|
358
|
+
/** Glyphs vs ASCII tokens, per NO_COLOR / non-TTY. */
|
|
359
|
+
function canUseGlyphs(stream) {
|
|
360
|
+
if (process.env.NO_COLOR) return false;
|
|
361
|
+
// `isTTY` is undefined on capture streams; treat as "no TTY → ASCII"
|
|
362
|
+
// since CI and pipe consumers would otherwise see escape codes /
|
|
363
|
+
// glyphs they can't render uniformly.
|
|
364
|
+
return stream && stream.isTTY === true;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function renderLocalCell(state, useGlyphs) {
|
|
368
|
+
switch (state) {
|
|
369
|
+
case SKILL_STATE.CURRENT:
|
|
370
|
+
return useGlyphs ? "✓" : "OK";
|
|
371
|
+
case SKILL_STATE.STALE:
|
|
372
|
+
return useGlyphs ? "⚠ stale" : "STALE";
|
|
373
|
+
case SKILL_STATE.MISSING:
|
|
374
|
+
return useGlyphs ? "✗ miss" : "MISS";
|
|
375
|
+
case SKILL_STATE.EDITED:
|
|
376
|
+
return useGlyphs ? "✎ edit" : "EDIT";
|
|
377
|
+
default:
|
|
378
|
+
return useGlyphs ? "?" : "?";
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Read the column width from the active output stream, falling back
|
|
384
|
+
* to process.stdout's TTY column count if the injected stream
|
|
385
|
+
* doesn't carry one (e.g., a test capture stream), then to a 100-col
|
|
386
|
+
* default for non-TTY contexts.
|
|
387
|
+
*/
|
|
388
|
+
function streamColumns(out) {
|
|
389
|
+
if (out && typeof out.columns === "number" && out.columns > 0) {
|
|
390
|
+
return out.columns;
|
|
391
|
+
}
|
|
392
|
+
if (process.stdout.columns && process.stdout.columns > 0) {
|
|
393
|
+
return process.stdout.columns;
|
|
394
|
+
}
|
|
395
|
+
return 100;
|
|
128
396
|
}
|
|
129
397
|
|
|
130
398
|
function computeColWidths(terminalColumns) {
|
|
131
|
-
// Cap at 120 so the table doesn't get unreadably wide on
|
|
132
|
-
//
|
|
133
|
-
//
|
|
399
|
+
// Cap at 120 so the table doesn't get unreadably wide on ultra-
|
|
400
|
+
// wide terminals; floor at 100 so the description still has room.
|
|
401
|
+
// The Local column is fixed-width (10 chars — biggest glyph cell
|
|
402
|
+
// is "✎ edit" plus padding).
|
|
134
403
|
const total = terminalColumns > 60 ? Math.min(terminalColumns, 120) : 100;
|
|
135
|
-
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
const updatedCol =
|
|
139
|
-
|
|
140
|
-
|
|
404
|
+
const skillCol = 30;
|
|
405
|
+
const libraryCol = 10;
|
|
406
|
+
const localCol = 10;
|
|
407
|
+
const updatedCol = 12;
|
|
408
|
+
// -8 accounts for the 6 between-column borders
|
|
409
|
+
const descCol = Math.max(
|
|
410
|
+
20,
|
|
411
|
+
total - skillCol - libraryCol - localCol - updatedCol - 8,
|
|
412
|
+
);
|
|
413
|
+
return [skillCol, libraryCol, localCol, updatedCol, descCol];
|
|
141
414
|
}
|
|
142
415
|
|
|
143
416
|
function truncate(s, n) {
|
|
@@ -164,7 +437,6 @@ function formatRelativeDate(iso) {
|
|
|
164
437
|
if (days < 7) return `${days}d ago`;
|
|
165
438
|
const weeks = Math.floor(days / 7);
|
|
166
439
|
if (weeks < 26) return `${weeks}w ago`;
|
|
167
|
-
// Older than ~6 months: fall back to a date
|
|
168
440
|
return new Date(iso).toISOString().slice(0, 10);
|
|
169
441
|
}
|
|
170
442
|
|