vskill 0.5.107 → 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 (60) 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 +13 -2
  29. package/dist/eval-server/api-routes.js +314 -37
  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-sazEtk0a.js → CommandPalette-mzfSdGtx.js} +1 -1
  48. package/dist/eval-ui/assets/{CreateSkillPage-B5qENwBj.js → CreateSkillPage-CjeJOSY4.js} +1 -1
  49. package/dist/eval-ui/assets/{UpdateDropdown-Ce9LXOxb.js → UpdateDropdown-QP4FPm7W.js} +1 -1
  50. package/dist/eval-ui/assets/{index-vn5bFfrb.js → index-CKclbM8p.js} +38 -38
  51. package/dist/eval-ui/index.html +1 -1
  52. package/dist/index.js +38 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/lib/skill-lifecycle.d.ts +78 -0
  55. package/dist/lib/skill-lifecycle.js +136 -0
  56. package/dist/lib/skill-lifecycle.js.map +1 -0
  57. package/dist/utils/version.d.ts +13 -0
  58. package/dist/utils/version.js +29 -0
  59. package/dist/utils/version.js.map +1 -1
  60. package/package.json +1 -1
@@ -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]));
400
+ continue;
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
+ }
307
445
  continue;
308
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;
@@ -474,6 +658,24 @@ export function resetStudioRestoreState() {
474
658
  currentOverrides = { provider: "claude-cli" };
475
659
  studioLoaded = false;
476
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
+ }
477
679
  /** Return the effective raw model ID (suitable for round-tripping via the API). */
478
680
  function getEffectiveRawModel() {
479
681
  if (currentOverrides.model)
@@ -590,6 +792,20 @@ const PROBE_CACHE_TTL = 30_000; // re-probe every 30s
590
792
  let ollamaCache = null;
591
793
  let lmStudioCache = null;
592
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
+ }
593
809
  export function resetOpenRouterCache() {
594
810
  OPENROUTER_CACHE.clear();
595
811
  }
@@ -609,10 +825,13 @@ async function probeOllama() {
609
825
  const baseUrl = resolveOllamaBaseUrl(process.env);
610
826
  const resp = await fetch(`${baseUrl}/api/tags`, { signal: AbortSignal.timeout(500) });
611
827
  if (resp.ok) {
612
- available = true;
613
828
  const data = await resp.json();
829
+ // Only mark available after JSON parses cleanly — protects against 200-with-bad-body proxies.
830
+ available = true;
614
831
  if (data.models?.length) {
615
- 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 }));
616
835
  }
617
836
  }
618
837
  }
@@ -637,10 +856,16 @@ async function probeLmStudio() {
637
856
  const baseUrl = process.env.LM_STUDIO_BASE_URL || "http://localhost:1234/v1";
638
857
  const resp = await fetch(`${baseUrl}/models`, { signal: AbortSignal.timeout(500) });
639
858
  if (resp.ok) {
640
- available = true;
641
859
  const data = await resp.json();
860
+ // Only mark available after JSON parses cleanly — protects against 200-with-bad-body proxies.
861
+ available = true;
642
862
  if (data.data?.length) {
643
- 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));
644
869
  }
645
870
  }
646
871
  }
@@ -878,6 +1103,9 @@ export function registerRoutes(router, root, projectName) {
878
1103
  },
879
1104
  }));
880
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();
881
1109
  res.setHeader?.("X-Vskill-Catalog-Age", "0");
882
1110
  sendJson(res, { models, ageSec: 0 });
883
1111
  }
@@ -994,12 +1222,37 @@ export function registerRoutes(router, root, projectName) {
994
1222
  // `{ provider: "claude-cli" }` would make the prior guard permanently
995
1223
  // false, silently discarding the persisted selection on every boot.
996
1224
  if (!studioLoaded) {
997
- studioLoaded = true;
998
- const stored = loadStudioSelection(root);
999
- if (stored) {
1000
- currentOverrides.provider = stored.activeAgent;
1001
- if (stored.activeModel)
1002
- currentOverrides.model = stored.activeModel;
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.
1003
1256
  }
1004
1257
  }
1005
1258
  try {
@@ -1033,6 +1286,21 @@ export function registerRoutes(router, root, projectName) {
1033
1286
  // Update config — change provider/model at runtime and persist atomically.
1034
1287
  router.post("/api/config", async (req, res) => {
1035
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 };
1036
1304
  if (body.provider)
1037
1305
  currentOverrides.provider = body.provider;
1038
1306
  if (body.model)
@@ -1062,9 +1330,10 @@ export function registerRoutes(router, root, projectName) {
1062
1330
  sendJson(res, { provider: currentOverrides.provider || null, model: getEffectiveRawModel(), providers });
1063
1331
  }
1064
1332
  catch (err) {
1065
- // Revert to safe default (not empty empty triggers auto-detection which
1066
- // picks ollama inside Claude Code sessions instead of claude-cli)
1067
- 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;
1068
1337
  sendJson(res, { error: err.message }, 400, req);
1069
1338
  }
1070
1339
  });
@@ -2210,13 +2479,21 @@ export function registerRoutes(router, root, projectName) {
2210
2479
  const body = (await readBody(req));
2211
2480
  const skillMdPath = join(skillDir, "SKILL.md");
2212
2481
  const skillContent = existsSync(skillMdPath) ? readFileSync(skillMdPath, "utf-8") : "";
2213
- // 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.
2214
2486
  const description = extractDescription(skillContent);
2215
- const nameMatch = skillContent.match(/^---[\s\S]*?name:\s*(\S+)[\s\S]*?---/);
2216
- 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;
2217
2490
  const meta = {
2218
- name: nameMatch ? nameMatch[1] : params.skill,
2219
- 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
+ : []),
2220
2497
  };
2221
2498
  // Use per-request model overrides if provided, fall back to global config
2222
2499
  const client = body.provider || body.model