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.
- package/README.md +109 -0
- package/agents.json +1 -1
- package/dist/commands/add.d.ts +36 -0
- package/dist/commands/add.js +149 -0
- package/dist/commands/add.js.map +1 -1
- package/dist/commands/cleanup.d.ts +6 -1
- package/dist/commands/cleanup.js +37 -5
- package/dist/commands/cleanup.js.map +1 -1
- package/dist/commands/disable.d.ts +8 -0
- package/dist/commands/disable.js +194 -0
- package/dist/commands/disable.js.map +1 -0
- package/dist/commands/enable.d.ts +11 -0
- package/dist/commands/enable.js +209 -0
- package/dist/commands/enable.js.map +1 -0
- package/dist/commands/eval/serve.js +18 -4
- package/dist/commands/eval/serve.js.map +1 -1
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.js +83 -1
- package/dist/commands/list.js.map +1 -1
- package/dist/commands/remove.d.ts +2 -0
- package/dist/commands/remove.js +84 -11
- package/dist/commands/remove.js.map +1 -1
- package/dist/eval/llm.js +7 -0
- package/dist/eval/llm.js.map +1 -1
- package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.d.ts +1 -0
- package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.js +16 -0
- package/dist/eval-server/__tests__/helpers/skill-md-test-helpers.js.map +1 -0
- package/dist/eval-server/api-routes.d.ts +13 -2
- package/dist/eval-server/api-routes.js +314 -37
- package/dist/eval-server/api-routes.js.map +1 -1
- package/dist/eval-server/eval-server.js +9 -6
- package/dist/eval-server/eval-server.js.map +1 -1
- package/dist/eval-server/improve-routes.js +50 -2
- package/dist/eval-server/improve-routes.js.map +1 -1
- package/dist/eval-server/platform-proxy.d.ts +22 -0
- package/dist/eval-server/platform-proxy.js +53 -4
- package/dist/eval-server/platform-proxy.js.map +1 -1
- package/dist/eval-server/providers.js +4 -1
- package/dist/eval-server/providers.js.map +1 -1
- package/dist/eval-server/settings-store.js +19 -1
- package/dist/eval-server/settings-store.js.map +1 -1
- package/dist/eval-server/skill-create-routes.d.ts +7 -0
- package/dist/eval-server/skill-create-routes.js +92 -17
- package/dist/eval-server/skill-create-routes.js.map +1 -1
- package/dist/eval-server/studio-json.js +12 -3
- package/dist/eval-server/studio-json.js.map +1 -1
- package/dist/eval-ui/assets/{CommandPalette-sazEtk0a.js → CommandPalette-mzfSdGtx.js} +1 -1
- package/dist/eval-ui/assets/{CreateSkillPage-B5qENwBj.js → CreateSkillPage-CjeJOSY4.js} +1 -1
- package/dist/eval-ui/assets/{UpdateDropdown-Ce9LXOxb.js → UpdateDropdown-QP4FPm7W.js} +1 -1
- package/dist/eval-ui/assets/{index-vn5bFfrb.js → index-CKclbM8p.js} +38 -38
- package/dist/eval-ui/index.html +1 -1
- package/dist/index.js +38 -2
- package/dist/index.js.map +1 -1
- package/dist/lib/skill-lifecycle.d.ts +78 -0
- package/dist/lib/skill-lifecycle.js +136 -0
- package/dist/lib/skill-lifecycle.js.map +1 -0
- package/dist/utils/version.d.ts +13 -0
- package/dist/utils/version.js +29 -0
- package/dist/utils/version.js.map +1 -1
- 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)
|
|
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
|
|
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
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
339
|
-
|
|
340
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
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
|
-
//
|
|
1066
|
-
//
|
|
1067
|
-
|
|
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
|
|
2216
|
-
const
|
|
2487
|
+
const fm = parseSkillFrontmatter(skillContent);
|
|
2488
|
+
const fmName = fm.name;
|
|
2489
|
+
const fmTags = fm.tags;
|
|
2217
2490
|
const meta = {
|
|
2218
|
-
name:
|
|
2219
|
-
tags:
|
|
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
|