Skip to content

Add managed extension installation with hunk extension commands - #712

Merged
benvinegar merged 8 commits into
mainfrom
claude/hunk-extension-hosting-jp6hot
Aug 12, 2026
Merged

Add managed extension installation with hunk extension commands#712
benvinegar merged 8 commits into
mainfrom
claude/hunk-extension-hosting-jp6hot

Conversation

@benvinegar

Copy link
Copy Markdown
Member

Implement a complete extension management system that allows users to install, list, update, and remove shared extensions from git repositories.

Summary

This change adds four new hunk extension subcommands that enable users to discover and manage extensions shared as plain git repositories, without requiring a centralized registry. Extensions install into a managed directory (~/.config/hunk/extensions/installed/), with their sources and commits recorded for reproducible updates.

Key Changes

  • Extension installation CLI (src/extensions/manage/install.ts): Core operations for cloning extension repositories, validating they contain extensions, installing npm dependencies, and managing install records. Includes staging/promotion pattern to ensure failed installs leave the filesystem untouched.

  • Install source parsing (src/extensions/manage/source.ts): Flexible source spec parser supporting GitHub shorthand (owner/repo), git-prefixed hosts (git:host/path), explicit git URLs, and local paths, all with optional @ref pinning for branches/tags/commits.

  • Install records (src/extensions/manage/records.ts): Persistent JSON records tracking each managed install's source, clone URL, pinned ref, current commit, and timestamps. Records are the source of truth for which directories Hunk owns.

  • CLI integration (src/extensions/manage/cli.ts): User-facing command runner with interactive confirmation for installs (required for security since extensions execute with full user permissions), plus output formatting for list/update/remove operations.

  • Manifest API versioning: Extensions can declare a minimum extension API version via "hunk": { "apiVersion": N } in their package.json. Hunk refuses to load extensions requiring a newer API, with a clear startup notice instead of runtime failures.

  • Discovery integration (src/extensions/discovery.ts): Updated to attach requiresApiVersion from manifests to candidates, and gated loading in src/extensions/host.ts to reject candidates whose API requirements exceed the current HUNK_EXTENSION_API_VERSION.

  • Tests: Comprehensive test coverage for install/update/remove operations, source parsing, manifest API versioning, and CLI parsing.

  • Documentation: Updated extension authoring guide and CLI reference with install command syntax, examples, and guidance on declaring API version requirements.

Implementation Details

  • Staging pattern: Clones and validation happen in a .staging-<name> directory; only successful clones are promoted to their final location, ensuring atomicity.
  • Dependency handling: Dependencies are installed via bun install --production when declared; missing bun degrades to a warning rather than failing the install.
  • Git fallback: Shallow --branch clones are attempted first for efficiency; on failure (bare commits, shallow-fetch refusal), falls back to full clone + checkout.
  • Confirmation seam: Install requires explicit user confirmation or --yes flag; confirmation is skipped in non-TTY environments when --yes is provided.
  • API version gating: Checked before any module import, so syntax errors in incompatible extensions never surface to the user.

https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb

@vercel

vercel Bot commented Aug 11, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
hunk-web Ignored Ignored Preview Aug 12, 2026 1:34am

Request Review

@greptile-apps

greptile-apps Bot commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR adds managed Git-based extension installation, persistent ownership records, extension discovery integration, and manifest API-version gating.

  • Adds hunk extension install, list, update, and remove command handling.
  • Introduces staged clones, dependency installation, persistent install records, and automatic global discovery.
  • Adds minimum extension API declarations and pre-import compatibility checks.
  • Updates extension authoring and CLI documentation.

Confidence Score: 2/5

The PR should not merge until local tilde sources, failure-safe promotion, and concurrent record updates are corrected.

Documented local installs fail for tilde paths, a failed staged rename can delete the working extension, and overlapping commands can lose ownership records or interfere with shared staging directories.

Files Needing Attention: src/extensions/manage/source.ts, src/extensions/manage/install.ts, src/extensions/manage/cli.ts

Important Files Changed

Filename Overview
src/extensions/manage/source.ts Adds source parsing for shorthand, URLs, refs, and local paths, but preserves documented tilde paths literally and therefore cannot clone them.
src/extensions/manage/install.ts Implements clone, validation, dependency preparation, promotion, update, and removal, but promotion can destroy the old install and concurrent operations can corrupt ownership state.
src/extensions/manage/records.ts Adds normalized persistent install records, though full-map callers lack cross-process coordination.
src/extensions/discovery.ts Adds managed-root scanning and propagates manifest API requirements through discovered candidates.
src/extensions/host.ts Rejects extensions requiring a newer API before importing them while allowing later compatible duplicate IDs.
src/extensions/manage/cli.ts Adds command execution, confirmation, and output handling, with one repository-rule violation from direct global environment access.
src/main.tsx Dispatches the new headless extension-management startup plan without loading the interactive UI.

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart LR
  CLI[hunk extension command] --> Parse[Parse source and optional ref]
  Parse --> Stage[Clone into .staging-name]
  Stage --> Validate[Validate extension layout]
  Validate --> Dependencies[Install production dependencies]
  Dependencies --> Promote[Promote into installed/name]
  Promote --> Records[Write records.json]
  Records --> Discovery[Global extension discovery]
  Discovery --> Gate[API version and ID gate]
  Gate --> Load[Load extension]
Loading
Prompt To Fix All With AI
### Issue 1
src/extensions/manage/source.ts:101-102
**Tilde paths remain unexpanded**

When a user runs the documented `hunk extension install ~/dev/hunk-word-diff` form, the parser preserves `~` in `cloneUrl` and passes it directly to Git without shell expansion, causing Git to report that the local repository does not exist.

### Issue 2
src/extensions/manage/install.ts:220-221
**Promotion deletes the active install**

If `renameSync` fails during an update, `promoteStagedClone` has already recursively deleted the active installation, leaving the staged clone orphaned and the unchanged record pointing to a missing directory.

### Issue 3
src/extensions/manage/install.ts:225-232
**Concurrent commands lose install records**

If two extension-management processes overlap, each can rewrite `records.json` from a stale full-map snapshot, causing one successful installation to lose its ownership record and disappear from `list`, `update`, and `remove`; same-name operations also share and delete the same deterministic staging directory.

### Issue 4
src/extensions/manage/cli.ts:62
**Direct global environment access**

The production command path falls back directly to `process.env`, bypassing the repository's required validated environment configuration boundary and adding untyped global state that callers and tests must account for.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(extensions): require deliberate exte..." | Re-trigger Greptile

Comment thread src/extensions/manage/source.ts Outdated
Comment on lines +101 to +102
if (hasExplicitTransport(location) || isLocalPath(location)) {
cloneUrl = location;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Tilde paths remain unexpanded

When a user runs the documented hunk extension install ~/dev/hunk-word-diff form, the parser preserves ~ in cloneUrl and passes it directly to Git without shell expansion, causing Git to report that the local repository does not exist.

Knowledge Base Used: Extension System: API, Loading, and Host Integration

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/extensions/manage/source.ts
Line: 101-102

Comment:
**Tilde paths remain unexpanded**

When a user runs the documented `hunk extension install ~/dev/hunk-word-diff` form, the parser preserves `~` in `cloneUrl` and passes it directly to Git without shell expansion, causing Git to report that the local repository does not exist.

**Knowledge Base Used:** [Extension System: API, Loading, and Host Integration](https://app.greptile.com/modem/-/custom-context/knowledge-base/modem-dev/hunk/-/docs/extension-system.md)

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a8e4d19. Local path sources now go through the same expandHomePath discovery uses for config paths, and are additionally resolved to absolute before being recorded — so the documented ~/dev/... form clones correctly and a later hunk extension update works from any working directory. Covered by a new test asserting both the tilde expansion and the absolute-path recording.


Generated by Claude Code

Comment thread src/extensions/manage/install.ts Outdated
Comment on lines +220 to +221
rmSync(directory, { recursive: true, force: true });
renameSync(stagingDir, directory);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Promotion deletes the active install

If renameSync fails during an update, promoteStagedClone has already recursively deleted the active installation, leaving the staged clone orphaned and the unchanged record pointing to a missing directory.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/extensions/manage/install.ts
Line: 220-221

Comment:
**Promotion deletes the active install**

If `renameSync` fails during an update, `promoteStagedClone` has already recursively deleted the active installation, leaving the staged clone orphaned and the unchanged record pointing to a missing directory.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in a8e4d19. promoteStagedClone now moves the existing install aside to a dot-prefixed backup directory, renames the staged clone into place, and only then deletes the backup; if the swap rename fails, the previous install is restored and the staging dir cleaned up, so an update either lands whole or leaves the prior install untouched. Discovery also explicitly skips the installer's dot-prefixed workspace directories so a backup or staging dir can never load as an extension.


Generated by Claude Code

Comment thread src/extensions/manage/install.ts Outdated
Comment on lines +225 to +232
function saveRecord(
context: ExtensionManageContext,
records: ExtensionInstallRecordMap,
name: string,
record: ExtensionInstallRecord,
) {
writeInstallRecords(context.installedRoot, { ...records, [name]: record });
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Concurrent commands lose install records

If two extension-management processes overlap, each can rewrite records.json from a stale full-map snapshot, causing one successful installation to lose its ownership record and disappear from list, update, and remove; same-name operations also share and delete the same deterministic staging directory.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/extensions/manage/install.ts
Line: 225-232

Comment:
**Concurrent commands lose install records**

If two extension-management processes overlap, each can rewrite `records.json` from a stale full-map snapshot, causing one successful installation to lose its ownership record and disappear from `list`, `update`, and `remove`; same-name operations also share and delete the same deterministic staging directory.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in a8e4d19. Staging directories are now pid-suffixed, so overlapping commands for the same name can't clone into or delete each other's workspace, and saveRecord merges over a fresh read of records.json at write time instead of the snapshot taken before the clone — the long clone-in-the-middle window is gone. The remaining read-to-write gap matches the documented, accepted posture of Hunk's shared state file (updateHunkStateRecord), which deliberately trades a tiny unlocked window over a lock file that needs crash reaping.


Generated by Claude Code

input: ExtensionManageCommandInput,
io: ExtensionManageIo,
): Promise<number> {
const env = io.env ?? process.env;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Direct global environment access

The production command path falls back directly to process.env, bypassing the repository's required validated environment configuration boundary and adding untyped global state that callers and tests must account for.

Context Used: guidelines.mdc Cursor rule (source)

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/extensions/manage/cli.ts
Line: 62

Comment:
**Direct global environment access**

The production command path falls back directly to `process.env`, bypassing the repository's required validated environment configuration boundary and adding untyped global state that callers and tests must account for.

**Context Used:** guidelines.mdc Cursor rule ([source](https://github.com/modem-dev/modem/blob/main/.cursor/rules/guidelines.mdc))

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Leaving this one as is: the cited guideline is from a different repository's rules, and hunk's convention is the opposite — env parameters defaulting to process.env are the established pattern across this codebase (resolveGlobalExtensionsDir, resolveRepoTrust, discoverExtensions, loadStartupExtensions all do exactly this). The io.env seam exists so tests inject an isolated environment, which they do.


Generated by Claude Code

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fair point — if env defaulting to process.env is the consistent pattern throughout this codebase, then flagging it here is noise. The io.env seam already gives tests the isolation they need. I'll drop the concern.

Shall I remember this as a rule: "In this codebase, env parameters defaulting to process.env are the accepted pattern — do not flag them as a violation"?

claude added 8 commits August 12, 2026 01:33
Shared extensions need a way to state the minimum extension API they were
built against, so an older Hunk refuses them with one actionable startup
notice instead of failing somewhere inside the factory. The manifest's hunk
field now accepts apiVersion alongside extensions, discovery carries it on
the candidate, and the host gates it with the id checks before anything is
imported.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Sharing an extension previously meant telling users to clone and cp by
hand. hunk extension install clones a git source (owner/repo shorthand,
git:host/path, full URLs, or a local path, each with an optional @ref)
into a managed directory under the global extensions dir, validates it
actually contains an extension, installs its npm dependencies when it
declares any, and records the source and resolved commit so list, update,
and remove can operate on exactly what Hunk installed. Installs are
confirmed interactively (or with --yes) because extensions run with full
user permissions, and discovery loads the managed directory through the
existing global origin so no new trust surface is introduced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Sharing an extension now has a documented path end to end: the repo guide,
website guide, README, and hunk-extensions skill all describe the
folder-extension repository layout, manifest metadata, hunk extension
install sources, and the hunk-extension GitHub topic that serves as the
zero-infrastructure community listing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
An update stages a fresh clone and compares its commit before swapping, but
dependencies were installed during staging — before the comparison — so a
pinned or unchanged install paid a full bun install on every update run.
Stage now clones and validates only, and dependencies install just before a
staged clone is promoted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Install validation accepted any repository with a bare src/index.ts one
level down — the shape of nearly every JavaScript project, including pi
extensions whose manifests use a pi field instead of hunk — and such
installs could only fail later at load time. The installer now requires a
root hunk manifest, a root index entry, top-level entry files, or a
subfolder with its own hunk manifest before recording a clone.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Package-manager muscle memory expects a short spelling, and the daemon
command already set the precedent with its mcp alias.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Address review findings: local install sources now expand ~ and are
recorded absolute so a later update works from any directory; promotion
moves the previous install aside and restores it if the swap fails instead
of deleting it first; records merge over a fresh read at write time and
staging directories are pid-suffixed, so overlapping commands cannot drop
each other's records or share a workspace; discovery skips the installer's
dot-prefixed workspace directories.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
Repository-name derivation split only on / and :, so a Windows local
source like C:\dev\hunk-ext produced a backslash-riddled name that
failed id validation and broke every install on Windows. Name derivation
and ref splitting now treat both separators, Windows-style relative
prefixes count as local paths, and the local-path test builds its fixture
with join so CI exercises real separators on every platform.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KLy4tadVCxapwRdfT9C7nb
@benvinegar
benvinegar force-pushed the claude/hunk-extension-hosting-jp6hot branch from 6d3e285 to 8f8375a Compare August 12, 2026 01:34
@benvinegar
benvinegar enabled auto-merge (squash) August 12, 2026 01:38
@benvinegar
benvinegar merged commit 994f66d into main Aug 12, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants