diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index b7b1ca7e..4f3c4082 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -134,6 +134,52 @@ describe("extensionGroupKey", () => { ); }); + it("prefers install_meta.url over a polluted enclosing-repo source.url", () => { + // Regression: an agent home (e.g. ~/.claude) is kept inside the user's own + // dotfiles git repo, so the scanner walks up and stamps that copy with a + // *wrong* source.url (the enclosing backup repo). The other agents' copies + // sit in non-git dirs and stay sourceless. All copies were installed from + // tw93/waza, so they must group into one row. Pre-fix, source.url won and + // the git-backed copy forked into its own dotfiles-repo group. + const wazaInstallMeta = { + install_type: "marketplace", + url: "tw93/waza", + url_resolved: null, + branch: null, + subpath: null, + revision: "51222bf", + remote_revision: null, + checked_at: null, + check_error: null, + }; + const claudeCopyPolluted: Extension = { + ...baseExt, + name: "check", + agents: ["claude"], + source: { + ...baseExt.source, + origin: "git", + url: "https://github.com/octo-user/dotfiles.git", + }, + install_meta: wazaInstallMeta, + }; + const codexCopySourceless: Extension = { + ...baseExt, + name: "check", + agents: ["codex"], + source: { ...baseExt.source, origin: "agent", url: null }, + install_meta: wazaInstallMeta, + }; + // The polluted Claude copy now resolves to its true origin… + expect(extensionGroupKey(claudeCopyPolluted)).toBe( + "skill\0check\0tw93/waza", + ); + // …and groups with the sourceless siblings instead of forking off. + expect(extensionGroupKey(claudeCopyPolluted)).toBe( + extensionGroupKey(codexCopySourceless), + ); + }); + it("uses pack as a user-driven tiebreaker for unlinked rows", () => { // Real-world case: arxiv-search was deployed to 4 agents but only the // agent that received the original `hk install` carries install_meta. diff --git a/src/lib/types.ts b/src/lib/types.ts index 38803f2d..76b079a7 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -139,18 +139,28 @@ export function instanceDir(inst: Extension): string | null { } /** Authoritative "where did this come from" URL for grouping purposes. - * Resolution order: source.url → install_meta.url → pack (synthesized to a - * GitHub URL so extractDeveloper handles it uniformly). `pack` is a - * user-editable field on the detail panel; treating it as a tiebreaker - * means a user can merge two unlinked rows into one group by typing the - * owner/repo identifier (e.g. arxiv-search where only one of four copies - * carries install_meta from the original install). Returns `null` when an - * extension is truly sourceless (hand-written project skill, agent-bundled - * global skill the user never linked, etc.). */ + * Resolution order: install_meta.url → source.url → pack (synthesized to a + * GitHub URL so extractDeveloper handles it uniformly). + * + * `install_meta.url` (written by HK at install time, not user-editable) is + * the authoritative origin and wins first. `source.url` comes from the + * scanner walking up to the nearest `.git` remote — usually null for + * marketplace skills, but it can be a *wrong* value when the user keeps an + * agent home (e.g. `~/.claude`) under their own dotfiles git repo: the + * enclosing backup remote then masks the real install source and forks one + * skill into two group rows. Preferring install_meta avoids that; source.url + * stays the fallback for git-cloned skills that carry no install_meta. + * + * `pack` is a user-editable field on the detail panel; treating it as the + * last tiebreaker means a user can merge two unlinked rows into one group by + * typing the owner/repo identifier (e.g. arxiv-search where only one of four + * copies carries install_meta from the original install). Returns `null` + * when an extension is truly sourceless (hand-written project skill, + * agent-bundled global skill the user never linked, etc.). */ export function deriveExtensionUrl(ext: Extension): string | null { return ( - ext.source.url ?? ext.install_meta?.url ?? + ext.source.url ?? (ext.pack ? `https://github.com/${ext.pack}` : null) ); }