Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 46 additions & 0 deletions src/lib/__tests__/types.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
28 changes: 19 additions & 9 deletions src/lib/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
);
}
Expand Down
Loading