vskill 0.5.85 → 0.5.87

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";
@@ -28,6 +29,9 @@ import { testActivation } from "../eval/activation-tester.js";
28
29
  import { detectMcpDependencies, detectSkillDependencies } from "../eval/mcp-detector.js";
29
30
  import { writeActivationRun, listActivationRuns, getActivationRun } from "../eval/activation-history.js";
30
31
  import { AGENTS_REGISTRY, detectInstalledAgents } from "../agents/agents-registry.js";
32
+ import { resolveOllamaBaseUrl } from "../eval/env.js";
33
+ import * as settingsStore from "./settings-store.js";
34
+ import { loadStudioSelection, saveStudioSelection } from "./studio-json.js";
31
35
  /**
32
36
  * Build the response for GET /api/agents/installed.
33
37
  * Returns all known agents with installed flag based on detected agents.
@@ -51,6 +55,196 @@ export function buildInstalledAgentsResponse(detectedAgents) {
51
55
  }
52
56
  return { agents, suggested };
53
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
+ }
54
248
  // ---------------------------------------------------------------------------
55
249
  // Shared helpers
56
250
  // ---------------------------------------------------------------------------
@@ -319,6 +513,10 @@ const PROVIDER_MODELS = {
319
513
  const PROBE_CACHE_TTL = 30_000; // re-probe every 30s
320
514
  let ollamaCache = null;
321
515
  let lmStudioCache = null;
516
+ export const OPENROUTER_CACHE = new Map();
517
+ export function resetOpenRouterCache() {
518
+ OPENROUTER_CACHE.clear();
519
+ }
322
520
  /** Test hook: clear all probe caches so the next detectAvailableProviders() re-probes. */
323
521
  export function resetDetectionCache() {
324
522
  ollamaCache = null;
@@ -332,7 +530,7 @@ async function probeOllama() {
332
530
  let models = PROVIDER_MODELS["ollama"];
333
531
  let available = false;
334
532
  try {
335
- const baseUrl = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
533
+ const baseUrl = resolveOllamaBaseUrl(process.env);
336
534
  const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(500) });
337
535
  if (resp.ok) {
338
536
  available = true;
@@ -374,27 +572,83 @@ async function probeLmStudio() {
374
572
  lmStudioCache = { available, models, ts: now };
375
573
  return lmStudioCache;
376
574
  }
575
+ const DETECTION_WRAPPER_FOLDERS = [
576
+ ".claude",
577
+ ".cursor",
578
+ ".codex",
579
+ ".gemini",
580
+ ".github",
581
+ ".zed",
582
+ ".specweave",
583
+ ];
584
+ const DETECTION_BINARIES = ["claude", "cursor", "codex", "gemini"];
585
+ let detectionCache = null;
586
+ const DETECTION_CACHE_TTL = 30_000;
587
+ export function resetProjectDetectionCache() {
588
+ detectionCache = null;
589
+ }
590
+ /**
591
+ * Scan the project root for known agent wrapper folders and the system
592
+ * PATH for known agent binaries. Cheap synchronous scan (`existsSync` +
593
+ * `which`) cached for 30 s so repeated `/api/config` polls don't burn CPU.
594
+ */
595
+ export function detectProjectAgents(root) {
596
+ const now = Date.now();
597
+ if (detectionCache && now - detectionCache.ts < DETECTION_CACHE_TTL) {
598
+ return detectionCache.data;
599
+ }
600
+ const wrapperFolders = {};
601
+ for (const folder of DETECTION_WRAPPER_FOLDERS) {
602
+ try {
603
+ wrapperFolders[folder] = existsSync(join(root, folder));
604
+ }
605
+ catch {
606
+ wrapperFolders[folder] = false;
607
+ }
608
+ }
609
+ const binaries = {};
610
+ for (const bin of DETECTION_BINARIES) {
611
+ binaries[bin] = isBinaryOnPath(bin);
612
+ }
613
+ const data = { wrapperFolders, binaries };
614
+ detectionCache = { data, ts: now };
615
+ return data;
616
+ }
617
+ function isBinaryOnPath(name) {
618
+ try {
619
+ const cmd = process.platform === "win32" ? `where ${name}` : `command -v ${name}`;
620
+ execSync(cmd, { stdio: "ignore", timeout: 1000 });
621
+ return true;
622
+ }
623
+ catch {
624
+ return false;
625
+ }
626
+ }
377
627
  export async function detectAvailableProviders() {
378
628
  const providers = [];
379
- // Claude CLI — always available for the eval server (runs in a separate terminal)
629
+ // Claude CLI — delegates to the `claude` binary; the CLI owns session auth.
630
+ // See src/eval/llm.ts createClaudeCliClient compliance doc-block.
380
631
  providers.push({
381
632
  id: "claude-cli",
382
- label: "Claude (Max/Pro subscription)",
633
+ label: "Use current Claude Code session",
383
634
  available: true,
384
635
  models: PROVIDER_MODELS["claude-cli"],
385
636
  });
386
- // Anthropic API — available if ANTHROPIC_API_KEY is set
637
+ // Anthropic API — available if ANTHROPIC_API_KEY is set OR a key is in the
638
+ // settings-store (browser tier or Darwin keychain).
387
639
  providers.push({
388
640
  id: "anthropic",
389
- label: "Anthropic API (requires key)",
390
- available: !!process.env.ANTHROPIC_API_KEY,
641
+ label: "Anthropic API",
642
+ available: !!process.env.ANTHROPIC_API_KEY ||
643
+ settingsStore.hasKeySync("anthropic"),
391
644
  models: PROVIDER_MODELS["anthropic"],
392
645
  });
393
- // OpenRouter — available if OPENROUTER_API_KEY is set
646
+ // OpenRouter — available if OPENROUTER_API_KEY is set OR a key is stored.
394
647
  providers.push({
395
648
  id: "openrouter",
396
- label: "OpenRouter (100+ models, requires key)",
397
- available: !!process.env.OPENROUTER_API_KEY,
649
+ label: "OpenRouter",
650
+ available: !!process.env.OPENROUTER_API_KEY ||
651
+ settingsStore.hasKeySync("openrouter"),
398
652
  models: PROVIDER_MODELS["openrouter"],
399
653
  });
400
654
  // Local providers (Ollama + LM Studio) — cached probes fired in parallel so
@@ -429,6 +683,20 @@ export function registerRoutes(router, root, projectName) {
429
683
  sendJson(res, { error: err.message }, 500, _req);
430
684
  }
431
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
+ });
432
700
  // Server-Sent Events endpoint for data change notifications
433
701
  // Clients subscribe here to receive push updates when benchmarks complete,
434
702
  // history is written, or leaderboard is updated.
@@ -454,19 +722,41 @@ export function registerRoutes(router, root, projectName) {
454
722
  req.on("close", cleanup);
455
723
  req.on("aborted", cleanup);
456
724
  });
457
- // OpenRouter model search proxy
725
+ // OpenRouter model search proxy — 10-minute in-memory cache keyed by the
726
+ // last-8 chars of the API key so different keys don't collide while the
727
+ // key itself is never stored in the cache map. Stale cache served (with
728
+ // X-Vskill-Catalog-Age header) when upstream is down.
458
729
  router.get("/api/openrouter/models", async (_req, res) => {
459
- const apiKey = process.env.OPENROUTER_API_KEY;
730
+ const envKey = process.env.OPENROUTER_API_KEY;
731
+ const storedKey = settingsStore.readKeySync("openrouter");
732
+ const apiKey = envKey || storedKey;
460
733
  if (!apiKey) {
461
734
  sendJson(res, { error: "OPENROUTER_API_KEY not configured" }, 400);
462
735
  return;
463
736
  }
737
+ const cacheKey = apiKey.slice(-8);
738
+ const now = Date.now();
739
+ const cached = OPENROUTER_CACHE.get(cacheKey);
740
+ const CACHE_TTL_MS = 600_000; // 10 min
741
+ // Fresh cache hit — serve immediately without upstream.
742
+ if (cached && now - cached.fetchedAt < CACHE_TTL_MS) {
743
+ const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
744
+ res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
745
+ sendJson(res, { models: cached.value, ageSec });
746
+ return;
747
+ }
464
748
  try {
465
749
  const resp = await fetch("https://openrouter.ai/api/v1/models", {
466
750
  headers: { "Authorization": `Bearer ${apiKey}` },
467
751
  signal: AbortSignal.timeout(10_000),
468
752
  });
469
753
  if (!resp.ok) {
754
+ if (cached) {
755
+ const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
756
+ res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
757
+ sendJson(res, { models: cached.value, ageSec, stale: true });
758
+ return;
759
+ }
470
760
  sendJson(res, { error: `OpenRouter API returned ${resp.status}` }, 502);
471
761
  return;
472
762
  }
@@ -474,40 +764,122 @@ export function registerRoutes(router, root, projectName) {
474
764
  const models = (data.data || []).map((m) => ({
475
765
  id: m.id,
476
766
  name: m.name || m.id,
767
+ contextWindow: typeof m.context_length === "number" ? m.context_length : undefined,
477
768
  pricing: {
478
769
  prompt: parseFloat(m.pricing?.prompt || "0"),
479
770
  completion: parseFloat(m.pricing?.completion || "0"),
480
771
  },
481
772
  }));
482
- sendJson(res, { models });
773
+ OPENROUTER_CACHE.set(cacheKey, { value: models, fetchedAt: now });
774
+ res.setHeader?.("X-Vskill-Catalog-Age", "0");
775
+ sendJson(res, { models, ageSec: 0 });
776
+ }
777
+ catch (err) {
778
+ if (cached) {
779
+ const ageSec = Math.floor((now - cached.fetchedAt) / 1000);
780
+ res.setHeader?.("X-Vskill-Catalog-Age", String(ageSec));
781
+ sendJson(res, { models: cached.value, ageSec, stale: true });
782
+ return;
783
+ }
784
+ sendJson(res, { error: err.message }, 500);
785
+ }
786
+ });
787
+ // Settings / API key endpoints (0682 — US-004).
788
+ // Keys live on-device only. Never logged, never synced, never returned
789
+ // through GET. Response includes only metadata (stored, updatedAt, tier).
790
+ router.get("/api/settings/keys", async (_req, res) => {
791
+ sendJson(res, settingsStore.listKeys());
792
+ });
793
+ router.post("/api/settings/keys", async (req, res) => {
794
+ // Reject any request that smuggles the key in a query-string — JSON body only.
795
+ const url = req.url || "";
796
+ if (/[?&]key=/.test(url)) {
797
+ sendJson(res, { error: "key must not appear in query string" }, 400);
798
+ return;
799
+ }
800
+ const body = (await readBody(req));
801
+ if (!body.key || typeof body.key !== "string" || body.key.trim().length === 0) {
802
+ sendJson(res, { error: "key must be non-empty string" }, 400);
803
+ return;
804
+ }
805
+ if (body.provider !== "anthropic" && body.provider !== "openrouter") {
806
+ sendJson(res, { error: `unknown provider: ${String(body.provider)}` }, 400);
807
+ return;
808
+ }
809
+ try {
810
+ const saved = await settingsStore.saveKey(body.provider, body.key.trim(), body.tier ?? "browser");
811
+ // Prefix hint — non-blocking, purely informational
812
+ let warning;
813
+ if (body.provider === "anthropic" && !body.key.startsWith("sk-ant-")) {
814
+ warning = "key doesn't match typical Anthropic prefix sk-ant-";
815
+ }
816
+ else if (body.provider === "openrouter" && !body.key.startsWith("sk-or-")) {
817
+ warning = "key doesn't match typical OpenRouter prefix sk-or-";
818
+ }
819
+ sendJson(res, {
820
+ ok: true,
821
+ updatedAt: saved.updatedAt,
822
+ tier: saved.tier,
823
+ available: true,
824
+ ...(warning ? { warning } : {}),
825
+ });
483
826
  }
484
827
  catch (err) {
485
828
  sendJson(res, { error: err.message }, 500);
486
829
  }
487
830
  });
831
+ router.delete("/api/settings/keys/:provider", async (req, res) => {
832
+ const provider = req.params?.provider;
833
+ if (provider !== "anthropic" && provider !== "openrouter") {
834
+ sendJson(res, { error: `unknown provider: ${String(provider)}` }, 400);
835
+ return;
836
+ }
837
+ await settingsStore.removeKey(provider);
838
+ sendJson(res, { ok: true });
839
+ });
488
840
  // Config — expose current provider/model + available providers + project
489
841
  // IMPORTANT: Return raw model IDs (e.g. "sonnet"), NOT display models
490
842
  // (e.g. "claude-sonnet"). The frontend round-trips config.model back to
491
843
  // generate-evals and other endpoints, so it must be a valid CLI model ID.
492
844
  router.get("/api/config", async (_req, res) => {
845
+ // On first load (no currentOverrides), try to restore from .vskill/studio.json.
846
+ if (!currentOverrides.provider) {
847
+ const stored = loadStudioSelection(root);
848
+ if (stored) {
849
+ currentOverrides.provider = stored.activeAgent;
850
+ if (stored.activeModel)
851
+ currentOverrides.model = stored.activeModel;
852
+ }
853
+ }
493
854
  try {
494
855
  // Validate the client can be created (catches missing API keys etc.)
495
856
  getClient();
496
857
  const providers = await detectAvailableProviders();
858
+ const detection = detectProjectAgents(root);
497
859
  sendJson(res, {
498
860
  provider: currentOverrides.provider || null,
499
861
  model: getEffectiveRawModel(),
500
862
  providers,
863
+ detection,
501
864
  projectName: projectName || null,
502
865
  root,
503
866
  });
504
867
  }
505
868
  catch (err) {
506
869
  const providers = await detectAvailableProviders().catch(() => []);
507
- sendJson(res, { provider: null, model: "unknown", error: err.message, providers, projectName: projectName || null, root });
870
+ const detection = detectProjectAgents(root);
871
+ sendJson(res, {
872
+ provider: null,
873
+ model: "unknown",
874
+ error: err.message,
875
+ providers,
876
+ detection,
877
+ projectName: projectName || null,
878
+ root,
879
+ });
508
880
  }
509
881
  });
510
- // Update config — change provider/model at runtime
882
+ // Update config — change provider/model at runtime and persist atomically.
511
883
  router.post("/api/config", async (req, res) => {
512
884
  const body = (await readBody(req));
513
885
  if (body.provider)
@@ -521,6 +893,21 @@ export function registerRoutes(router, root, projectName) {
521
893
  // Validate the client can be created
522
894
  getClient();
523
895
  const providers = await detectAvailableProviders();
896
+ // Persist to .vskill/studio.json (atomic tmp-then-rename). Fire-and-forget
897
+ // from the handler's perspective — errors are logged but not surfaced,
898
+ // matching how currentOverrides already survives process lifetime.
899
+ if (currentOverrides.provider) {
900
+ try {
901
+ await saveStudioSelection(root, {
902
+ activeAgent: currentOverrides.provider,
903
+ activeModel: getEffectiveRawModel(),
904
+ updatedAt: new Date().toISOString(),
905
+ });
906
+ }
907
+ catch (e) {
908
+ console.warn(`[studio.json] atomic write failed: ${e.message}`);
909
+ }
910
+ }
524
911
  sendJson(res, { provider: currentOverrides.provider || null, model: getEffectiveRawModel(), providers });
525
912
  }
526
913
  catch (err) {
@@ -539,7 +926,33 @@ export function registerRoutes(router, root, projectName) {
539
926
  // unknown/missing fields default to `null` (not undefined) so the shape
540
927
  // remains JSON-stable for all consumers.
541
928
  router.get("/api/skills", async (req, res) => {
542
- 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 });
543
956
  const enriched = await Promise.all(skills.map(async (s) => {
544
957
  let evalCount = 0;
545
958
  let assertionCount = 0;
@@ -553,12 +966,17 @@ export function registerRoutes(router, root, projectName) {
553
966
  catch { /* no evals */ }
554
967
  const benchmark = await readBenchmark(s.dir);
555
968
  const meta = buildSkillMetadata(s.dir, s.origin, root);
556
- // Defensive origin guarantee — scanSkills sets it, but fall back to a
557
- // recomputation if anything downstream ever widens the type.
558
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;
559
973
  return {
560
974
  ...s,
561
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"),
562
980
  evalCount,
563
981
  assertionCount,
564
982
  benchmarkStatus: computeBenchmarkStatus(benchmark, evalIds, s.hasEvals),
@@ -576,10 +994,14 @@ export function registerRoutes(router, root, projectName) {
576
994
  entryPoint: meta.entryPoint,
577
995
  lastModified: meta.lastModified,
578
996
  sizeBytes: meta.sizeBytes,
579
- sourceAgent: meta.sourceAgent,
997
+ sourceAgent,
580
998
  };
581
999
  }));
582
- sendJson(res, enriched, 200, req);
1000
+ const filtered = filterSkillsByScopeAndAgent(enriched, {
1001
+ scope: rawScope,
1002
+ agent: rawAgent,
1003
+ });
1004
+ sendJson(res, filtered, 200, req);
583
1005
  });
584
1006
  // Check for skill updates via `vskill outdated --json`
585
1007
  router.get("/api/skills/updates", async (req, res) => {