Skip to content

Fetch extensions from Forge's own releases - #29

Merged
thousandflowers merged 6 commits into
mainfrom
feat/extension-manager
Sep 5, 2026
Merged

thousandflowers merged 6 commits into
mainfrom
feat/extension-manager

Conversation

@thousandflowers

Copy link
Copy Markdown
Owner

A tool Forge does not ship arrives one of two ways: Homebrew, which is the user's own package manager, or a build Forge hosts and pins. This adds the second.

A JSON manifest names the version, the licence, the project and the exact bytes. The install resolves the build for this processor, streams the archive into a scratch folder inside the managed directory, checks its SHA-256 against the manifest, unpacks it, takes macOS's quarantine flag off it, and moves the finished folder into place in one rename — a rename, so the scratch lives on the same volume. Nothing is bundled in the .app, and nothing runs that did not hash to what the manifest promised.

An extension is a folder, not a file: a binary that travels with its dylibs or its data is the ordinary case. Developer ID verification has a hook and no implementation, because the hosted builds are ad-hoc signed and vouched for by their hash.

The leverage point is ExternalTools.locate, which now looks in the managed directory first — so ExternalProcessor, ConvertKind gating and FormatCatalog needed no changes at all. Two things stood between installing a tool and using it and are fixed here: what pandoc reads and writes was asked once and kept forever, and a tool's standard output went to a pipe nobody read, which stops a chatty one dead at 64KB.

Surfaces: the Capabilities card (version installed, update available, download with progress, remove) with consent first — version, licence, project, size, and where it lands — and forge extensions list | add | remove, where a download needs --yes.

Not done, needs your call: publishing extensions.json and the built tarballs to the Releases page. That is release configuration, so I stopped there.

Test plan

  • swift test — green
  • 11 tests: arch resolution and the Rosetta fallback, manifest parsing, a schema from the future refused, checksum mismatch aborting with nothing left on disk, install → record → locate → remove round trip, update replacing the old version
  • Pilot: a real Markdown → DOCX through a pandoc the manager fetched, checked and unpacked
  • Real 265MB pandoc served over HTTP: consent shown, refused without --yes, installed, converted, tampered checksum aborted, removed

🤖 Generated with Claude Code

https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578

thousandflowers and others added 6 commits September 5, 2026 21:50
A tool Forge does not ship arrives one of two ways: Homebrew, which is
the user's own package manager, or a build Forge hosts and pins. This
adds the second. A JSON manifest names the version, the licence, the
project and the exact bytes; the install streams the archive to a
scratch folder inside the managed directory, checks its SHA-256 against
the manifest, unpacks it, takes the quarantine flag off it and moves the
finished folder into place in one rename.

An extension is a folder, not a file: a binary that travels with its
dylibs or its data is the ordinary case.

Nothing is bundled in the .app, and nothing runs that did not hash to
what the manifest promised. Developer ID verification has a hook and no
implementation: the hosted builds are ad-hoc signed and vouched for by
their hash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578
Three things stood between installing a tool and using it.

`locate` looked only at PATH, so a fetched extension was invisible to
every part of the app that decides whether to offer a conversion. It now
looks in the managed directory first: that build is the one the manifest
named and the hash was checked against, which is the only copy Forge can
say anything true about.

What pandoc reads and writes was asked once and kept forever. A pandoc
that was not installed when it was asked answered nothing, and keeping
that answer hid every format it had just added.

And a tool's standard output went to a pipe nobody read, which stops a
chatty one dead at 64KB. It goes to /dev/null now, and its complaint is
read as it comes with only the last 64KB kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578
Fixtures are built in the test's own scratch directory and served from a
file: URL, so the suite needs no network. The store is repointed for the
duration, so a run cannot see or write what the person using Forge has
installed.

The pilot converts a real Markdown file to DOCX through a pandoc that
was fetched, checked and unpacked by the manager, and skips itself where
pandoc is not installed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578
The card now shows which copy of a tool is here — one Forge fetched, at
its version, or one the user's own package manager put there — and
offers the download when Forge hosts a build. Consent first, as with the
Homebrew command: version, licence, project, size and where it lands,
before anything is downloaded.

The README claimed Forge downloads nobody's binaries. It downloads the
ones you ask it for now, from its own releases, checksum checked, and
says so.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578
The same pipeline as the app's card, from a script and without one. A
download is printed in full before it happens and needs --yes: it writes
to the user's machine, so it is not something a command should do on the
way past.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018H9CZxWumc3ryumgEHd578
# Conflicts:
#	Sources/Forge/Services/ExternalTools.swift
@thousandflowers
thousandflowers merged commit a07c5a7 into main Sep 5, 2026
1 check failed
@thousandflowers
thousandflowers deleted the feat/extension-manager branch September 5, 2026 21:41
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.

1 participant