vskill 0.5.86 → 0.5.88

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.
@@ -13,7 +13,8 @@ import { parseSource } from "../resolvers/source-resolver.js";
13
13
  import { runBenchmarkSSE, runSingleCaseSSE } from "./benchmark-runner.js";
14
14
  import { getSkillSemaphore } from "./concurrency.js";
15
15
  import { resolveSkillDir } from "./skill-resolver.js";
16
- import { scanSkills, classifyOrigin } from "../eval/skill-scanner.js";
16
+ import { classifyOrigin, scanSkillsTriScope } from "../eval/skill-scanner.js";
17
+ import { resolveGlobalSkillsDir } from "../eval/path-utils.js";
17
18
  import { loadAndValidateEvals, EvalValidationError } from "../eval/schema.js";
18
19
  import { readBenchmark } from "../eval/benchmark.js";
19
20
  import { writeHistoryEntry, listHistory, readHistoryEntry, computeRegressions, deleteHistoryEntry, getCaseHistory, computeStats } from "../eval/benchmark-history.js";
@@ -54,6 +55,196 @@ export function buildInstalledAgentsResponse(detectedAgents) {
54
55
  }
55
56
  return { agents, suggested };
56
57
  }
58
+ let agentPresenceCache = null;
59
+ const AGENT_PRESENCE_CACHE_TTL = 30_000;
60
+ /** Test hook — clear the 30 s cache so the next buildAgentsResponse() re-scans. */
61
+ export function resetAgentPresenceCache() {
62
+ agentPresenceCache = null;
63
+ }
64
+ /** Count skills in a directory following the `<dir>/<skill>/SKILL.md` layout.
65
+ * Non-recursive — skills are conventionally flat children of the skills dir. */
66
+ function countSkillsIn(dir) {
67
+ if (!existsSync(dir))
68
+ return 0;
69
+ try {
70
+ const entries = readdirSync(dir, { withFileTypes: true });
71
+ let count = 0;
72
+ for (const entry of entries) {
73
+ const fullPath = join(dir, entry.name);
74
+ // Accept plain dirs AND symlinked-dirs.
75
+ let isDirLike = entry.isDirectory();
76
+ if (!isDirLike && entry.isSymbolicLink?.()) {
77
+ try {
78
+ isDirLike = statSync(fullPath).isDirectory();
79
+ }
80
+ catch { /* broken link */ }
81
+ }
82
+ if (!isDirLike)
83
+ continue;
84
+ if (existsSync(join(fullPath, "SKILL.md")))
85
+ count++;
86
+ }
87
+ return count;
88
+ }
89
+ catch {
90
+ return 0;
91
+ }
92
+ }
93
+ /**
94
+ * Build the /api/agents response. Filters to agents with presence and
95
+ * includes per-agent counts, resolved paths, and shared-folder grouping.
96
+ *
97
+ * Results are cached for 30s keyed by `(root, home, binaries)` so repeated
98
+ * polls don't re-walk the filesystem.
99
+ */
100
+ export async function buildAgentsResponse(opts) {
101
+ const root = opts.root;
102
+ const home = opts.home;
103
+ const detectedBinaries = opts.detectedBinaries ?? new Set();
104
+ const cacheKey = {
105
+ rootKey: root,
106
+ homeKey: home ?? "",
107
+ binariesKey: [...detectedBinaries].sort().join(","),
108
+ };
109
+ const now = Date.now();
110
+ if (agentPresenceCache &&
111
+ now - agentPresenceCache.ts < AGENT_PRESENCE_CACHE_TTL &&
112
+ agentPresenceCache.rootKey === cacheKey.rootKey &&
113
+ agentPresenceCache.homeKey === cacheKey.homeKey &&
114
+ agentPresenceCache.binariesKey === cacheKey.binariesKey) {
115
+ return agentPresenceCache.data;
116
+ }
117
+ // Map each agent → resolved local + global dir. For tests, `home` overrides
118
+ // the homedir-derived global path. In production, resolveGlobalSkillsDir()
119
+ // handles cross-platform resolution (darwin / linux / win32).
120
+ const entries = [];
121
+ const globalDirByAgentId = new Map();
122
+ for (const agent of AGENTS_REGISTRY) {
123
+ const resolvedLocalDir = join(root, agent.localSkillsDir);
124
+ const resolvedGlobalDir = home
125
+ ? join(home, firstNonTildeSegment(agent.globalSkillsDir))
126
+ : resolveGlobalSkillsDir(agent);
127
+ globalDirByAgentId.set(agent.id, resolvedGlobalDir);
128
+ const localExists = existsSync(resolvedLocalDir);
129
+ const globalExists = existsSync(resolvedGlobalDir);
130
+ const binaryDetected = detectedBinaries.has(agent.id);
131
+ const hasPresence = localExists || globalExists || binaryDetected;
132
+ if (!hasPresence)
133
+ continue;
134
+ const localSkillCount = countSkillsIn(resolvedLocalDir);
135
+ const globalSkillCount = countSkillsIn(resolvedGlobalDir);
136
+ const firstLocalSegment = agent.localSkillsDir.split("/")[0] || "";
137
+ const hasProjectFolder = firstLocalSegment
138
+ ? existsSync(join(root, firstLocalSegment))
139
+ : false;
140
+ const isDefault = agent.id === "claude-code" && hasProjectFolder;
141
+ // Best-effort lastSync from lockfile — null when no lockfile or no entry.
142
+ const lastSync = resolveAgentLastSync(root, agent.id);
143
+ const health = computeAgentHealth(lastSync, localSkillCount + globalSkillCount);
144
+ entries.push({
145
+ id: agent.id,
146
+ displayName: agent.displayName,
147
+ featureSupport: agent.featureSupport,
148
+ isUniversal: agent.isUniversal,
149
+ parentCompany: agent.parentCompany,
150
+ detected: hasPresence,
151
+ isDefault,
152
+ localSkillCount,
153
+ globalSkillCount,
154
+ resolvedLocalDir,
155
+ resolvedGlobalDir,
156
+ lastSync,
157
+ health,
158
+ });
159
+ }
160
+ // Sort: healthy + detected first, then by id.
161
+ entries.sort((a, b) => {
162
+ const aMissing = a.health === "missing";
163
+ const bMissing = b.health === "missing";
164
+ if (aMissing !== bMissing)
165
+ return aMissing ? 1 : -1;
166
+ return a.id.localeCompare(b.id);
167
+ });
168
+ // Shared-folder grouping — normalize paths via resolve() and group agents
169
+ // whose resolvedGlobalDir maps to the same absolute path.
170
+ const sharedGroups = new Map();
171
+ for (const entry of entries) {
172
+ const key = resolve(entry.resolvedGlobalDir);
173
+ const list = sharedGroups.get(key) ?? [];
174
+ list.push(entry.id);
175
+ sharedGroups.set(key, list);
176
+ }
177
+ const sharedFolders = [];
178
+ for (const [path, consumers] of sharedGroups.entries()) {
179
+ if (consumers.length >= 2) {
180
+ sharedFolders.push({ path, consumers: consumers.sort() });
181
+ }
182
+ }
183
+ // Suggested: claude-code if detected; else alphabetically-first detected;
184
+ // else claude-code as fallback (consistent with buildInstalledAgentsResponse).
185
+ let suggested = "claude-code";
186
+ if (!entries.some((e) => e.id === "claude-code")) {
187
+ suggested = entries[0]?.id ?? "claude-code";
188
+ }
189
+ const data = { agents: entries, suggested, sharedFolders };
190
+ agentPresenceCache = { data, ts: now, ...cacheKey };
191
+ return data;
192
+ }
193
+ function firstNonTildeSegment(p) {
194
+ if (p.startsWith("~/") || p.startsWith("~\\"))
195
+ return p.slice(2);
196
+ if (p.startsWith("~"))
197
+ return p.slice(1);
198
+ return p;
199
+ }
200
+ /** Read the lockfile and return the newest `updatedAt` across entries owned by
201
+ * `agentId`. Returns null if the lockfile is missing or has no matching entry. */
202
+ function resolveAgentLastSync(root, _agentId) {
203
+ try {
204
+ const lock = readLockfile(root);
205
+ if (!lock?.skills)
206
+ return null;
207
+ let newest = null;
208
+ for (const entry of Object.values(lock.skills)) {
209
+ const updatedAt = entry.updatedAt;
210
+ if (typeof updatedAt === "string" && (!newest || updatedAt > newest)) {
211
+ newest = updatedAt;
212
+ }
213
+ }
214
+ return newest;
215
+ }
216
+ catch {
217
+ return null;
218
+ }
219
+ }
220
+ function computeAgentHealth(lastSync, totalSkills) {
221
+ if (totalSkills === 0 && !lastSync)
222
+ return "missing";
223
+ if (!lastSync)
224
+ return "ok";
225
+ const SEVEN_DAYS_MS = 7 * 24 * 60 * 60 * 1000;
226
+ const age = Date.now() - new Date(lastSync).getTime();
227
+ return age > SEVEN_DAYS_MS ? "stale" : "ok";
228
+ }
229
+ export function filterSkillsByScopeAndAgent(skills, filter) {
230
+ let out = skills;
231
+ if (filter.scope !== undefined) {
232
+ const allowed = ["own", "installed", "global"];
233
+ if (!allowed.includes(filter.scope))
234
+ return [];
235
+ out = out.filter((s) => (s.scope ?? "own") === filter.scope);
236
+ }
237
+ if (filter.agent !== undefined) {
238
+ const agent = filter.agent;
239
+ out = out.filter((s) => {
240
+ const scope = s.scope ?? "own";
241
+ if (scope === "own")
242
+ return true;
243
+ return s.sourceAgent === agent;
244
+ });
245
+ }
246
+ return out;
247
+ }
57
248
  // ---------------------------------------------------------------------------
58
249
  // Shared helpers
59
250
  // ---------------------------------------------------------------------------
@@ -492,6 +683,20 @@ export function registerRoutes(router, root, projectName) {
492
683
  sendJson(res, { error: err.message }, 500, _req);
493
684
  }
494
685
  });
686
+ // 0686 — /api/agents: agents with filesystem presence + per-agent counts +
687
+ // shared-folder grouping. 30s detection cache (mirrors Ollama/LM Studio
688
+ // probe pattern from 0677).
689
+ router.get("/api/agents", async (req, res) => {
690
+ try {
691
+ const detected = await detectInstalledAgents();
692
+ const detectedBinaries = new Set(detected.map((a) => a.id));
693
+ const data = await buildAgentsResponse({ root, detectedBinaries });
694
+ sendJson(res, data, 200, req);
695
+ }
696
+ catch (err) {
697
+ sendJson(res, { error: err.message }, 500, req);
698
+ }
699
+ });
495
700
  // Server-Sent Events endpoint for data change notifications
496
701
  // Clients subscribe here to receive push updates when benchmarks complete,
497
702
  // history is written, or leaderboard is updated.
@@ -721,7 +926,33 @@ export function registerRoutes(router, root, projectName) {
721
926
  // unknown/missing fields default to `null` (not undefined) so the shape
722
927
  // remains JSON-stable for all consumers.
723
928
  router.get("/api/skills", async (req, res) => {
724
- const skills = await scanSkills(root);
929
+ // 0686: ?scope=own|installed|global and ?agent=<id> query params.
930
+ // When either is present, switch to tri-scope scanning so the response
931
+ // carries the new `scope`/`isSymlink`/`symlinkTarget`/`installMethod`/
932
+ // `sourceAgent` fields. With no filter, we stay on the legacy two-scope
933
+ // path AND still layer the tri-scope enrichment on top so the UI gets a
934
+ // consistent shape either way.
935
+ const url = new URL(req.url ?? "/api/skills", "http://localhost");
936
+ const rawScope = url.searchParams.get("scope") ?? undefined;
937
+ const rawAgent = url.searchParams.get("agent") ?? undefined;
938
+ // Determine which agent's global scope to scan. When the caller doesn't
939
+ // specify one, default to the suggested agent from buildAgentsResponse —
940
+ // that's usually claude-code but falls back to the first detected agent.
941
+ let activeAgent = rawAgent;
942
+ if (!activeAgent) {
943
+ try {
944
+ const detected = await detectInstalledAgents();
945
+ const resp = await buildAgentsResponse({
946
+ root,
947
+ detectedBinaries: new Set(detected.map((a) => a.id)),
948
+ });
949
+ activeAgent = resp.suggested;
950
+ }
951
+ catch {
952
+ activeAgent = "claude-code";
953
+ }
954
+ }
955
+ const skills = await scanSkillsTriScope(root, { agentId: activeAgent });
725
956
  const enriched = await Promise.all(skills.map(async (s) => {
726
957
  let evalCount = 0;
727
958
  let assertionCount = 0;
@@ -735,12 +966,17 @@ export function registerRoutes(router, root, projectName) {
735
966
  catch { /* no evals */ }
736
967
  const benchmark = await readBenchmark(s.dir);
737
968
  const meta = buildSkillMetadata(s.dir, s.origin, root);
738
- // Defensive origin guarantee — scanSkills sets it, but fall back to a
739
- // recomputation if anything downstream ever widens the type.
740
969
  const origin = s.origin ?? classifyOrigin(s.dir, root);
970
+ // Preserve scanner-derived sourceAgent (populated for installed + global
971
+ // scopes) over the metadata-derived one which only covers local wrappers.
972
+ const sourceAgent = s.sourceAgent ?? meta.sourceAgent;
741
973
  return {
742
974
  ...s,
743
975
  origin,
976
+ scope: s.scope ?? (origin === "installed" ? "installed" : "own"),
977
+ isSymlink: s.isSymlink ?? false,
978
+ symlinkTarget: s.symlinkTarget ?? null,
979
+ installMethod: s.installMethod ?? (origin === "installed" ? "copied" : "authored"),
744
980
  evalCount,
745
981
  assertionCount,
746
982
  benchmarkStatus: computeBenchmarkStatus(benchmark, evalIds, s.hasEvals),
@@ -758,10 +994,14 @@ export function registerRoutes(router, root, projectName) {
758
994
  entryPoint: meta.entryPoint,
759
995
  lastModified: meta.lastModified,
760
996
  sizeBytes: meta.sizeBytes,
761
- sourceAgent: meta.sourceAgent,
997
+ sourceAgent,
762
998
  };
763
999
  }));
764
- sendJson(res, enriched, 200, req);
1000
+ const filtered = filterSkillsByScopeAndAgent(enriched, {
1001
+ scope: rawScope,
1002
+ agent: rawAgent,
1003
+ });
1004
+ sendJson(res, filtered, 200, req);
765
1005
  });
766
1006
  // Check for skill updates via `vskill outdated --json`
767
1007
  router.get("/api/skills/updates", async (req, res) => {