vskill 0.5.140 → 0.5.141

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/agents.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "version": 1,
3
- "generatedAt": "2026-04-26T18:39:18.872Z",
3
+ "generatedAt": "2026-04-26T19:11:59.758Z",
4
4
  "agentPrefixes": [
5
5
  ".adal",
6
6
  ".agent",
@@ -28,6 +28,7 @@ export interface AgentScopeEntry {
28
28
  isDefault: boolean;
29
29
  localSkillCount: number;
30
30
  globalSkillCount: number;
31
+ pluginSkillCount: number;
31
32
  resolvedLocalDir: string;
32
33
  resolvedGlobalDir: string;
33
34
  lastSync: string | null;
@@ -111,6 +112,40 @@ export declare function parseSkillFrontmatter(content: string): Record<string, s
111
112
  * for `origin="source"` or if no registry entry matches.
112
113
  */
113
114
  export declare function deriveSourceAgent(skillDir: string, root: string, origin: "source" | "installed"): string | null;
115
+ /**
116
+ * 0770 — Pure regex parser. Normalizes any github.com origin remote
117
+ * (SSH, HTTPS, ssh://) to its canonical `https://github.com/owner/repo`
118
+ * form (no `.git` suffix, no trailing path). Returns null for non-github
119
+ * hosts, malformed input, empty/whitespace strings.
120
+ */
121
+ export declare function parseGithubRemote(remote: string | null | undefined): string | null;
122
+ /**
123
+ * 0770 — Walk parent directories from `startDir` looking for a `.git` entry
124
+ * (directory OR file — git worktrees use a `.git` file). Bails at the
125
+ * filesystem root or after `maxLevels` iterations. Returns the absolute
126
+ * path of the discovered git root, or null.
127
+ */
128
+ export declare function walkUpForGitRoot(startDir: string, maxLevels?: number): string | null;
129
+ /**
130
+ * 0770 — Test-only helper to clear the module-level memoization cache so
131
+ * tests can isolate detection runs across `beforeEach`.
132
+ */
133
+ export declare function resetAuthoredSourceLinkCache(): void;
134
+ /**
135
+ * 0770 — Detect source-repo provenance for a locally-authored skill (no
136
+ * lockfile entry). Walks for `.git`, reads `origin` remote, normalizes via
137
+ * `parseGithubRemote`, and computes `skillPath` from `git ls-files` (with a
138
+ * filesystem fallback for untracked SKILL.md files). Memoized per absolute
139
+ * skill dir for the eval-server process lifetime.
140
+ *
141
+ * All git invocations use `execFileSync` with explicit argv (no shell), a
142
+ * 1500ms hard timeout, and silenced stderr. Any error converts to
143
+ * `{null, null}` — `buildSkillMetadata` never throws because of git.
144
+ */
145
+ export declare function detectAuthoredSourceLink(skillDir: string): {
146
+ repoUrl: string | null;
147
+ skillPath: string | null;
148
+ };
114
149
  /**
115
150
  * Build the T-025 metadata payload for a single skill. Reads SKILL.md from
116
151
  * disk if present; returns EMPTY_METADATA on any error so the /api/skills
@@ -3,7 +3,7 @@
3
3
  // ---------------------------------------------------------------------------
4
4
  import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, statSync } from "node:fs";
5
5
  import { execSync, execFileSync } from "node:child_process";
6
- import { join, resolve, dirname, basename } from "node:path";
6
+ import { join, resolve, dirname, basename, relative } from "node:path";
7
7
  import { homedir } from "node:os";
8
8
  import { sendJson, readBody } from "./router.js";
9
9
  import { initSSE, sendSSE, sendSSEDone, withHeartbeat, startDynamicHeartbeat } from "./sse-helpers.js";
@@ -122,6 +122,14 @@ export async function buildAgentsResponse(opts) {
122
122
  agentPresenceCache.binariesKey === cacheKey.binariesKey) {
123
123
  return agentPresenceCache.data;
124
124
  }
125
+ // 0772 US-002: count plugin skills once for claude-code. The plugin scanner
126
+ // walks ~/.claude/plugins/cache/<marketplace>/<plugin>/<version>/skills/, so
127
+ // the result is independent of agent identity (plugins are CC-only by
128
+ // current registry design). Pass `home` so tests can override the homedir.
129
+ const claudePluginCount = scanInstalledPluginSkills({
130
+ agentId: "claude-code",
131
+ home,
132
+ }).length;
125
133
  // Map each agent → resolved local + global dir. For tests, `home` overrides
126
134
  // the homedir-derived global path. In production, resolveGlobalSkillsDir()
127
135
  // handles cross-platform resolution (darwin / linux / win32).
@@ -167,6 +175,7 @@ export async function buildAgentsResponse(opts) {
167
175
  isDefault,
168
176
  localSkillCount,
169
177
  globalSkillCount,
178
+ pluginSkillCount: agent.id === "claude-code" ? claudePluginCount : 0,
170
179
  resolvedLocalDir,
171
180
  resolvedGlobalDir,
172
181
  lastSync,
@@ -597,6 +606,116 @@ export function deriveSourceAgent(skillDir, root, origin) {
597
606
  }
598
607
  return null;
599
608
  }
609
+ /**
610
+ * 0770 — Pure regex parser. Normalizes any github.com origin remote
611
+ * (SSH, HTTPS, ssh://) to its canonical `https://github.com/owner/repo`
612
+ * form (no `.git` suffix, no trailing path). Returns null for non-github
613
+ * hosts, malformed input, empty/whitespace strings.
614
+ */
615
+ export function parseGithubRemote(remote) {
616
+ const trimmed = (remote ?? "").trim();
617
+ if (!trimmed)
618
+ return null;
619
+ // SSH: git@github.com:owner/repo[.git]
620
+ let m = /^git@github\.com:([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(trimmed);
621
+ if (m)
622
+ return `https://github.com/${m[1]}/${m[2]}`;
623
+ // ssh://git@github.com/owner/repo[.git]
624
+ m = /^ssh:\/\/git@github\.com\/([^/\s]+)\/([^/\s]+?)(?:\.git)?$/.exec(trimmed);
625
+ if (m)
626
+ return `https://github.com/${m[1]}/${m[2]}`;
627
+ // http(s)://github.com/owner/repo[.git][/...]
628
+ m = /^https?:\/\/github\.com\/([^/\s]+)\/([^/\s?#]+?)(?:\.git)?(?:[/?#].*)?$/.exec(trimmed);
629
+ if (m)
630
+ return `https://github.com/${m[1]}/${m[2]}`;
631
+ return null;
632
+ }
633
+ /**
634
+ * 0770 — Walk parent directories from `startDir` looking for a `.git` entry
635
+ * (directory OR file — git worktrees use a `.git` file). Bails at the
636
+ * filesystem root or after `maxLevels` iterations. Returns the absolute
637
+ * path of the discovered git root, or null.
638
+ */
639
+ export function walkUpForGitRoot(startDir, maxLevels = 12) {
640
+ let current = resolve(startDir);
641
+ for (let i = 0; i < maxLevels; i++) {
642
+ if (existsSync(join(current, ".git")))
643
+ return current;
644
+ const parent = dirname(current);
645
+ if (parent === current)
646
+ return null;
647
+ current = parent;
648
+ }
649
+ return null;
650
+ }
651
+ const authoredSourceLinkCache = new Map();
652
+ /**
653
+ * 0770 — Test-only helper to clear the module-level memoization cache so
654
+ * tests can isolate detection runs across `beforeEach`.
655
+ */
656
+ export function resetAuthoredSourceLinkCache() {
657
+ authoredSourceLinkCache.clear();
658
+ }
659
+ /**
660
+ * 0770 — Detect source-repo provenance for a locally-authored skill (no
661
+ * lockfile entry). Walks for `.git`, reads `origin` remote, normalizes via
662
+ * `parseGithubRemote`, and computes `skillPath` from `git ls-files` (with a
663
+ * filesystem fallback for untracked SKILL.md files). Memoized per absolute
664
+ * skill dir for the eval-server process lifetime.
665
+ *
666
+ * All git invocations use `execFileSync` with explicit argv (no shell), a
667
+ * 1500ms hard timeout, and silenced stderr. Any error converts to
668
+ * `{null, null}` — `buildSkillMetadata` never throws because of git.
669
+ */
670
+ export function detectAuthoredSourceLink(skillDir) {
671
+ const absDir = resolve(skillDir);
672
+ const cached = authoredSourceLinkCache.get(absDir);
673
+ if (cached)
674
+ return cached;
675
+ const compute = () => {
676
+ const gitRoot = walkUpForGitRoot(absDir);
677
+ if (!gitRoot)
678
+ return { repoUrl: null, skillPath: null };
679
+ let remote = "";
680
+ try {
681
+ remote = execFileSync("git", ["config", "--get", "remote.origin.url"], {
682
+ cwd: gitRoot,
683
+ timeout: 1500,
684
+ stdio: ["ignore", "pipe", "ignore"],
685
+ encoding: "utf-8",
686
+ }).trim();
687
+ }
688
+ catch {
689
+ return { repoUrl: null, skillPath: null };
690
+ }
691
+ const repoUrl = parseGithubRemote(remote);
692
+ if (!repoUrl)
693
+ return { repoUrl: null, skillPath: null };
694
+ let skillPath = null;
695
+ try {
696
+ const tracked = execFileSync("git", ["ls-files", "--full-name", "SKILL.md"], {
697
+ cwd: absDir,
698
+ timeout: 1500,
699
+ stdio: ["ignore", "pipe", "ignore"],
700
+ encoding: "utf-8",
701
+ }).trim();
702
+ if (tracked)
703
+ skillPath = tracked;
704
+ }
705
+ catch {
706
+ // fall through to filesystem fallback
707
+ }
708
+ if (!skillPath) {
709
+ // Filesystem fallback for untracked SKILL.md — same path the file will
710
+ // have on github.com once committed and pushed.
711
+ skillPath = relative(gitRoot, join(absDir, "SKILL.md")).replace(/\\/g, "/");
712
+ }
713
+ return { repoUrl, skillPath };
714
+ };
715
+ const result = compute();
716
+ authoredSourceLinkCache.set(absDir, result);
717
+ return result;
718
+ }
600
719
  /**
601
720
  * 0737 — Resolve the source-repo provenance (repoUrl + skillPath) for a
602
721
  * skill by looking up its lockfile entry. Two precedences:
@@ -608,19 +727,20 @@ export function deriveSourceAgent(skillDir, root, origin) {
608
727
  * skill dir basename and the lockfile key differ — fall back to the parent
609
728
  * directory's basename when no exact match exists.
610
729
  *
611
- * Returns `{ null, null }` for any non-github source (e.g.
612
- * `marketplace:specweave/sw#name`) guessing a repo URL would produce
613
- * broken anchors. Authored skills (no lockfile entry) also return null.
730
+ * 0770 When no lockfile entry resolves provenance, fall through to
731
+ * `detectAuthoredSourceLink` which inspects the parent git repo's origin
732
+ * remote. Lockfile-derived values still take precedence to preserve
733
+ * install-time provenance when the workspace itself is a git repo.
614
734
  */
615
735
  function resolveSourceLink(skillDir, root) {
616
736
  const lock = readLockfile(root);
617
737
  if (!lock)
618
- return { repoUrl: null, skillPath: null };
738
+ return detectAuthoredSourceLink(skillDir);
619
739
  const skillName = basename(skillDir);
620
740
  const parentName = basename(dirname(skillDir));
621
741
  const entry = lock.skills[skillName] ?? lock.skills[parentName];
622
742
  if (!entry)
623
- return { repoUrl: null, skillPath: null };
743
+ return detectAuthoredSourceLink(skillDir);
624
744
  if (entry.sourceRepoUrl) {
625
745
  return {
626
746
  repoUrl: entry.sourceRepoUrl,
@@ -636,6 +756,9 @@ function resolveSourceLink(skillDir, root) {
636
756
  // copy-chip (local path); a fresh `vskill add` writes the explicit
637
757
  // `sourceSkillPath` and restores the working anchor via the branch above.
638
758
  const m = /^github:([^/]+)\/([^/#]+)/.exec(entry.source ?? "");
759
+ // 0770: do NOT fall through here — an installed skill with a non-github
760
+ // `source` (e.g. `marketplace:...`) is still installed, not authored. Local
761
+ // git detection would leak the workspace remote (umbrella, etc.).
639
762
  if (!m)
640
763
  return { repoUrl: null, skillPath: null };
641
764
  return {