vskill 0.5.106 → 0.5.108

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.
Files changed (64) hide show
  1. package/README.md +109 -0
  2. package/agents.json +1 -1
  3. package/dist/commands/add.d.ts +36 -0
  4. package/dist/commands/add.js +149 -0
  5. package/dist/commands/add.js.map +1 -1
  6. package/dist/commands/cleanup.d.ts +6 -1
  7. package/dist/commands/cleanup.js +37 -5
  8. package/dist/commands/cleanup.js.map +1 -1
  9. package/dist/commands/disable.d.ts +8 -0
  10. package/dist/commands/disable.js +194 -0
  11. package/dist/commands/disable.js.map +1 -0
  12. package/dist/commands/enable.d.ts +11 -0
  13. package/dist/commands/enable.js +209 -0
  14. package/dist/commands/enable.js.map +1 -0
  15. package/dist/commands/eval/serve.js +18 -4
  16. package/dist/commands/eval/serve.js.map +1 -1
  17. package/dist/commands/list.d.ts +2 -0
  18. package/dist/commands/list.js +83 -1
  19. package/dist/commands/list.js.map +1 -1
  20. package/dist/commands/remove.d.ts +2 -0
  21. package/dist/commands/remove.js +84 -11
  22. package/dist/commands/remove.js.map +1 -1
  23. package/dist/eval/llm.js +7 -0
  24. package/dist/eval/llm.js.map +1 -1
  25. package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.d.ts +1 -0
  26. package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.js +16 -0
  27. package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.js.map +1 -0
  28. package/dist/eval-server/api-routes.d.ts +20 -2
  29. package/dist/eval-server/api-routes.js +339 -38
  30. package/dist/eval-server/api-routes.js.map +1 -1
  31. package/dist/eval-server/eval-server.js +9 -6
  32. package/dist/eval-server/eval-server.js.map +1 -1
  33. package/dist/eval-server/improve-routes.js +50 -2
  34. package/dist/eval-server/improve-routes.js.map +1 -1
  35. package/dist/eval-server/platform-proxy.d.ts +22 -0
  36. package/dist/eval-server/platform-proxy.js +53 -4
  37. package/dist/eval-server/platform-proxy.js.map +1 -1
  38. package/dist/eval-server/providers.js +4 -1
  39. package/dist/eval-server/providers.js.map +1 -1
  40. package/dist/eval-server/settings-store.js +19 -1
  41. package/dist/eval-server/settings-store.js.map +1 -1
  42. package/dist/eval-server/skill-create-routes.d.ts +7 -0
  43. package/dist/eval-server/skill-create-routes.js +92 -17
  44. package/dist/eval-server/skill-create-routes.js.map +1 -1
  45. package/dist/eval-server/studio-json.js +12 -3
  46. package/dist/eval-server/studio-json.js.map +1 -1
  47. package/dist/eval-ui/assets/{CommandPalette-COqdrmRl.js → CommandPalette-mzfSdGtx.js} +1 -1
  48. package/dist/eval-ui/assets/CreateSkillPage-CjeJOSY4.js +12 -0
  49. package/dist/eval-ui/assets/{UpdateDropdown-DnKKMBBN.js → UpdateDropdown-QP4FPm7W.js} +1 -1
  50. package/dist/eval-ui/assets/index-BBoVqs6V.css +1 -0
  51. package/dist/eval-ui/assets/index-CKclbM8p.js +102 -0
  52. package/dist/eval-ui/index.html +2 -2
  53. package/dist/index.js +38 -2
  54. package/dist/index.js.map +1 -1
  55. package/dist/lib/skill-lifecycle.d.ts +78 -0
  56. package/dist/lib/skill-lifecycle.js +136 -0
  57. package/dist/lib/skill-lifecycle.js.map +1 -0
  58. package/dist/utils/version.d.ts +13 -0
  59. package/dist/utils/version.js +29 -0
  60. package/dist/utils/version.js.map +1 -1
  61. package/package.json +2 -1
  62. package/dist/eval-ui/assets/CreateSkillPage-C3IjO8es.js +0 -12
  63. package/dist/eval-ui/assets/index-Dmja1p3A.css +0 -1
  64. package/dist/eval-ui/assets/index-KIcQ5e5a.js +0 -102
@@ -286,58 +286,242 @@ const EMPTY_METADATA = {
286
286
  sizeBytes: null,
287
287
  sourceAgent: null,
288
288
  };
289
+ /**
290
+ * Allow-list of metadata children that may be surfaced at the top level
291
+ * during the metadata-nesting migration. Surfacing is intentional for these
292
+ * keys (existing consumers read them as `fm.<key>`); other metadata children
293
+ * stay nested under `fm.metadata.<key>` only, so they cannot accidentally
294
+ * shadow future top-level fields. (0679 review F-004.)
295
+ *
296
+ * Includes every key that `buildSkillMetadata` (below) reads at the root, so
297
+ * that if a future hand-edit moves them under `metadata:` per the spec, the
298
+ * /api/skills payload still recovers them.
299
+ */
300
+ const SURFACED_METADATA_KEYS = new Set([
301
+ // Spec-required keys (agentskills.io/specification — `metadata:` block)
302
+ "tags",
303
+ "target-agents",
304
+ "version",
305
+ "homepage",
306
+ "category",
307
+ "author",
308
+ "license",
309
+ // Dependency keys read by buildSkillMetadata. Both kebab-case (canonical)
310
+ // and camelCase (legacy SKILL.md) are surfaced so the migration is total.
311
+ "skill-deps",
312
+ "skillDeps",
313
+ "deps",
314
+ "mcp-deps",
315
+ "mcpDeps",
316
+ "mcpDependencies",
317
+ // Path / file metadata
318
+ "entryPoint",
319
+ "entry-point",
320
+ ]);
289
321
  /**
290
322
  * Minimal YAML frontmatter parser — handles scalars and arrays (inline [a, b]
291
- * or YAML list form). We intentionally avoid pulling gray-matter into the
323
+ * or YAML list form), folded scalars (`key: >` + indented continuation), and
324
+ * the `metadata:` block. We intentionally avoid pulling gray-matter into the
292
325
  * eval-server bundle; SKILL.md frontmatter is a well-bounded subset.
326
+ *
327
+ * 0679: also recognizes the canonical agentskills.io shape where `tags` and
328
+ * `target-agents` are nested under a `metadata:` block. Allow-listed children
329
+ * of `metadata:` (see SURFACED_METADATA_KEYS) are surfaced both as top-level
330
+ * keys (`fm.tags`) AND under `metadata.<key>` (e.g., `fm.metadata.tags`), so
331
+ * existing consumers that read `fm.tags` keep working without changes. Other
332
+ * metadata children stay nested-only. If a SKILL.md somehow has BOTH a
333
+ * top-level `tags:` AND a `metadata.tags:` (hand-edited transitional file),
334
+ * the top-level value wins — explicit beats nested.
293
335
  */
294
336
  export function parseSkillFrontmatter(content) {
295
337
  const match = content.match(/^---\n([\s\S]*?)\n---/);
296
338
  if (!match)
297
339
  return {};
298
340
  const lines = match[1].split("\n");
299
- const out = {};
341
+ const rootOut = {};
342
+ const metaOut = {};
343
+ let scope = "root";
300
344
  let currentKey = null;
301
345
  let currentList = null;
346
+ let currentScope = "root";
347
+ // Folded-scalar (`key: >`) / literal-scalar (`key: |`) accumulator.
348
+ // While active, indented continuation lines are joined into a single value.
349
+ let foldedKey = null;
350
+ let foldedScope = null;
351
+ let foldedLines = [];
352
+ let foldedKind = null;
302
353
  const stripQuotes = (s) => s.trim().replace(/^["']|["']$/g, "");
354
+ const flushList = () => {
355
+ if (currentKey && currentList) {
356
+ const target = currentScope === "metadata" ? metaOut : rootOut;
357
+ target[currentKey] = currentList;
358
+ }
359
+ currentKey = null;
360
+ currentList = null;
361
+ // 0679 review F-005: keep currentScope coherent with the cleared list.
362
+ currentScope = scope;
363
+ };
364
+ const flushFolded = () => {
365
+ if (!foldedKey)
366
+ return;
367
+ // Folded (`>`): join with single spaces, collapsing blank lines to "\n".
368
+ // Literal (`|`): preserve newlines.
369
+ const joined = foldedKind === "|"
370
+ ? foldedLines.join("\n").trim()
371
+ : foldedLines.map((l) => l.trim()).filter(Boolean).join(" ").trim();
372
+ const target = foldedScope === "metadata" ? metaOut : rootOut;
373
+ target[foldedKey] = joined;
374
+ foldedKey = null;
375
+ foldedScope = null;
376
+ foldedLines = [];
377
+ foldedKind = null;
378
+ };
303
379
  for (const line of lines) {
304
- const listItem = line.match(/^\s+-\s+(.+)$/);
305
- if (listItem && currentKey && currentList) {
306
- currentList.push(stripQuotes(listItem[1]));
380
+ // While accumulating a folded/literal scalar, any indented continuation
381
+ // line belongs to the scalar; an empty line is a paragraph break.
382
+ if (foldedKey) {
383
+ if (line.trim() === "") {
384
+ foldedLines.push("");
385
+ continue;
386
+ }
387
+ if (/^ {2,}/.test(line)) {
388
+ foldedLines.push(line.replace(/^ +/, ""));
389
+ continue;
390
+ }
391
+ // Non-indented line — fold ends, fall through to normal processing.
392
+ flushFolded();
393
+ }
394
+ if (line.trim() === "")
395
+ continue;
396
+ // Metadata-nested list item (exactly 4 spaces indent): ` - value`
397
+ const nestedListItem = line.match(/^ {4}-\s+(.+)$/);
398
+ if (nestedListItem && currentKey && currentList && currentScope === "metadata") {
399
+ currentList.push(stripQuotes(nestedListItem[1]));
307
400
  continue;
308
401
  }
402
+ // Root-level list item — accept either ` - value` (2-space, YAML-conventional
403
+ // for top-level keys followed by indented list) or `- value` (0-space, also
404
+ // valid YAML). Reject 1-space and 3+ space (malformed) for symmetry with
405
+ // the strict 2-space metadata-child rule. (0679 review F-002 iter-1, regex
406
+ // readability cleanup iter-4 F-002: `(?: )?-` is the conventional form.)
407
+ const rootListItem = line.match(/^(?: )?-\s+(.+)$/);
408
+ if (rootListItem && currentKey && currentList && currentScope === "root") {
409
+ currentList.push(stripQuotes(rootListItem[1]));
410
+ continue;
411
+ }
412
+ // Metadata child key: ` key:` or ` key: value` (exactly 2-space indent)
413
+ const metaChild = line.match(/^ {2}([\w-]+):\s*(.*)$/);
414
+ if (scope === "metadata" && metaChild) {
415
+ flushList();
416
+ const key = metaChild[1];
417
+ const rawValue = metaChild[2].trim();
418
+ currentScope = "metadata";
419
+ currentKey = key;
420
+ if (!rawValue) {
421
+ currentList = [];
422
+ continue;
423
+ }
424
+ // Folded / literal scalar inside metadata.
425
+ if (rawValue === ">" || rawValue === "|") {
426
+ foldedKey = key;
427
+ foldedScope = "metadata";
428
+ foldedLines = [];
429
+ foldedKind = rawValue;
430
+ currentKey = null;
431
+ currentList = null;
432
+ continue;
433
+ }
434
+ const arrayMatch = rawValue.match(/^\[(.*)\]$/);
435
+ if (arrayMatch) {
436
+ metaOut[key] = arrayMatch[1].split(",").map(stripQuotes).filter(Boolean);
437
+ currentList = null;
438
+ currentKey = null;
439
+ }
440
+ else {
441
+ metaOut[key] = stripQuotes(rawValue);
442
+ currentList = null;
443
+ currentKey = null;
444
+ }
445
+ continue;
446
+ }
447
+ // Top-level key: `key:` or `key: value` (no leading whitespace)
309
448
  const kv = line.match(/^([\w-]+):\s*(.*)$/);
310
449
  if (!kv)
311
450
  continue;
312
- // Flush pending list
313
- if (currentKey && currentList) {
314
- out[currentKey] = currentList;
315
- currentList = null;
316
- }
451
+ flushList();
317
452
  const key = kv[1];
318
453
  const rawValue = kv[2].trim();
454
+ if (key === "metadata") {
455
+ // 0679 review F-002 + iter-3 F-001: `metadata: <inline-scalar>` is a
456
+ // malformed file. We open the block (subsequent indented children land
457
+ // under `metadata`) AND preserve the inline value at a sentinel
458
+ // top-level key (`metadata-inline`) so callers can detect the mistake
459
+ // instead of silently losing data. The post-merge step (below) removes
460
+ // any chance of the block contents overwriting the inline value.
461
+ if (rawValue && rawValue !== ">" && rawValue !== "|") {
462
+ rootOut["metadata-inline"] = stripQuotes(rawValue);
463
+ }
464
+ // Entering the metadata block; subsequent indented children are nested.
465
+ scope = "metadata";
466
+ currentScope = "metadata";
467
+ currentKey = null;
468
+ currentList = null;
469
+ continue;
470
+ }
471
+ // Any unindented key resets scope back to root.
472
+ scope = "root";
473
+ currentScope = "root";
319
474
  currentKey = key;
320
475
  if (!rawValue) {
321
- // Next lines may be a YAML list
322
476
  currentList = [];
323
477
  continue;
324
478
  }
479
+ // 0679 review F-001: folded (`>`) and literal (`|`) scalars at root.
480
+ if (rawValue === ">" || rawValue === "|") {
481
+ foldedKey = key;
482
+ foldedScope = "root";
483
+ foldedLines = [];
484
+ foldedKind = rawValue;
485
+ currentKey = null;
486
+ currentList = null;
487
+ continue;
488
+ }
325
489
  const arrayMatch = rawValue.match(/^\[(.*)\]$/);
326
490
  if (arrayMatch) {
327
- out[key] = arrayMatch[1]
328
- .split(",")
329
- .map(stripQuotes)
330
- .filter(Boolean);
491
+ rootOut[key] = arrayMatch[1].split(",").map(stripQuotes).filter(Boolean);
331
492
  currentList = null;
493
+ currentKey = null;
332
494
  }
333
495
  else {
334
- out[key] = stripQuotes(rawValue);
496
+ // scope is "root" here — set just above. Top-level scalar.
497
+ rootOut[key] = stripQuotes(rawValue);
335
498
  currentList = null;
499
+ currentKey = null;
336
500
  }
337
501
  }
338
- if (currentKey && currentList)
339
- out[currentKey] = currentList;
340
- return out;
502
+ flushList();
503
+ flushFolded();
504
+ // Surface allow-listed metadata children at the top level so existing
505
+ // consumers (`fm.tags`, `fm["target-agents"]`) keep working post-migration.
506
+ // A top-level value with the same name takes precedence over the nested
507
+ // one. Non-allow-listed children stay nested-only — see SURFACED_METADATA_KEYS.
508
+ // 0679 review iter-4 F-003 + grill G-002: defensive-copy arrays on BOTH
509
+ // sides (root surfacing and the nested `fm.metadata.<key>`) so neither
510
+ // path can accidentally mutate the other.
511
+ const merged = { ...rootOut };
512
+ for (const [k, v] of Object.entries(metaOut)) {
513
+ if (SURFACED_METADATA_KEYS.has(k) && !(k in merged)) {
514
+ merged[k] = Array.isArray(v) ? [...v] : v;
515
+ }
516
+ }
517
+ if (Object.keys(metaOut).length > 0) {
518
+ const metaCopy = {};
519
+ for (const [k, v] of Object.entries(metaOut)) {
520
+ metaCopy[k] = Array.isArray(v) ? [...v] : v;
521
+ }
522
+ merged.metadata = metaCopy;
523
+ }
524
+ return merged;
341
525
  }
342
526
  function toStringOrNull(v) {
343
527
  return typeof v === "string" && v.trim().length > 0 ? v.trim() : null;
@@ -455,6 +639,43 @@ export function buildSkillMetadata(skillDir, origin, root) {
455
639
  // (which only matters for the `vskill eval run` CLI command).
456
640
  // ---------------------------------------------------------------------------
457
641
  let currentOverrides = { provider: "claude-cli" };
642
+ // 0682 F-001 — Tracks whether the persistent studio.json selection has been
643
+ // loaded for this process lifetime. Initialized to false; set to true once
644
+ // loadStudioSelection(root) has been attempted (regardless of whether a file
645
+ // existed). Without this flag, the prior gate `if (!currentOverrides.provider)`
646
+ // was permanently false (the default `{ provider: "claude-cli" }` already
647
+ // populates `provider`), so the persisted selection at .vskill/studio.json
648
+ // was silently discarded on every server boot — a CRITICAL regression of
649
+ // AC-US1-03 / FR-005.
650
+ let studioLoaded = false;
651
+ /**
652
+ * 0682 F-001 — Test helper. Resets `currentOverrides` to the default and
653
+ * clears the `studioLoaded` flag so subsequent /api/config calls re-attempt
654
+ * loadStudioSelection. Production code never needs this; it exists solely so
655
+ * vitest can simulate a fresh server boot per-case.
656
+ */
657
+ export function resetStudioRestoreState() {
658
+ currentOverrides = { provider: "claude-cli" };
659
+ studioLoaded = false;
660
+ }
661
+ // 0682 CR-001 — Known ProviderName values, used to validate the activeAgent
662
+ // string read from .vskill/studio.json before assigning into currentOverrides.
663
+ // studio-json.ts only checks `typeof activeAgent === "string"`; without this
664
+ // gate a corrupt or version-mismatched file would flow an unknown provider
665
+ // into getClient() / PROVIDER_MODELS and produce undefined-key lookups.
666
+ const KNOWN_PROVIDER_NAMES = new Set([
667
+ "anthropic",
668
+ "claude-cli",
669
+ "codex-cli",
670
+ "gemini-cli",
671
+ "lm-studio",
672
+ "ollama",
673
+ "openai",
674
+ "openrouter",
675
+ ]);
676
+ function isKnownProviderName(s) {
677
+ return KNOWN_PROVIDER_NAMES.has(s);
678
+ }
458
679
  /** Return the effective raw model ID (suitable for round-tripping via the API). */
459
680
  function getEffectiveRawModel() {
460
681
  if (currentOverrides.model)
@@ -571,6 +792,20 @@ const PROBE_CACHE_TTL = 30_000; // re-probe every 30s
571
792
  let ollamaCache = null;
572
793
  let lmStudioCache = null;
573
794
  export const OPENROUTER_CACHE = new Map();
795
+ // 0682 F-003 (review iter 2): cap the cache size so a long-running
796
+ // multi-tenant server doesn't accumulate one ~30KB entry per distinct API
797
+ // key seen since boot. 16 entries is plenty for any realistic deployment
798
+ // (single-developer + occasional team-shared studio); FIFO eviction keeps
799
+ // the cap simple — true LRU isn't worth the bookkeeping cost.
800
+ const OPENROUTER_CACHE_MAX_ENTRIES = 16;
801
+ export function evictOldestOpenRouterCacheIfFull() {
802
+ if (OPENROUTER_CACHE.size <= OPENROUTER_CACHE_MAX_ENTRIES)
803
+ return;
804
+ // Map iteration order is insertion order — first key is oldest.
805
+ const firstKey = OPENROUTER_CACHE.keys().next().value;
806
+ if (firstKey !== undefined)
807
+ OPENROUTER_CACHE.delete(firstKey);
808
+ }
574
809
  export function resetOpenRouterCache() {
575
810
  OPENROUTER_CACHE.clear();
576
811
  }
@@ -590,10 +825,13 @@ async function probeOllama() {
590
825
  const baseUrl = resolveOllamaBaseUrl(process.env);
591
826
  const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(500) });
592
827
  if (resp.ok) {
593
- available = true;
594
828
  const data = await resp.json();
829
+ // Only mark available after JSON parses cleanly — protects against 200-with-bad-body proxies.
830
+ available = true;
595
831
  if (data.models?.length) {
596
- models = data.models.map((m) => ({ id: m.name, label: m.name }));
832
+ models = data.models
833
+ .filter((m) => typeof m?.name === "string" && m.name.length > 0)
834
+ .map((m) => ({ id: m.name, label: m.name }));
597
835
  }
598
836
  }
599
837
  }
@@ -618,10 +856,16 @@ async function probeLmStudio() {
618
856
  const baseUrl = process.env.LM_STUDIO_BASE_URL || "http://localhost:1234/v1";
619
857
  const resp = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(500) });
620
858
  if (resp.ok) {
621
- available = true;
622
859
  const data = await resp.json();
860
+ // Only mark available after JSON parses cleanly — protects against 200-with-bad-body proxies.
861
+ available = true;
623
862
  if (data.data?.length) {
624
- models = data.data.map((m) => ({ id: m.id, label: m.id }));
863
+ // Alphabetical sort honors AC-US3-01 (group children ordered alphabetically
864
+ // in the dropdown). LM Studio returns models in load-order, which is unstable.
865
+ models = data.data
866
+ .filter((m) => typeof m?.id === "string" && m.id.length > 0)
867
+ .map((m) => ({ id: m.id, label: m.id }))
868
+ .sort((a, b) => a.id.localeCompare(b.id));
625
869
  }
626
870
  }
627
871
  }
@@ -859,6 +1103,9 @@ export function registerRoutes(router, root, projectName) {
859
1103
  },
860
1104
  }));
861
1105
  OPENROUTER_CACHE.set(cacheKey, { value: models, fetchedAt: now });
1106
+ // 0682 F-003 (review iter 2): bound cache size to avoid unbounded
1107
+ // growth on long-running shared servers.
1108
+ evictOldestOpenRouterCacheIfFull();
862
1109
  res.setHeader?.("X-Vskill-Catalog-Age", "0");
863
1110
  sendJson(res, { models, ageSec: 0 });
864
1111
  }
@@ -969,13 +1216,43 @@ export function registerRoutes(router, root, projectName) {
969
1216
  // (e.g. "claude-sonnet"). The frontend round-trips config.model back to
970
1217
  // generate-evals and other endpoints, so it must be a valid CLI model ID.
971
1218
  router.get("/api/config", async (_req, res) => {
972
- // On first load (no currentOverrides), try to restore from .vskill/studio.json.
973
- if (!currentOverrides.provider) {
974
- const stored = loadStudioSelection(root);
975
- if (stored) {
976
- currentOverrides.provider = stored.activeAgent;
977
- if (stored.activeModel)
978
- currentOverrides.model = stored.activeModel;
1219
+ // 0682 F-001 Boot-time restoration of .vskill/studio.json selection.
1220
+ // Use a dedicated `studioLoaded` flag rather than checking
1221
+ // `!currentOverrides.provider` because the module default
1222
+ // `{ provider: "claude-cli" }` would make the prior guard permanently
1223
+ // false, silently discarding the persisted selection on every boot.
1224
+ if (!studioLoaded) {
1225
+ // 0682 CR-0682-M1 — Wrap loadStudioSelection() in its own try/catch
1226
+ // and set `studioLoaded = true` ONLY after the load attempt completes.
1227
+ // Pre-fix the flag was flipped before the call, so any thrown error
1228
+ // (fs, permissions, malformed JSON path inside the loader) would
1229
+ // permanently mask studio.json for the process lifetime — exactly the
1230
+ // same class of bug as the F-001 it tried to remedy. Now: on success
1231
+ // OR clean miss, mark loaded; on thrown error, log and leave the flag
1232
+ // unset so the next request retries.
1233
+ try {
1234
+ const stored = loadStudioSelection(root);
1235
+ if (stored) {
1236
+ // 0682 CR-001 — Validate the persisted activeAgent against the
1237
+ // ProviderName union before mutating currentOverrides. A tampered
1238
+ // or version-mismatched studio.json (e.g. "unknown", "claude-code",
1239
+ // or arbitrary text) silently falls back to the claude-cli default
1240
+ // rather than poisoning getClient() lookups downstream.
1241
+ if (isKnownProviderName(stored.activeAgent)) {
1242
+ currentOverrides.provider = stored.activeAgent;
1243
+ if (stored.activeModel)
1244
+ currentOverrides.model = stored.activeModel;
1245
+ }
1246
+ else {
1247
+ console.warn(`[studio.json] Ignoring unknown activeAgent "${stored.activeAgent}" — falling back to claude-cli`);
1248
+ }
1249
+ }
1250
+ studioLoaded = true;
1251
+ }
1252
+ catch (e) {
1253
+ console.warn(`[studio.json] load failed: ${e.message} — will retry on next /api/config request`);
1254
+ // Intentionally leave studioLoaded=false so transient FS errors don't
1255
+ // permanently mask the persisted selection.
979
1256
  }
980
1257
  }
981
1258
  try {
@@ -1009,6 +1286,21 @@ export function registerRoutes(router, root, projectName) {
1009
1286
  // Update config — change provider/model at runtime and persist atomically.
1010
1287
  router.post("/api/config", async (req, res) => {
1011
1288
  const body = (await readBody(req));
1289
+ // 0682 F-001 (review iter 3): validate the incoming provider against the
1290
+ // ProviderName union BEFORE mutating currentOverrides. Pre-fix, an
1291
+ // unknown provider was eagerly applied and only caught downstream by
1292
+ // `getClient()`, where a hard reset to claude-cli destroyed any prior
1293
+ // valid in-memory selection. Now: reject early with 400 and leave the
1294
+ // existing selection intact.
1295
+ if (body.provider !== undefined && !isKnownProviderName(body.provider)) {
1296
+ sendJson(res, { error: `unknown provider: ${String(body.provider)}` }, 400, req);
1297
+ return;
1298
+ }
1299
+ // 0682 F-001 (review iter 3): snapshot the prior selection so we can
1300
+ // restore it on validation failure, rather than zapping everything to
1301
+ // the default. Spec semantics: a failed POST should leave the previous
1302
+ // good state intact.
1303
+ const priorOverrides = { ...currentOverrides };
1012
1304
  if (body.provider)
1013
1305
  currentOverrides.provider = body.provider;
1014
1306
  if (body.model)
@@ -1038,9 +1330,10 @@ export function registerRoutes(router, root, projectName) {
1038
1330
  sendJson(res, { provider: currentOverrides.provider || null, model: getEffectiveRawModel(), providers });
1039
1331
  }
1040
1332
  catch (err) {
1041
- // Revert to safe default (not empty empty triggers auto-detection which
1042
- // picks ollama inside Claude Code sessions instead of claude-cli)
1043
- currentOverrides = { provider: "claude-cli" };
1333
+ // 0682 F-001 (review iter 3): revert to the prior good selection, NOT
1334
+ // the hard-coded claude-cli default. Pre-fix any failed POST silently
1335
+ // wiped the user's prior valid choice.
1336
+ currentOverrides = priorOverrides;
1044
1337
  sendJson(res, { error: err.message }, 400, req);
1045
1338
  }
1046
1339
  });
@@ -2186,13 +2479,21 @@ export function registerRoutes(router, root, projectName) {
2186
2479
  const body = (await readBody(req));
2187
2480
  const skillMdPath = join(skillDir, "SKILL.md");
2188
2481
  const skillContent = existsSync(skillMdPath) ? readFileSync(skillMdPath, "utf-8") : "";
2189
- // Extract description, name, and tags from frontmatter
2482
+ // Extract description, name, and tags from frontmatter.
2483
+ // 0679 F-001: route through parseSkillFrontmatter so the metadata-nested
2484
+ // tags shape (per agentskills.io/specification) is honored. Direct regex
2485
+ // missed indented `metadata.tags:` and silently produced an empty array.
2190
2486
  const description = extractDescription(skillContent);
2191
- const nameMatch = skillContent.match(/^---[\s\S]*?name:\s*(\S+)[\s\S]*?---/);
2192
- const tagsMatch = skillContent.match(/^---[\s\S]*?tags:\s*(.+)[\s\S]*?---/m);
2487
+ const fm = parseSkillFrontmatter(skillContent);
2488
+ const fmName = fm.name;
2489
+ const fmTags = fm.tags;
2193
2490
  const meta = {
2194
- name: nameMatch ? nameMatch[1] : params.skill,
2195
- tags: tagsMatch ? tagsMatch[1].split(",").map((t) => t.trim()).filter(Boolean) : [],
2491
+ name: typeof fmName === "string" && fmName.trim().length > 0 ? fmName.trim() : params.skill,
2492
+ tags: Array.isArray(fmTags)
2493
+ ? fmTags.filter((t) => typeof t === "string" && t.length > 0)
2494
+ : (typeof fmTags === "string"
2495
+ ? fmTags.split(",").map((t) => t.trim()).filter(Boolean)
2496
+ : []),
2196
2497
  };
2197
2498
  // Use per-request model overrides if provided, fall back to global config
2198
2499
  const client = body.provider || body.model