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.
- 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 +20 -2
- package/dist/eval-server/api-routes.js +339 -38
- 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-COqdrmRl.js → CommandPalette-mzfSdGtx.js} +1 -1
- package/dist/eval-ui/assets/CreateSkillPage-CjeJOSY4.js +12 -0
- package/dist/eval-ui/assets/{UpdateDropdown-DnKKMBBN.js → UpdateDropdown-QP4FPm7W.js} +1 -1
- package/dist/eval-ui/assets/index-BBoVqs6V.css +1 -0
- package/dist/eval-ui/assets/index-CKclbM8p.js +102 -0
- package/dist/eval-ui/index.html +2 -2
- 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 +2 -1
- package/dist/eval-ui/assets/CreateSkillPage-C3IjO8es.js +0 -12
- package/dist/eval-ui/assets/index-Dmja1p3A.css +0 -1
- 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)
|
|
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]));
|
|
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
|
-
|
|
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;
|
|
@@ -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
|
|
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
|
-
|
|
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
|
-
//
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
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
|
-
//
|
|
1042
|
-
//
|
|
1043
|
-
|
|
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
|
|
2192
|
-
const
|
|
2487
|
+
const fm = parseSkillFrontmatter(skillContent);
|
|
2488
|
+
const fmName = fm.name;
|
|
2489
|
+
const fmTags = fm.tags;
|
|
2193
2490
|
const meta = {
|
|
2194
|
-
name:
|
|
2195
|
-
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
|
+
: []),
|
|
2196
2497
|
};
|
|
2197
2498
|
// Use per-request model overrides if provided, fall back to global config
|
|
2198
2499
|
const client = body.provider || body.model
|