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
7 changes: 4 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,9 +364,10 @@ Gemini auto-discovers the `skills/` folder next to it):
gemini extensions install https://github.com/zuke-build/zuke
```

Gemini installs a GitHub extension from the repo's **latest release** snapshot
(offering a git clone as the fallback), so the extension tracks releases rather
than `master`.
Gemini installs a GitHub extension from the repo's **latest release**, so the
extension tracks releases rather than `master`. Each release carries a minimal
extension archive (the manifest plus `skills/`, attached by the `release`
target), so the install downloads two skills, not the whole monorepo.

> The `SKILL.md` content is harness-agnostic (the open
> [Agent Skills](https://agentskills.io) standard); each manifest above is a
Expand Down
105 changes: 105 additions & 0 deletions build/gemini_archive.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
// Copyright (c) 2026 the Zuke contributors
// SPDX-License-Identifier: MIT

/**
* The Gemini CLI extension archive attached to GitHub releases.
*
* `gemini extensions install <github url>` prefers a release asset over
* cloning: it resolves the repository's **latest** release and picks an asset
* by name — `{platform}.{arch}.{name}`, then `{platform}.{name}`, then a
* single generic asset. Without one it downloads the whole source tarball,
* which for this monorepo means shipping 50+ packages to install two skill
* folders. This module builds a minimal, deterministic archive — the root
* `gemini-extension.json` plus `skills/` and the license, exactly what the
* extension serves — for the `release` target to attach to every release.
*
* The asset trio is platform-prefixed rather than one generic file on
* purpose: Gemini's fallback accepts a generic asset only when it is the
* *only* asset on the release, so a second file attached later (a checksum, an
* SBOM) would silently degrade installs back to the source tarball. Platform
* prefixes match deterministically regardless of what else the release
* carries; the extension is platform-independent, so all three names carry
* the same bytes.
*
* @module
*/

import { createTarGzip } from "@zuke/core";

/** The extension manifest Gemini requires at the archive root. */
export const GEMINI_MANIFEST = "gemini-extension.json";

/** The skills tree the extension serves. */
export const GEMINI_SKILLS_DIR = "skills";

/**
* The asset names to attach to a release, one per platform Gemini matches
* (`os.platform()` values). All three point at identical archive bytes.
*/
export const GEMINI_ASSET_NAMES: readonly string[] = [
"darwin.zuke.tar.gz",
"linux.zuke.tar.gz",
"win32.zuke.tar.gz",
];

/** Every file under `dir`, as `dir`-prefixed paths, sorted for determinism. */
async function walk(root: string, dir: string): Promise<string[]> {
const out: string[] = [];
for await (const entry of Deno.readDir(`${root}/${dir}`)) {
const path = `${dir}/${entry.name}`;
if (entry.isDirectory) out.push(...await walk(root, path));
else if (entry.isFile) out.push(path);
else {
// Silently dropping it would ship an archive missing content that git
// (and every other harness) still carries.
throw new Error(
`the Gemini extension archive cannot pack "${root}/${path}" — it is ` +
"neither a regular file nor a directory (a symlink?). Keep " +
`${GEMINI_SKILLS_DIR}/ to real files so every harness ships the ` +
"same content.",
);
}
}
return out.sort();
}

/**
* The files the extension archive packs, relative to `root`, in a stable
* order: the manifest, the license, then every file under `skills/`.
*/
export async function geminiArchiveFiles(root = "."): Promise<string[]> {
for (const required of [GEMINI_MANIFEST, "LICENSE"]) {
const info = await Deno.stat(`${root}/${required}`).catch(() => null);
if (info?.isFile !== true) {
throw new Error(
`the Gemini extension archive requires ${required} at the extension ` +
`root — nothing at "${root}/${required}".`,
);
}
}
try {
return [GEMINI_MANIFEST, "LICENSE", ...await walk(root, GEMINI_SKILLS_DIR)];
} catch (error) {
if (error instanceof Deno.errors.NotFound) {
throw new Error(
`the Gemini extension archive requires a ${GEMINI_SKILLS_DIR}/ tree ` +
`under "${root}" — Gemini auto-discovers skills from it.`,
);
}
throw error;
}
}

/**
* Write the extension archive to `dest`: a `.tar.gz` with
* `gemini-extension.json` at the root, which is where Gemini requires it.
* Returns the paths that were packed.
*/
export async function buildGeminiArchive(
dest: string,
root = ".",
): Promise<string[]> {
const files = await geminiArchiveFiles(root);
await createTarGzip(files, dest, { cwd: root });
return files;
}
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -160,6 +160,7 @@
"tfvars",
"tmpl",
"todorov",
"tokenless",
"topo",
"totollygeek",
"transpiling",
Expand Down
77 changes: 76 additions & 1 deletion llms-full.txt
Original file line number Diff line number Diff line change
Expand Up @@ -9419,6 +9419,9 @@ function readWorkflowResult(state: TargetStateHandle): WorkflowResult | undefine
async function tagCommit(configure?: (settings: GhTagSettings) => GhTagSettings): Promise<void>
Perform the configured tag.

async function uploadReleaseAsset(configure?: Configure<GhReleaseAssetSettings>): Promise<GhReleaseAssetResult>
Upload the release asset the settings describe.

async function uploadSarifReport(configure?: Configure<GhSarifSettings>): Promise<GhSarifUploadResult>
Upload the SARIF report the settings describe.

Expand Down Expand Up @@ -9663,6 +9666,52 @@ class GhPullRequestSettings
authToken_(): string
The effective token, from the setting or the environment.

class GhReleaseAssetSettings
Settings for {@link GhReleaseAssetApi.uploadReleaseAsset}.

file_?: string
The file to upload. Set by {@link file}.
name_?: string
The asset name on the release. Set by {@link name}.
contentType_?: string
The asset's `content-type`. Set by {@link contentType}.
repo_?: string
`owner/repo` to upload to. Set by {@link repo}.
tag_?: string
The release tag to attach to. Set by {@link tag}.
token_?: string
The token to authenticate with. Set by {@link token}.
baseUrl_: string
REST base URL. Set by {@link baseUrl}.
fetch_: typeof fetch
The `fetch` implementation. Set by {@link fetch}.
file(path: PathLike): this
The file to upload (required).
name(value: string): this
The asset's name on the release. Defaults to the file's base name.
contentType(value: string): this
The asset's `content-type`. Defaults by extension (`.tar.gz`/`.tgz`,
`.zip`, `.json`), then to `application/octet-stream`.
repo(slug: string): this
The `owner/repo` to upload to. Defaults to `GITHUB_REPOSITORY`.
tag(value: string): this
Attach to the release with this tag instead of the latest release.
token(value: string): this
The token to authenticate with — needs `contents: write`. Defaults to
`GITHUB_TOKEN` in the environment, so it never has to reach argv.
baseUrl(url: string): this
Use a different REST base (GitHub Enterprise Server).
fetch(fn: typeof fetch): this
Override the `fetch` implementation (a test seam).
repoSlug_(): string
The effective `owner/repo`, from the setting or the Actions environment.
filePath_(): string
The file to upload, or a friendly error naming the missing setting.
assetName_(): string
The effective asset name: the setting, or the file's base name.
effectiveContentType_(): string
The effective `content-type`: the setting, or inferred by extension.

class GhSarifSettings
Settings for {@link GhSarifApi.uploadSarif}.

Expand Down Expand Up @@ -9918,6 +9967,32 @@ interface GhPullRequestResult
different things to a human reading a build log, even though neither is a
failure.

interface GhReleaseAssetApi
The shape of the release-asset task, mixed into `GhTasks`.

uploadReleaseAsset(configure?: Configure<GhReleaseAssetSettings>): Promise<GhReleaseAssetResult>
Attach a file to a GitHub release — the latest release by default, or the
one named by `.tag(...)`. Idempotent: an asset the release already
carries under the same name is kept as-is, and a repository with no
releases resolves to `state: "no-release"` rather than throwing. Needs a
token with `contents: write`.

interface GhReleaseAssetResult
What became of a release-asset upload.

state: "uploaded" | "already-exists" | "no-release"
`uploaded` when the asset was sent; `already-exists` when the release
carries an asset of the same name (nothing was changed); `no-release`
when the repository has no release to attach to.
name: string
The asset name the call targeted.
releaseTag?: string
The tag of the release the asset belongs to, when one was resolved.
releaseId?: number
The id of the release the asset belongs to, when one was resolved.
url?: string
The asset's download URL, when it was uploaded or already present.

interface GhSarifApi
The shape of the SARIF task, mixed into `GhTasks`.

Expand All @@ -9933,7 +10008,7 @@ interface GhSarifUploadResult
url: string
The URL that reports whether GitHub finished processing the report.

interface GhTasksApi extends GhAppTokenApi, GhSarifApi, GhCommitApi, GhPullRequestApi, GhCheckRunApi
interface GhTasksApi extends GhAppTokenApi, GhSarifApi, GhReleaseAssetApi, GhCommitApi, GhPullRequestApi, GhCheckRunApi
The shape of {@link GhTasks}: the `gh` CLI plus the GitHub operations that
have no CLI subcommand (see {@link GhAppTokenApi}, {@link GhSarifApi}) and
would otherwise force a build back to a marketplace action.
Expand Down
Loading