From 2cb325347ac8ab78328e1c696b40e5342ff0a66d Mon Sep 17 00:00:00 2001 From: trick77 <1525566+trick77@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:02:59 +0100 Subject: [PATCH 1/3] Add @archive; planify-german installs diagram-design from a pinned archive skill-diagram-design needed a hand-made clone, so it was usually missing and planify silently dropped every diagram. @archive downloads a .tar.gz, verifies sha256 and unpacks it (optionally one subdir) into the cache with a built-in tar reader that refuses links, devices and escaping paths. skill-diagram-design 1.0.0 uses it at commit 10cfad6 (skills/ only, 287 files), pinned via a new github-branch pin source; the planify-german bundle now includes it. --- AGENTS.md | 8 +- README.md | 10 +- presets/planify-german.conf | 10 +- presets/skill-diagram-design.conf | 19 +-- scripts/check-pins.mjs | 14 ++- src/batch.ts | 15 ++- src/fetch-archive.ts | 183 +++++++++++++++++++++++++++++ src/parse-conf.ts | 44 ++++++- src/pin-sources.ts | 5 +- test/builtin-presets.test.ts | 42 ++++--- test/deny-warnings.test.ts | 2 +- test/distribute-set-values.test.ts | 1 + test/fetch-archive.test.ts | 118 +++++++++++++++++++ test/parse-conf.test.ts | 33 ++++++ 14 files changed, 467 insertions(+), 37 deletions(-) create mode 100644 src/fetch-archive.ts create mode 100644 test/fetch-archive.test.ts diff --git a/AGENTS.md b/AGENTS.md index 4de30d6..f49e4a7 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,6 +45,12 @@ Order irrelevant. reads `tui.json`. - `@mode` — `replace` (default) | `merge` | `merge-overwrite` | `append`. - `@fetch: URL -> dest [sha256=hex]` — repeatable. +- `@archive: URL -> destDir sha256=hex [subdir=path]` — repeatable. `.tar.gz` + unpacked into a cache dir (`src/fetch-archive.ts`): top-level dir stripped, + only `subdir` kept, sha256 mandatory. Links, devices, `..`, absolute paths → + whole archive refused. Cache hit = marker file in destDir with that sha256. + For skill repos without tags (`skill-diagram-design`): pin the commit SHA in + `@pins` (source kind `github-branch`). - `@pins: name version` — repeatable, one per pinned third-party artifact. Version must also appear in body or `@fetch` (tested). - `@prompt: name | type | help | default | setup` — repeatable; type @@ -213,7 +219,7 @@ wrong, not good news — lombok (since dropped) needed git tags because Maven Central's index and projectlombok.org's download page each reported a stale "latest". -`@fetch` → always `sha256=`, and version the dest filename; `fetchAsset` skips +`@fetch`/`@archive` → always `sha256=`, and version the dest filename; `fetchAsset` skips an existing dest, so reusing the filename makes a bump a no-op. Verify the hash against a second source before committing. diff --git a/README.md b/README.md index fdbbd89..35eac3b 100644 --- a/README.md +++ b/README.md @@ -79,8 +79,8 @@ something outside your opencode config say so in their description. | `plugin-superpowers` | Plugin | append | Add the Superpowers OpenCode plugin from `obra/superpowers` (brainstorming, plans, TDD, review workflows; pins tag `v7.0.0`) | | `agent-runaway-guard` | Agent | merge | Adds step limits to built-in agents to prevent runaway tool loops | | `default-agent-plan` | Agent | replace | Sets the default agent to "plan" so opencode always starts in plan mode instead of build mode | -| `planify-german` | Bundle | — | The whole planify setup, which does nothing in parts: the `opencode-planify-german` plugin from npm (pinned to `1.0.1`, needs opencode 2), registering a `plan_render` tool that validates a plan JSON against a schema and renders a self-contained HTML file to `docs/plans/-.html`, and a `pr_summary` tool that writes a short task/problem/solution summary into the description of the Bitbucket Data Center PR of the current branch, opening it as a draft if none is open; the rules file that makes opencode answer in German with Swiss orthography (never the eszett character, always `ss`) and send every plan through `plan_render`; and the `planify` skill documenting the plan schema. Prompts for the Bitbucket URL and an HTTP access token with repository write, stored as plugin options in `opencode.json` (token in plain text; prefer `--set-env bitbucketToken=ENV_VAR`). Rules file and skill are fetched sha256-verified. Run `opencode service restart` after installing | -| `skill-diagram-design` | Skill | append | Needs a clone of the repo on disk first — `git clone https://github.com/cathrynlavery/diagram-design ~/src/diagram-design`. Registers the `diagram-design` skill (editorial diagram types as self-contained HTML + SVG) by appending your clone's `skills/` dir to `skills.paths`; answer with that clone's `skills/` dir (`--set skillsDir=/Users/you/src/diagram-design/skills`); the install refuses if the dir is not there. Tracks `main` — the repo ships no tags. One-way: `remove` cannot undo an append preset that prompts, so uninstall by deleting the one `skills.paths` entry by hand | +| `planify-german` | Bundle | — | The whole planify setup, which does nothing in parts: the `opencode-planify-german` plugin from npm (pinned to `1.0.1`, needs opencode 2), registering a `plan_render` tool that validates a plan JSON against a schema and renders a self-contained HTML file to `docs/plans/-.html`, and a `pr_summary` tool that writes a short task/problem/solution summary into the description of the Bitbucket Data Center PR of the current branch, opening it as a draft if none is open; the rules file that makes opencode answer in German with Swiss orthography (never the eszett character, always `ss`) and send every plan through `plan_render`; the `planify` skill documenting the plan schema; and `skill-diagram-design`, which draws the plan's diagrams. Prompts for the Bitbucket URL and an HTTP access token with repository write, stored as plugin options in `opencode.json` (token in plain text; prefer `--set-env bitbucketToken=ENV_VAR`). Rules file and skills are fetched sha256-verified. Run `opencode service restart` after installing | +| `skill-diagram-design` | Skill | append | Registers the `diagram-design` skill (editorial diagram types as self-contained HTML + SVG). Downloads the archive of one pinned commit of [cathrynlavery/diagram-design](https://github.com/cathrynlavery/diagram-design), sha256-verified, unpacks its `skills/` dir to `/diagram-design-skills-` and appends that to `skills.paths`. No clone. The repo ships no tags, so a new upstream commit means a new preset version. Coming from 0.3.x (clone + prompt): delete the old clone's `skills.paths` entry by hand, or the skill is registered twice | ### Bundles @@ -363,6 +363,12 @@ Presets write `opencode.json` only. `@target: tui` is rejected: opencode 2 does not read `tui.json`. `@fetch: -> [sha256=hex]` downloads to the cache. +`@archive: -> sha256=hex [subdir=path]` downloads a `.tar.gz`, +checks its sha256 and unpacks it into the cache: the archive's top-level +directory is stripped, and with `subdir` only that part is kept. Only regular +files and directories are accepted; a link, device or path escaping the +directory refuses the whole archive. Unpacked to a temp dir and renamed into +place, so an interrupted install never looks complete. `@prompt: name | text|secret|dir | help | default | setup` collects input at install time; everything after the type is optional. Both repeatable. Reference fetched files as `{{cache}}/` and prompt values as diff --git a/presets/planify-german.conf b/presets/planify-german.conf index 809e727..bd96148 100644 --- a/presets/planify-german.conf +++ b/presets/planify-german.conf @@ -2,11 +2,13 @@ // @description: Bundle: the whole planify setup — the plugin providing // `plan_render` (plan JSON to HTML) and `pr_summary` (summary into a Bitbucket // Data Center PR, opened as draft if needed), the German rules file that routes -// every plan through them, and the skill documenting the plan schema. Asks for -// the Bitbucket URL and a repo-write HTTP access token; the token lands in -// opencode.json in plain text. Install all three or the setup is inert. +// every plan through them, the skill documenting the plan schema, and the +// diagram-design skill that draws the plan's diagrams. Asks for the Bitbucket +// URL and a repo-write HTTP access token; the token lands in opencode.json in +// plain text. // @author: Jan -// @version: 1.0.1 +// @version: 1.0.2 // @include: plugin-planify-german // @include: instructions-planify-german // @include: skill-planify-german +// @include: skill-diagram-design diff --git a/presets/skill-diagram-design.conf b/presets/skill-diagram-design.conf index ee4bccf..b6816eb 100644 --- a/presets/skill-diagram-design.conf +++ b/presets/skill-diagram-design.conf @@ -1,15 +1,16 @@ // @name: skill-diagram-design -// @description: Needs a clone of github.com/cathrynlavery/diagram-design on disk -// first — clone it yourself, then answer with the absolute path to its skills/ -// dir; the install refuses if it is not there. Registers the diagram-design -// skill: editorial diagram types rendered as self-contained HTML + SVG. No tags -// upstream, so you track main: update with git pull, and review what you pull. -// Fetches and runs nothing. Uninstall by deleting the skills.paths entry. +// @description: Registers the diagram-design skill: editorial diagram types +// rendered as self-contained HTML + SVG. Downloads the archive of one pinned +// commit of github.com/cathrynlavery/diagram-design, sha256-verified, unpacks +// its skills/ dir into the cache and appends that to `skills.paths` — no clone +// needed. Coming from 0.3.x: delete your old clone's `skills.paths` entry by +// hand, or the skill is registered twice. // @author: Jan -// @version: 0.3.1 +// @version: 1.0.0 // @path: skills.paths // @mode: append -// @prompt: skillsDir | dir | absolute path to your diagram-design clone's skills/ dir | | clone it first: git clone https://github.com/cathrynlavery/diagram-design, then answer with the skills/ dir inside that clone +// @pins: diagram-design 10cfad6a7be7f734a92d3d628e5fa344685d4f01 +// @archive: https://github.com/cathrynlavery/diagram-design/archive/10cfad6a7be7f734a92d3d628e5fa344685d4f01.tar.gz -> {{cache}}/diagram-design-skills-1.0.0 sha256=0282300989b71193ba520ee1ddfb48add72ebc3c84111744b8f3dd0b5ab41c92 subdir=skills [ - "{{prompt:skillsDir}}" + "{{cache}}/diagram-design-skills-1.0.0" ] diff --git a/scripts/check-pins.mjs b/scripts/check-pins.mjs index 831ca53..e36f835 100644 --- a/scripts/check-pins.mjs +++ b/scripts/check-pins.mjs @@ -52,6 +52,11 @@ async function latestFor(source) { if (!latest) throw new Error(`${source.repo}: no version-shaped tags on the first page`); return latest; } + case 'github-branch': { + const body = await getJson( + `https://api.github.com/repos/${source.repo}/commits/${source.branch}`, ghHeaders()); + return body.sha; + } } throw new Error(`unhandled pin source kind: ${source.kind}`); } @@ -96,7 +101,10 @@ for (const pin of pins) { try { const latest = await latestFor(source); - const cmp = compareVersions(pin.version, latest); + // A commit SHA has no order: anything but the branch head is behind. + const cmp = source.kind === 'github-branch' + ? (pin.version === latest ? 0 : -1) + : compareVersions(pin.version, latest); // A pin ahead of upstream is not good news — it means this source is not // authoritative for that artifact, so its "ok" verdicts are worthless too. // Both sources first tried for lombok (since dropped) failed exactly that way. @@ -110,9 +118,11 @@ for (const pin of pins) { } const w = (s, n) => String(s).padEnd(n); +// Commit SHAs shortened so the table keeps its columns. +const v = s => /^[0-9a-f]{40}$/.test(String(s)) ? String(s).slice(0, 10) : s; console.log(`${w('PIN', 34)}${w('PINNED', 12)}${w('LATEST', 12)}${w('STATE', 15)}PRESET`); for (const r of rows) { - console.log(`${w(r.name, 34)}${w(r.version, 12)}${w(r.latest, 12)}${w(r.state, 15)}${r.preset}`); + console.log(`${w(r.name, 34)}${w(v(r.version), 12)}${w(v(r.latest), 12)}${w(r.state, 15)}${r.preset}`); if (r.detail) console.log(` ${r.detail}`); } diff --git a/src/batch.ts b/src/batch.ts index 958abc8..3ece394 100644 --- a/src/batch.ts +++ b/src/batch.ts @@ -6,6 +6,7 @@ import type { ConfMeta, FetchDirective } from './parse-conf.js'; import { applyAtPath, removeAtPath, getAtPath } from './merge.js'; import type { ApplyStats, RemoveStats, MergeMode } from './merge.js'; import { fetchAsset } from './fetch-asset.js'; +import { fetchArchive } from './fetch-archive.js'; import { checkDir, findOnPath, formatSetup } from './preconditions.js'; import { recoverPromptValues } from './recover-prompts.js'; import { backup } from './backup.js'; @@ -219,6 +220,16 @@ export async function runBatch(opts: RunBatchOpts): Promise { process.exit(1); } } + for (const a of m.meta.archive) { + a.dest = expandCacheStr(a.dest, cacheDir); + try { + const r = await fetchArchive(a); + console.log(' ' + c.ok('✓') + ' ' + (r.cached ? 'cached ' : `unpacked ${r.files} files to `) + c.meta(a.dest)); + } catch (e) { + console.error(c.err('error: ') + (e instanceof Error ? e.message : String(e))); + process.exit(1); + } + } } // ── Compute cumulative new root ── @@ -441,11 +452,13 @@ function renderSummary( for (const line of agentOverrideWarnings(modules, existing)) lines.push(line); const totalFetches = modules.reduce((n, m) => n + m.meta.fetch.length, 0); + const totalArchives = modules.reduce((n, m) => n + m.meta.archive.length, 0); const totalPrompts = modules.reduce((n, m) => n + m.meta.prompts.length, 0); const totalPins = modules.reduce((n, m) => n + m.meta.pins.length, 0); - if (totalFetches > 0 || totalPrompts > 0 || totalPins > 0) { + if (totalFetches > 0 || totalArchives > 0 || totalPrompts > 0 || totalPins > 0) { const bits: string[] = []; if (totalFetches > 0) bits.push(`${totalFetches} fetch${totalFetches === 1 ? '' : 'es'}`); + if (totalArchives > 0) bits.push(`${totalArchives} archive${totalArchives === 1 ? '' : 's'}`); if (totalPrompts > 0) bits.push(`${totalPrompts} prompt${totalPrompts === 1 ? '' : 's'}`); if (totalPins > 0) bits.push(`${totalPins} pin${totalPins === 1 ? '' : 's'}`); lines.push(' ' + c.dim(bits.join(', '))); diff --git a/src/fetch-archive.ts b/src/fetch-archive.ts new file mode 100644 index 0000000..f3df2c7 --- /dev/null +++ b/src/fetch-archive.ts @@ -0,0 +1,183 @@ +import { createHash } from 'node:crypto'; +import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises'; +import { dirname, join, resolve, sep } from 'node:path'; +import { gunzipSync } from 'node:zlib'; + +export interface ArchiveSpec { + url: string; + dest: string; + sha256: string; + // Only this subdirectory of the archive lands in dest, its prefix stripped. + subdir?: string | null; +} + +export interface ArchiveResult { + dest: string; + cached: boolean; + files: number; +} + +// Written last into dest, holding the archive's sha256. A dest without it is a +// half-finished extract, not a cache hit. +export const ARCHIVE_MARKER = '.opencode-presets-archive'; + +export async function fetchArchive({ url, dest, sha256, subdir }: ArchiveSpec): Promise { + if (await markerMatches(dest, sha256)) return { dest, cached: true, files: 0 }; + + const res = await fetch(url); + if (!res.ok) { + throw new Error(`fetch ${url} failed: HTTP ${res.status} ${res.statusText}`); + } + const buf = Buffer.from(await res.arrayBuffer()); + const actual = createHash('sha256').update(buf).digest('hex'); + if (actual !== sha256) { + throw new Error(`sha256 mismatch for ${url}: expected ${sha256}, got ${actual}`); + } + + const entries = selectEntries(readTar(gunzipSync(buf)), subdir ?? null, url); + if (!entries.some(e => e.type === 'file')) { + throw new Error(`archive ${url} has no files${subdir ? ` under ${subdir}/` : ''}`); + } + + // Extract beside dest and rename into place, so an interrupted install never + // leaves a dest that looks complete. + const tmp = `${dest}.tmp-${process.pid}`; + await rm(tmp, { recursive: true, force: true }); + await mkdir(tmp, { recursive: true }); + try { + for (const e of entries) { + const target = join(tmp, ...e.path.split('/')); + if (e.type === 'dir') { + await mkdir(target, { recursive: true }); + } else { + await mkdir(dirname(target), { recursive: true }); + await writeFile(target, e.data, { mode: e.mode & 0o111 ? 0o755 : 0o644 }); + } + } + await writeFile(join(tmp, ARCHIVE_MARKER), sha256 + '\n'); + await rm(dest, { recursive: true, force: true }); + await rename(tmp, dest); + } catch (e) { + await rm(tmp, { recursive: true, force: true }); + throw e; + } + return { dest, cached: false, files: entries.filter(e => e.type === 'file').length }; +} + +async function markerMatches(dest: string, sha256: string): Promise { + try { + return (await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim() === sha256; + } catch { + return false; + } +} + +export interface TarEntry { + path: string; + type: 'file' | 'dir'; + mode: number; + data: Buffer; +} + +// Minimal tar reader: ustar plus the pax and GNU long-name records GitHub and +// git archive emit. Only regular files and directories are accepted — a link +// or device entry is how an archive writes outside its own tree, so one of +// those refuses the whole archive rather than being skipped in silence. +export function readTar(tar: Buffer): TarEntry[] { + const entries: TarEntry[] = []; + let offset = 0; + let longName: string | null = null; + + while (offset + 512 <= tar.length) { + const header = tar.subarray(offset, offset + 512); + if (header.every(b => b === 0)) break; + + const size = parseOctal(header.subarray(124, 136)); + const typeflag = String.fromCharCode(header[156] || 0x30); + const dataStart = offset + 512; + const data = tar.subarray(dataStart, dataStart + size); + if (data.length !== size) throw new Error('tar archive is truncated'); + offset = dataStart + Math.ceil(size / 512) * 512; + + if (typeflag === 'g') continue; + if (typeflag === 'x') { + longName = paxPath(data) ?? longName; + continue; + } + if (typeflag === 'L') { + longName = cString(data); + continue; + } + + const name = longName ?? ustarName(header); + longName = null; + + if (typeflag === '5') { + entries.push({ path: name, type: 'dir', mode: 0o755, data: Buffer.alloc(0) }); + } else if (typeflag === '0' || typeflag === '\0' || typeflag === '7') { + entries.push({ path: name, type: 'file', mode: parseOctal(header.subarray(100, 108)), data: Buffer.from(data) }); + } else { + throw new Error(`tar entry "${name}" has type "${typeflag}": only files and directories are accepted`); + } + } + return entries; +} + +// Strip the archive's single top-level directory (`-/`), keep only +// `subdir` if given, and refuse any path that could land outside dest. +export function selectEntries(entries: TarEntry[], subdir: string | null, url: string): TarEntry[] { + const selected: TarEntry[] = []; + const prefix = subdir ? subdir.replace(/^\/+|\/+$/g, '') + '/' : ''; + + for (const e of entries) { + const parts = e.path.split('/').filter(p => p.length > 0); + if (e.path.startsWith('/') || parts.some(p => p === '..' || p === '.' || p.includes('\\'))) { + throw new Error(`archive ${url} has unsafe path "${e.path}"`); + } + const rel = parts.slice(1).join('/'); + if (!rel) continue; + if (prefix && !(rel + '/').startsWith(prefix)) continue; + const path = rel.slice(prefix.length); + if (!path) continue; + // Belt and braces: the joined path must still sit under a fake root. + const root = resolve(sep, 'root'); + if (!resolve(root, ...path.split('/')).startsWith(root + sep)) { + throw new Error(`archive ${url} has unsafe path "${e.path}"`); + } + selected.push({ ...e, path }); + } + return selected; +} + +function ustarName(header: Buffer): string { + const name = cString(header.subarray(0, 100)); + const magic = header.subarray(257, 262).toString('latin1'); + const prefix = magic === 'ustar' ? cString(header.subarray(345, 500)) : ''; + return prefix ? `${prefix}/${name}` : name; +} + +function paxPath(data: Buffer): string | null { + let path: string | null = null; + let i = 0; + while (i < data.length) { + const space = data.indexOf(0x20, i); + if (space === -1) break; + const len = parseInt(data.subarray(i, space).toString('latin1'), 10); + if (!Number.isFinite(len) || len <= 0) break; + const record = data.subarray(space + 1, i + len - 1).toString('utf8'); + const eq = record.indexOf('='); + if (eq !== -1 && record.slice(0, eq) === 'path') path = record.slice(eq + 1); + i += len; + } + return path; +} + +function cString(buf: Buffer): string { + const nul = buf.indexOf(0); + return (nul === -1 ? buf : buf.subarray(0, nul)).toString('utf8'); +} + +function parseOctal(buf: Buffer): number { + const s = cString(buf).trim(); + return s ? parseInt(s, 8) : 0; +} diff --git a/src/parse-conf.ts b/src/parse-conf.ts index 424e68e..9af0cbc 100644 --- a/src/parse-conf.ts +++ b/src/parse-conf.ts @@ -7,6 +7,16 @@ export interface FetchDirective { sha256: string | null; } +// A .tar.gz unpacked into a cache directory. sha256 is mandatory: unlike a +// single @fetch file there is no way to eyeball hundreds of files. +export interface ArchiveDirective { + url: string; + dest: string; + sha256: string; + // Only this subdirectory of the archive is kept. + subdir: string | null; +} + export type PromptType = 'text' | 'secret' | 'dir'; export interface PromptDirective { @@ -43,6 +53,7 @@ export interface ConfMeta { path: string; mode: MergeMode; fetch: FetchDirective[]; + archive: ArchiveDirective[]; prompts: PromptDirective[]; pins: PinDirective[]; // Executables this preset needs on PATH to be worth installing. Checked at @@ -82,6 +93,7 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf path: '', mode: 'replace', fetch: [], + archive: [], prompts: [], pins: [], requiresBin: [], @@ -128,6 +140,9 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf case 'fetch': meta.fetch.push(parseFetch(value, filePath)); break; + case 'archive': + meta.archive.push(parseArchive(value, filePath, i + 1)); + break; case 'prompt': meta.prompts.push(parsePrompt(value, filePath)); break; @@ -181,7 +196,8 @@ export function parseConfString(raw: string, filePath = ''): ParsedConf // A bundle never reaches an applier, so these would be silently dropped. // Rejecting them beats letting an author think they took effect. for (const [key, present] of [ - ['fetch', meta.fetch.length], ['prompt', meta.prompts.length], ['pins', meta.pins.length], + ['fetch', meta.fetch.length], ['archive', meta.archive.length], + ['prompt', meta.prompts.length], ['pins', meta.pins.length], ['requires-bin', meta.requiresBin.length], ] as const) { if (present) { @@ -319,6 +335,32 @@ function parseFetch(value: string, filePath: string): FetchDirective { return { url, dest, sha256 }; } +function parseArchive(value: string, filePath: string, line: number): ArchiveDirective { + const usage = `@archive must be "URL -> dest sha256=hex [subdir=path]", got "${value}"`; + const arrowIdx = value.indexOf('->'); + if (arrowIdx === -1) throw parseError(filePath, line, usage); + const url = value.slice(0, arrowIdx).trim(); + const [dest, ...opts] = value.slice(arrowIdx + 2).trim().split(/\s+/); + + let sha256 = ''; + let subdir: string | null = null; + for (const opt of opts) { + const m = opt.match(/^(sha256|subdir)=(.+)$/); + if (!m) throw parseError(filePath, line, usage); + if (m[1] === 'sha256') sha256 = m[2].toLowerCase(); + else subdir = m[2]; + } + + if (!url || !dest) throw parseError(filePath, line, usage); + if (!/^[0-9a-f]{64}$/.test(sha256)) { + throw parseError(filePath, line, `@archive needs sha256=<64 hex chars>: nobody reviews hundreds of unpinned files, got "${value}"`); + } + if (subdir !== null && (subdir.startsWith('/') || subdir.split('/').some(p => p === '..' || p === '.'))) { + throw parseError(filePath, line, `@archive subdir must be a relative path inside the archive, got "${subdir}"`); + } + return { url, dest, sha256, subdir }; +} + function stripJsonComments(text: string): string { let out = ''; let i = 0; diff --git a/src/pin-sources.ts b/src/pin-sources.ts index cbd37c0..c4f2e65 100644 --- a/src/pin-sources.ts +++ b/src/pin-sources.ts @@ -9,13 +9,16 @@ export type PinSource = | { kind: 'npm'; pkg: string } | { kind: 'github-release'; repo: string } - | { kind: 'github-tags'; repo: string }; + | { kind: 'github-tags'; repo: string } + // Repo without tags or releases: the pin is a commit SHA, current = branch head. + | { kind: 'github-branch'; repo: string; branch: string }; export const PIN_SOURCES: Record = { '@playwright/mcp': { kind: 'npm', pkg: '@playwright/mcp' }, 'opencode-plugin-litellm-pricing': { kind: 'npm', pkg: 'opencode-plugin-litellm-pricing' }, 'opencode-planify-german': { kind: 'npm', pkg: 'opencode-planify-german' }, 'superpowers': { kind: 'github-release', repo: 'obra/superpowers' }, + 'diagram-design': { kind: 'github-branch', repo: 'cathrynlavery/diagram-design', branch: 'main' }, }; export class UnknownPinError extends Error {} diff --git a/test/builtin-presets.test.ts b/test/builtin-presets.test.ts index 7983b35..c7c4d59 100644 --- a/test/builtin-presets.test.ts +++ b/test/builtin-presets.test.ts @@ -86,26 +86,30 @@ test('every precondition that can refuse an install carries a setup hint', async } }); -test('the diagram-design skill preset takes a validated dir, and writes exactly it', async () => { +test('the diagram-design skill preset unpacks a pinned commit and writes exactly its dir', async () => { const preset = resolve(process.cwd(), 'presets/skill-diagram-design.conf'); const { meta, body } = await parseConf(preset); assert.equal(meta.path, 'skills.paths'); assert.equal(meta.mode, 'append'); + // No clone to ask for: the clone was the step nobody took, so the skill was + // never there and planify dropped every diagram. + assert.deepEqual(meta.prompts, []); + + assert.equal(meta.archive.length, 1); + const [a] = meta.archive; + const sha = meta.pins.find(p => p.name === 'diagram-design')?.version; + assert.ok(sha && a.url.includes(`/archive/${sha}.tar.gz`), 'archive URL is the pinned commit'); + assert.equal(a.subdir, 'skills'); + // Versioned with the preset's X.Y.Z so a bump supersedes the old entry (cacheFamily). + assert.equal(a.dest, `{{cache}}/diagram-design-skills-${meta.version}`); + assert.deepEqual(body, [a.dest]); +}); - // Typed `dir`, so install resolves and stats it and refuses a path that is - // not there — the failure this replaces was a dead skills.paths entry that - // opencode ignores in silence. - assert.deepEqual( - meta.prompts.map((p) => ({ name: p.name, type: p.type })), - [{ name: 'skillsDir', type: 'dir' }], - ); - - // The prompted path is written verbatim, with no suffix bolted on. A body of - // `{{prompt:x}}/skills` would validate the clone root while writing a path - // nothing checked — the exact gap the `dir` type exists to close. - assert.deepEqual(body, ['{{prompt:skillsDir}}']); +test('the planify bundle installs the diagram-design skill it draws with', async () => { + const { meta } = await parseConf(resolve(process.cwd(), 'presets/planify-german.conf')); + assert.ok(meta.includes.includes('skill-diagram-design')); }); test('ships a litellm provider preset that points at a proxy URL, no models', async () => { @@ -201,6 +205,7 @@ test('records the pinned third-party version of every preset that installs one', 'plugin-litellm-pricing': [{ name: 'opencode-plugin-litellm-pricing', version: '1.0.0' }], 'plugin-planify-german': [{ name: 'opencode-planify-german', version: '1.0.1' }], 'plugin-superpowers': [{ name: 'superpowers', version: '7.0.0' }], + 'skill-diagram-design': [{ name: 'diagram-design', version: '10cfad6a7be7f734a92d3d628e5fa344685d4f01' }], }; for (const [name, pins] of Object.entries(expected)) { @@ -213,13 +218,14 @@ test('every @pins version actually appears in the body or fetch it describes', a for (const file of await shippedPresets()) { const { meta, body } = await parseConf(file); const haystack = JSON.stringify(body) + - meta.fetch.map((f) => f.url + f.dest).join(''); + meta.fetch.map((f) => f.url + f.dest).join('') + + meta.archive.map((a) => a.url + a.dest).join(''); for (const pin of meta.pins) { assert.ok( haystack.includes(pin.version), `${meta.name}: @pins says ${pin.name} ${pin.version}, but that version ` + - 'appears nowhere in the body or @fetch lines — one side was bumped without the other', + 'appears nowhere in the body, @fetch or @archive lines — one side was bumped without the other', ); } } @@ -245,6 +251,12 @@ test('every @fetch destination is actually referenced by the preset it feeds', a 'without the reference that uses it', ); } + for (const a of meta.archive) { + assert.ok( + haystack.includes(a.dest), + `${meta.name}: @archive unpacks to ${a.dest}, but nothing in the body or @path points at it`, + ); + } } }); diff --git a/test/deny-warnings.test.ts b/test/deny-warnings.test.ts index d284a2b..3824a42 100644 --- a/test/deny-warnings.test.ts +++ b/test/deny-warnings.test.ts @@ -9,7 +9,7 @@ function meta(path: string): ConfMeta { return { name: 'm', description: '', author: '', version: '0.0.0', path, mode: 'merge', - fetch: [], prompts: [], pins: [], requiresBin: [], includes: [], partOf: '', + fetch: [], archive: [], prompts: [], pins: [], requiresBin: [], includes: [], partOf: '', }; } diff --git a/test/distribute-set-values.test.ts b/test/distribute-set-values.test.ts index 82d5d01..27e9cf1 100644 --- a/test/distribute-set-values.test.ts +++ b/test/distribute-set-values.test.ts @@ -14,6 +14,7 @@ function mod(name: string, prompts: string[]): BatchModule { path: 'x', mode: 'replace', fetch: [], + archive: [], prompts: prompts.map(p => ({ name: p, type: 'text' as const, help: '' })), pins: [], requiresBin: [], diff --git a/test/fetch-archive.test.ts b/test/fetch-archive.test.ts new file mode 100644 index 0000000..ddabcc8 --- /dev/null +++ b/test/fetch-archive.test.ts @@ -0,0 +1,118 @@ +import { test, describe, afterEach } from 'node:test'; +import assert from 'node:assert/strict'; +import { createHash } from 'node:crypto'; +import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { gzipSync } from 'node:zlib'; +import { ARCHIVE_MARKER, fetchArchive } from '../src/fetch-archive.js'; + +type Entry = { name: string; type?: string; body?: string; mode?: number }; + +// Just enough of ustar to build fixtures: name, mode, size, typeflag, magic, checksum. +function tarOf(entries: Entry[]): Buffer { + const blocks: Buffer[] = []; + for (const e of entries) { + const body = Buffer.from(e.body ?? ''); + const h = Buffer.alloc(512); + h.write(e.name, 0, 100, 'utf8'); + h.write((e.mode ?? 0o644).toString(8).padStart(7, '0') + '\0', 100, 'latin1'); + h.write(body.length.toString(8).padStart(11, '0') + '\0', 124, 'latin1'); + h.write(e.type ?? '0', 156, 'latin1'); + h.write('ustar\0' + '00', 257, 'latin1'); + h.fill(0x20, 148, 156); + const sum = h.reduce((n, b) => n + b, 0); + h.write(sum.toString(8).padStart(6, '0') + '\0 ', 148, 'latin1'); + blocks.push(h, body, Buffer.alloc((512 - (body.length % 512)) % 512)); + } + blocks.push(Buffer.alloc(1024)); + return gzipSync(Buffer.concat(blocks)); +} + +const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex'); + +const realFetch = globalThis.fetch; +let requests = 0; +function serve(buf: Buffer) { + requests = 0; + globalThis.fetch = (async () => { + requests++; + return new Response(new Uint8Array(buf)); + }) as typeof fetch; +} +afterEach(() => { globalThis.fetch = realFetch; }); + +const fixture = () => tarOf([ + { name: 'repo-abc/', type: '5' }, + { name: 'repo-abc/README.md', body: 'top' }, + { name: 'repo-abc/skills/', type: '5' }, + { name: 'repo-abc/skills/tool/SKILL.md', body: 'skill' }, + { name: 'repo-abc/skills/tool/scripts/run.py', body: 'print(1)', mode: 0o755 }, +]); + +describe('fetchArchive', () => { + test('unpacks only subdir, strips the top-level dir, writes the marker', async () => { + const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); + const tgz = fixture(); + serve(tgz); + + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: 'skills' }); + + assert.equal(r.cached, false); + assert.equal(r.files, 2); + assert.equal(await readFile(join(dest, 'tool/SKILL.md'), 'utf8'), 'skill'); + assert.ok((await stat(join(dest, 'tool/scripts/run.py'))).mode & 0o100, 'executable bit kept'); + assert.deepEqual((await readdir(dest)).sort(), [ARCHIVE_MARKER, 'tool']); + assert.equal((await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim(), sha(tgz)); + }); + + test('second run with the same sha256 is a cache hit, no download', async () => { + const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); + const tgz = fixture(); + serve(tgz); + await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: null }); + + serve(tgz); + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: null }); + assert.equal(r.cached, true); + assert.equal(requests, 0); + }); + + test('sha256 mismatch refuses and leaves no dest behind', async () => { + const dir = await mkdtemp(join(tmpdir(), 'archive-')); + serve(fixture()); + await assert.rejects( + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: 'f'.repeat(64), subdir: null }), + /sha256 mismatch/, + ); + assert.deepEqual(await readdir(dir), []); + }); + + for (const [label, entry, re] of [ + ['a path climbing out with ..', { name: 'repo-abc/../../evil', body: 'x' }, /unsafe path/], + ['an absolute path', { name: '/etc/evil', body: 'x' }, /unsafe path/], + ['a symlink', { name: 'repo-abc/link', type: '2' }, /only files and directories/], + ['a hardlink', { name: 'repo-abc/hard', type: '1' }, /only files and directories/], + ] as const) { + test(`refuses an archive with ${label}`, async () => { + const dir = await mkdtemp(join(tmpdir(), 'archive-')); + const tgz = tarOf([{ name: 'repo-abc/ok', body: 'ok' }, entry]); + serve(tgz); + await assert.rejects( + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: sha(tgz), subdir: null }), + re, + ); + assert.deepEqual(await readdir(dir), []); + }); + } + + test('a subdir with no files is an error, not an empty skill dir', async () => { + const dir = await mkdtemp(join(tmpdir(), 'archive-')); + const tgz = fixture(); + serve(tgz); + await assert.rejects( + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: sha(tgz), subdir: 'nope' }), + /no files under nope\//, + ); + }); +}); diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts index 2e44555..b62b755 100644 --- a/test/parse-conf.test.ts +++ b/test/parse-conf.test.ts @@ -99,6 +99,7 @@ describe('parseConfString — @include', () => { test('rejects a bundle that sets @fetch, @prompt, @pins or @requires-bin', () => { const cases: [string, RegExp][] = [ ['// @fetch: https://x/y.jar -> {{cache}}/y.jar\n', /must not set @fetch/], + [`// @archive: https://x/y.tar.gz -> {{cache}}/y sha256=${'a'.repeat(64)}\n`, /must not set @archive/], ['// @prompt: url | text | where\n', /must not set @prompt/], ['// @pins: lombok 1.18.46\n', /must not set @pins/], ['// @requires-bin: shellcheck\n', /must not set @requires-bin/], @@ -227,6 +228,38 @@ describe('parseConfString — @fetch', () => { }); }); +describe('parseConfString — @archive', () => { + const sha = 'AB'.repeat(32); + + test('parses url, dest, sha256 and subdir', () => { + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} subdir=skills\n\n{}`; + const { meta } = parseConfString(src); + assert.deepEqual(meta.archive, [ + { url: 'https://example.com/a.tar.gz', dest: '/tmp/a', sha256: sha.toLowerCase(), subdir: 'skills' }, + ]); + }); + + test('subdir is optional', () => { + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha}\n\n{}`; + assert.equal(parseConfString(src).meta.archive[0].subdir, null); + }); + + test('rejects an archive without sha256', () => { + const src = minimalHeader + '// @archive: https://example.com/a.tar.gz -> /tmp/a\n\n{}'; + assert.throws(() => parseConfString(src), /@archive needs sha256/); + }); + + test('rejects a subdir that climbs out of the archive', () => { + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} subdir=../x\n\n{}`; + assert.throws(() => parseConfString(src), /subdir must be a relative path/); + }); + + test('rejects an unknown option', () => { + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} strip=2\n\n{}`; + assert.throws(() => parseConfString(src), /@archive must be/); + }); +}); + describe('parseConfString — @prompt', () => { test('parses name | type | help', () => { const src = minimalHeader + '// @prompt: token | secret | bearer auth\n\n{}'; From 93d863d4a9f680f94accba02736db44ae66b0107 Mon Sep 17 00:00:00 2001 From: trick77 <1525566+trick77@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:27:12 +0100 Subject: [PATCH 2/3] Review: tar-sha256, path-scoped branch pins, archive edge cases Hash the tar after gunzip instead of the download: survives a GitHub recompression and is reproducible with git archive from any clone. Branch pins only go BEHIND when a commit touching the installed path is missing, not on every upstream release chore. Normalize a trailing slash on dest, key the cache marker on hash + subdir, read the ustar prefix only for POSIX headers, keep tar data as views. Bundle description says remove takes diagram-design along; migration note moved from description to README. --- AGENTS.md | 17 ++++---- README.md | 16 +++---- presets/planify-german.conf | 9 ++-- presets/skill-diagram-design.conf | 9 ++-- scripts/check-pins.mjs | 18 ++++++-- src/fetch-archive.ts | 40 +++++++++++------- src/parse-conf.ts | 21 +++++----- src/pin-sources.ts | 8 ++-- test/fetch-archive.test.ts | 69 +++++++++++++++++++++++++------ test/parse-conf.test.ts | 16 +++---- 10 files changed, 147 insertions(+), 76 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index f49e4a7..6c170ff 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -45,12 +45,15 @@ Order irrelevant. reads `tui.json`. - `@mode` — `replace` (default) | `merge` | `merge-overwrite` | `append`. - `@fetch: URL -> dest [sha256=hex]` — repeatable. -- `@archive: URL -> destDir sha256=hex [subdir=path]` — repeatable. `.tar.gz` - unpacked into a cache dir (`src/fetch-archive.ts`): top-level dir stripped, - only `subdir` kept, sha256 mandatory. Links, devices, `..`, absolute paths → - whole archive refused. Cache hit = marker file in destDir with that sha256. - For skill repos without tags (`skill-diagram-design`): pin the commit SHA in - `@pins` (source kind `github-branch`). +- `@archive: URL -> destDir tar-sha256=hex [subdir=path]` — repeatable. + `.tar.gz` unpacked into a cache dir (`src/fetch-archive.ts`): top-level dir + stripped, only `subdir` kept. Hash = sha256 of the tar after gunzip, not the + download: GitHub changed only gzip so far (Jan 2023), and + `git archive --format=tar --prefix=-/ ` reproduces the tar + from any clone — that is the second source. Links, devices, `..`, absolute + paths → whole archive refused. Cache hit = marker in destDir with hash + + subdir. Tagless repo (`skill-diagram-design`): commit SHA in `@pins`, source + kind `github-branch` with `path` = what gets installed (head moves hourly). - `@pins: name version` — repeatable, one per pinned third-party artifact. Version must also appear in body or `@fetch` (tested). - `@prompt: name | type | help | default | setup` — repeatable; type @@ -219,7 +222,7 @@ wrong, not good news — lombok (since dropped) needed git tags because Maven Central's index and projectlombok.org's download page each reported a stale "latest". -`@fetch`/`@archive` → always `sha256=`, and version the dest filename; `fetchAsset` skips +`@fetch` → always `sha256=` (`@archive`: `tar-sha256=`), and version the dest filename; `fetchAsset` skips an existing dest, so reusing the filename makes a bump a no-op. Verify the hash against a second source before committing. diff --git a/README.md b/README.md index 35eac3b..7c161ac 100644 --- a/README.md +++ b/README.md @@ -80,7 +80,7 @@ something outside your opencode config say so in their description. | `agent-runaway-guard` | Agent | merge | Adds step limits to built-in agents to prevent runaway tool loops | | `default-agent-plan` | Agent | replace | Sets the default agent to "plan" so opencode always starts in plan mode instead of build mode | | `planify-german` | Bundle | — | The whole planify setup, which does nothing in parts: the `opencode-planify-german` plugin from npm (pinned to `1.0.1`, needs opencode 2), registering a `plan_render` tool that validates a plan JSON against a schema and renders a self-contained HTML file to `docs/plans/-.html`, and a `pr_summary` tool that writes a short task/problem/solution summary into the description of the Bitbucket Data Center PR of the current branch, opening it as a draft if none is open; the rules file that makes opencode answer in German with Swiss orthography (never the eszett character, always `ss`) and send every plan through `plan_render`; the `planify` skill documenting the plan schema; and `skill-diagram-design`, which draws the plan's diagrams. Prompts for the Bitbucket URL and an HTTP access token with repository write, stored as plugin options in `opencode.json` (token in plain text; prefer `--set-env bitbucketToken=ENV_VAR`). Rules file and skills are fetched sha256-verified. Run `opencode service restart` after installing | -| `skill-diagram-design` | Skill | append | Registers the `diagram-design` skill (editorial diagram types as self-contained HTML + SVG). Downloads the archive of one pinned commit of [cathrynlavery/diagram-design](https://github.com/cathrynlavery/diagram-design), sha256-verified, unpacks its `skills/` dir to `/diagram-design-skills-` and appends that to `skills.paths`. No clone. The repo ships no tags, so a new upstream commit means a new preset version. Coming from 0.3.x (clone + prompt): delete the old clone's `skills.paths` entry by hand, or the skill is registered twice | +| `skill-diagram-design` | Skill | append | Registers the `diagram-design` skill (editorial diagram types as self-contained HTML + SVG). Downloads the archive of one pinned commit of [cathrynlavery/diagram-design](https://github.com/cathrynlavery/diagram-design), verifies its tar sha256, unpacks its `skills/` dir to `/diagram-design-skills-` and appends that to `skills.paths`. No clone. The repo ships no tags, so a new upstream commit means a new preset version. Part of `planify-german`, so `remove planify-german` removes it too. Coming from 0.3.x (clone + prompt): drop `--set skillsDir` from scripts, and delete the old clone's `skills.paths` entry by hand, or the skill is registered twice | ### Bundles @@ -363,12 +363,14 @@ Presets write `opencode.json` only. `@target: tui` is rejected: opencode 2 does not read `tui.json`. `@fetch: -> [sha256=hex]` downloads to the cache. -`@archive: -> sha256=hex [subdir=path]` downloads a `.tar.gz`, -checks its sha256 and unpacks it into the cache: the archive's top-level -directory is stripped, and with `subdir` only that part is kept. Only regular -files and directories are accepted; a link, device or path escaping the -directory refuses the whole archive. Unpacked to a temp dir and renamed into -place, so an interrupted install never looks complete. +`@archive: -> tar-sha256=hex [subdir=path]` downloads a +`.tar.gz` and unpacks it into the cache: the archive's top-level directory is +stripped, and with `subdir` only that part is kept. The hash is the sha256 of +the tar after gunzip, so a GitHub archive can be checked against +`git archive --format=tar --prefix=-/ ` from any clone. Only +regular files and directories are accepted; a link, device or path escaping +the directory refuses the whole archive. Unpacked to a temp dir and renamed +into place, so an interrupted install never looks complete. `@prompt: name | text|secret|dir | help | default | setup` collects input at install time; everything after the type is optional. Both repeatable. Reference fetched files as `{{cache}}/` and prompt values as diff --git a/presets/planify-german.conf b/presets/planify-german.conf index bd96148..c62c46e 100644 --- a/presets/planify-german.conf +++ b/presets/planify-german.conf @@ -1,11 +1,10 @@ // @name: planify-german // @description: Bundle: the whole planify setup — the plugin providing // `plan_render` (plan JSON to HTML) and `pr_summary` (summary into a Bitbucket -// Data Center PR, opened as draft if needed), the German rules file that routes -// every plan through them, the skill documenting the plan schema, and the -// diagram-design skill that draws the plan's diagrams. Asks for the Bitbucket -// URL and a repo-write HTTP access token; the token lands in opencode.json in -// plain text. +// Data Center PR), the German rules file routing every plan through them, the +// plan schema skill, and the diagram-design skill (removed with the bundle). +// Asks for the Bitbucket URL and a repo-write HTTP access token; the token +// lands in opencode.json in plain text. // @author: Jan // @version: 1.0.2 // @include: plugin-planify-german diff --git a/presets/skill-diagram-design.conf b/presets/skill-diagram-design.conf index b6816eb..2e7424f 100644 --- a/presets/skill-diagram-design.conf +++ b/presets/skill-diagram-design.conf @@ -1,16 +1,15 @@ // @name: skill-diagram-design // @description: Registers the diagram-design skill: editorial diagram types // rendered as self-contained HTML + SVG. Downloads the archive of one pinned -// commit of github.com/cathrynlavery/diagram-design, sha256-verified, unpacks -// its skills/ dir into the cache and appends that to `skills.paths` — no clone -// needed. Coming from 0.3.x: delete your old clone's `skills.paths` entry by -// hand, or the skill is registered twice. +// commit of github.com/cathrynlavery/diagram-design, verifies its tar sha256, +// unpacks its skills/ dir into the cache and appends that to `skills.paths` — +// no clone needed. // @author: Jan // @version: 1.0.0 // @path: skills.paths // @mode: append // @pins: diagram-design 10cfad6a7be7f734a92d3d628e5fa344685d4f01 -// @archive: https://github.com/cathrynlavery/diagram-design/archive/10cfad6a7be7f734a92d3d628e5fa344685d4f01.tar.gz -> {{cache}}/diagram-design-skills-1.0.0 sha256=0282300989b71193ba520ee1ddfb48add72ebc3c84111744b8f3dd0b5ab41c92 subdir=skills +// @archive: https://github.com/cathrynlavery/diagram-design/archive/10cfad6a7be7f734a92d3d628e5fa344685d4f01.tar.gz -> {{cache}}/diagram-design-skills-1.0.0 tar-sha256=642c222b4135497d52bc67db854e371f46edcc6ec58edddb6c42aae3e0a17883 subdir=skills [ "{{cache}}/diagram-design-skills-1.0.0" ] diff --git a/scripts/check-pins.mjs b/scripts/check-pins.mjs index e36f835..3180df2 100644 --- a/scripts/check-pins.mjs +++ b/scripts/check-pins.mjs @@ -53,14 +53,25 @@ async function latestFor(source) { return latest; } case 'github-branch': { + // Newest commit on the branch that touches the installed path. const body = await getJson( - `https://api.github.com/repos/${source.repo}/commits/${source.branch}`, ghHeaders()); - return body.sha; + `https://api.github.com/repos/${source.repo}/commits?sha=${source.branch}&path=${source.path}&per_page=1`, + ghHeaders()); + if (!body.length) throw new Error(`${source.repo}: no commits touch ${source.path} on ${source.branch}`); + return body[0].sha; } } throw new Error(`unhandled pin source kind: ${source.kind}`); } +// A pinned commit is current when it already contains the newest commit that +// touched the path: compare reports the pin identical to it or ahead of it. +async function branchCmp(source, pinned, latest) { + const body = await getJson( + `https://api.github.com/repos/${source.repo}/compare/${latest}...${pinned}`, ghHeaders()); + return body.status === 'identical' || body.status === 'ahead' ? 0 : -1; +} + async function shippedPins() { const files = (await readdir(PRESET_DIR)).filter(n => n.endsWith('.conf')).sort(); const pins = []; @@ -101,9 +112,8 @@ for (const pin of pins) { try { const latest = await latestFor(source); - // A commit SHA has no order: anything but the branch head is behind. const cmp = source.kind === 'github-branch' - ? (pin.version === latest ? 0 : -1) + ? await branchCmp(source, pin.version, latest) : compareVersions(pin.version, latest); // A pin ahead of upstream is not good news — it means this source is not // authoritative for that artifact, so its "ok" verdicts are worthless too. diff --git a/src/fetch-archive.ts b/src/fetch-archive.ts index f3df2c7..04abc6e 100644 --- a/src/fetch-archive.ts +++ b/src/fetch-archive.ts @@ -6,7 +6,11 @@ import { gunzipSync } from 'node:zlib'; export interface ArchiveSpec { url: string; dest: string; - sha256: string; + // sha256 of the tar after gunzip, not of the download. GitHub builds archives + // with `git archive` and has changed only the gzip layer so far (Jan 2023); + // the tar is what `git archive --format=tar --prefix=-/ ` + // reproduces from any clone. + tarSha256: string; // Only this subdirectory of the archive lands in dest, its prefix stripped. subdir?: string | null; } @@ -17,24 +21,27 @@ export interface ArchiveResult { files: number; } -// Written last into dest, holding the archive's sha256. A dest without it is a +// Written last into dest, holding tar sha256 and subdir. A dest without it is a // half-finished extract, not a cache hit. export const ARCHIVE_MARKER = '.opencode-presets-archive'; -export async function fetchArchive({ url, dest, sha256, subdir }: ArchiveSpec): Promise { - if (await markerMatches(dest, sha256)) return { dest, cached: true, files: 0 }; +export async function fetchArchive({ url, dest: rawDest, tarSha256, subdir }: ArchiveSpec): Promise { + // resolve() drops a trailing slash, which would otherwise put tmp inside dest. + const dest = resolve(rawDest); + const marker = `${tarSha256} ${subdir ?? ''}`.trim(); + if (await markerMatches(dest, marker)) return { dest, cached: true, files: 0 }; const res = await fetch(url); if (!res.ok) { throw new Error(`fetch ${url} failed: HTTP ${res.status} ${res.statusText}`); } - const buf = Buffer.from(await res.arrayBuffer()); - const actual = createHash('sha256').update(buf).digest('hex'); - if (actual !== sha256) { - throw new Error(`sha256 mismatch for ${url}: expected ${sha256}, got ${actual}`); + const tar = gunzipSync(Buffer.from(await res.arrayBuffer())); + const actual = createHash('sha256').update(tar).digest('hex'); + if (actual !== tarSha256) { + throw new Error(`tar-sha256 mismatch for ${url}: expected ${tarSha256}, got ${actual}`); } - const entries = selectEntries(readTar(gunzipSync(buf)), subdir ?? null, url); + const entries = selectEntries(readTar(tar), subdir ?? null, url); if (!entries.some(e => e.type === 'file')) { throw new Error(`archive ${url} has no files${subdir ? ` under ${subdir}/` : ''}`); } @@ -54,7 +61,7 @@ export async function fetchArchive({ url, dest, sha256, subdir }: ArchiveSpec): await writeFile(target, e.data, { mode: e.mode & 0o111 ? 0o755 : 0o644 }); } } - await writeFile(join(tmp, ARCHIVE_MARKER), sha256 + '\n'); + await writeFile(join(tmp, ARCHIVE_MARKER), marker + '\n'); await rm(dest, { recursive: true, force: true }); await rename(tmp, dest); } catch (e) { @@ -64,9 +71,9 @@ export async function fetchArchive({ url, dest, sha256, subdir }: ArchiveSpec): return { dest, cached: false, files: entries.filter(e => e.type === 'file').length }; } -async function markerMatches(dest: string, sha256: string): Promise { +async function markerMatches(dest: string, marker: string): Promise { try { - return (await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim() === sha256; + return (await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim() === marker; } catch { return false; } @@ -115,7 +122,8 @@ export function readTar(tar: Buffer): TarEntry[] { if (typeflag === '5') { entries.push({ path: name, type: 'dir', mode: 0o755, data: Buffer.alloc(0) }); } else if (typeflag === '0' || typeflag === '\0' || typeflag === '7') { - entries.push({ path: name, type: 'file', mode: parseOctal(header.subarray(100, 108)), data: Buffer.from(data) }); + // A view into the tar, no copy: most entries are filtered out by subdir. + entries.push({ path: name, type: 'file', mode: parseOctal(header.subarray(100, 108)), data }); } else { throw new Error(`tar entry "${name}" has type "${typeflag}": only files and directories are accepted`); } @@ -151,8 +159,10 @@ export function selectEntries(entries: TarEntry[], subdir: string | null, url: s function ustarName(header: Buffer): string { const name = cString(header.subarray(0, 100)); - const magic = header.subarray(257, 262).toString('latin1'); - const prefix = magic === 'ustar' ? cString(header.subarray(345, 500)) : ''; + // POSIX ustar only ("ustar\0" + "00"). Old GNU ("ustar \0") keeps atime, + // ctime and sparse data in these bytes, not a path prefix. + const posix = header.subarray(257, 265).toString('latin1') === 'ustar\u000000'; + const prefix = posix ? cString(header.subarray(345, 500)) : ''; return prefix ? `${prefix}/${name}` : name; } diff --git a/src/parse-conf.ts b/src/parse-conf.ts index 9af0cbc..f0dd707 100644 --- a/src/parse-conf.ts +++ b/src/parse-conf.ts @@ -7,12 +7,13 @@ export interface FetchDirective { sha256: string | null; } -// A .tar.gz unpacked into a cache directory. sha256 is mandatory: unlike a -// single @fetch file there is no way to eyeball hundreds of files. +// A .tar.gz unpacked into a cache directory. The hash is mandatory: unlike a +// single @fetch file there is no way to eyeball hundreds of files. It covers +// the tar after gunzip, see fetch-archive.ts. export interface ArchiveDirective { url: string; dest: string; - sha256: string; + tarSha256: string; // Only this subdirectory of the archive is kept. subdir: string | null; } @@ -336,29 +337,29 @@ function parseFetch(value: string, filePath: string): FetchDirective { } function parseArchive(value: string, filePath: string, line: number): ArchiveDirective { - const usage = `@archive must be "URL -> dest sha256=hex [subdir=path]", got "${value}"`; + const usage = `@archive must be "URL -> dest tar-sha256=hex [subdir=path]", got "${value}"`; const arrowIdx = value.indexOf('->'); if (arrowIdx === -1) throw parseError(filePath, line, usage); const url = value.slice(0, arrowIdx).trim(); const [dest, ...opts] = value.slice(arrowIdx + 2).trim().split(/\s+/); - let sha256 = ''; + let tarSha256 = ''; let subdir: string | null = null; for (const opt of opts) { - const m = opt.match(/^(sha256|subdir)=(.+)$/); + const m = opt.match(/^(tar-sha256|subdir)=(.+)$/); if (!m) throw parseError(filePath, line, usage); - if (m[1] === 'sha256') sha256 = m[2].toLowerCase(); + if (m[1] === 'tar-sha256') tarSha256 = m[2].toLowerCase(); else subdir = m[2]; } if (!url || !dest) throw parseError(filePath, line, usage); - if (!/^[0-9a-f]{64}$/.test(sha256)) { - throw parseError(filePath, line, `@archive needs sha256=<64 hex chars>: nobody reviews hundreds of unpinned files, got "${value}"`); + if (!/^[0-9a-f]{64}$/.test(tarSha256)) { + throw parseError(filePath, line, `@archive needs tar-sha256=<64 hex chars>: nobody reviews hundreds of unpinned files, got "${value}"`); } if (subdir !== null && (subdir.startsWith('/') || subdir.split('/').some(p => p === '..' || p === '.'))) { throw parseError(filePath, line, `@archive subdir must be a relative path inside the archive, got "${subdir}"`); } - return { url, dest, sha256, subdir }; + return { url, dest, tarSha256, subdir }; } function stripJsonComments(text: string): string { diff --git a/src/pin-sources.ts b/src/pin-sources.ts index c4f2e65..b4687f1 100644 --- a/src/pin-sources.ts +++ b/src/pin-sources.ts @@ -10,15 +10,17 @@ export type PinSource = | { kind: 'npm'; pkg: string } | { kind: 'github-release'; repo: string } | { kind: 'github-tags'; repo: string } - // Repo without tags or releases: the pin is a commit SHA, current = branch head. - | { kind: 'github-branch'; repo: string; branch: string }; + // Repo without tags or releases: the pin is a commit SHA. Behind only when a + // commit touching `path` on `branch` is missing from it — the branch head + // moves hourly with release chores that change nothing we install. + | { kind: 'github-branch'; repo: string; branch: string; path: string }; export const PIN_SOURCES: Record = { '@playwright/mcp': { kind: 'npm', pkg: '@playwright/mcp' }, 'opencode-plugin-litellm-pricing': { kind: 'npm', pkg: 'opencode-plugin-litellm-pricing' }, 'opencode-planify-german': { kind: 'npm', pkg: 'opencode-planify-german' }, 'superpowers': { kind: 'github-release', repo: 'obra/superpowers' }, - 'diagram-design': { kind: 'github-branch', repo: 'cathrynlavery/diagram-design', branch: 'main' }, + 'diagram-design': { kind: 'github-branch', repo: 'cathrynlavery/diagram-design', branch: 'main', path: 'skills' }, }; export class UnknownPinError extends Error {} diff --git a/test/fetch-archive.test.ts b/test/fetch-archive.test.ts index ddabcc8..ee65802 100644 --- a/test/fetch-archive.test.ts +++ b/test/fetch-archive.test.ts @@ -4,8 +4,8 @@ import { createHash } from 'node:crypto'; import { mkdtemp, readFile, readdir, stat } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { gzipSync } from 'node:zlib'; -import { ARCHIVE_MARKER, fetchArchive } from '../src/fetch-archive.js'; +import { gunzipSync, gzipSync } from 'node:zlib'; +import { ARCHIVE_MARKER, fetchArchive, readTar } from '../src/fetch-archive.js'; type Entry = { name: string; type?: string; body?: string; mode?: number }; @@ -30,6 +30,7 @@ function tarOf(entries: Entry[]): Buffer { } const sha = (b: Buffer) => createHash('sha256').update(b).digest('hex'); +const tarSha = (tgz: Buffer) => sha(gunzipSync(tgz)); const realFetch = globalThis.fetch; let requests = 0; @@ -56,34 +57,34 @@ describe('fetchArchive', () => { const tgz = fixture(); serve(tgz); - const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: 'skills' }); + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: 'skills' }); assert.equal(r.cached, false); assert.equal(r.files, 2); assert.equal(await readFile(join(dest, 'tool/SKILL.md'), 'utf8'), 'skill'); assert.ok((await stat(join(dest, 'tool/scripts/run.py'))).mode & 0o100, 'executable bit kept'); assert.deepEqual((await readdir(dest)).sort(), [ARCHIVE_MARKER, 'tool']); - assert.equal((await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim(), sha(tgz)); + assert.equal((await readFile(join(dest, ARCHIVE_MARKER), 'utf8')).trim(), `${tarSha(tgz)} skills`); }); - test('second run with the same sha256 is a cache hit, no download', async () => { + test('second run with the same tar-sha256 is a cache hit, no download', async () => { const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); const tgz = fixture(); serve(tgz); - await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: null }); + await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: null }); serve(tgz); - const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, sha256: sha(tgz), subdir: null }); + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: null }); assert.equal(r.cached, true); assert.equal(requests, 0); }); - test('sha256 mismatch refuses and leaves no dest behind', async () => { + test('tar-sha256 mismatch refuses and leaves no dest behind', async () => { const dir = await mkdtemp(join(tmpdir(), 'archive-')); serve(fixture()); await assert.rejects( - fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: 'f'.repeat(64), subdir: null }), - /sha256 mismatch/, + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), tarSha256: 'f'.repeat(64), subdir: null }), + /tar-sha256 mismatch/, ); assert.deepEqual(await readdir(dir), []); }); @@ -99,7 +100,7 @@ describe('fetchArchive', () => { const tgz = tarOf([{ name: 'repo-abc/ok', body: 'ok' }, entry]); serve(tgz); await assert.rejects( - fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: sha(tgz), subdir: null }), + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), tarSha256: tarSha(tgz), subdir: null }), re, ); assert.deepEqual(await readdir(dir), []); @@ -111,8 +112,52 @@ describe('fetchArchive', () => { const tgz = fixture(); serve(tgz); await assert.rejects( - fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), sha256: sha(tgz), subdir: 'nope' }), + fetchArchive({ url: 'https://x/a.tar.gz', dest: join(dir, 'out'), tarSha256: tarSha(tgz), subdir: 'nope' }), /no files under nope\//, ); }); + + test('the hash covers the tar, so a recompressed download still installs', async () => { + const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); + const tgz = fixture(); + const recompressed = gzipSync(gunzipSync(tgz), { level: 1 }); + assert.notEqual(sha(recompressed), sha(tgz)); + serve(recompressed); + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: null }); + assert.equal(r.cached, false); + }); + + test('a trailing slash on dest unpacks into dest, not into a temp dir inside it', async () => { + const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); + const tgz = fixture(); + serve(tgz); + await fetchArchive({ url: 'https://x/a.tar.gz', dest: dest + '/', tarSha256: tarSha(tgz), subdir: 'skills' }); + assert.equal(await readFile(join(dest, 'tool/SKILL.md'), 'utf8'), 'skill'); + }); + + test('a changed subdir re-extracts instead of counting as a cache hit', async () => { + const dest = join(await mkdtemp(join(tmpdir(), 'archive-')), 'out'); + const tgz = fixture(); + serve(tgz); + await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: null }); + + serve(tgz); + const r = await fetchArchive({ url: 'https://x/a.tar.gz', dest, tarSha256: tarSha(tgz), subdir: 'skills' }); + assert.equal(r.cached, false); + assert.deepEqual((await readdir(dest)).sort(), [ARCHIVE_MARKER, 'tool']); + }); +}); + +describe('readTar', () => { + test('an old-GNU header does not read atime bytes as a path prefix', () => { + const h = Buffer.alloc(512); + h.write('repo/a.md', 0, 'utf8'); + h.write('0000644\0', 100, 'latin1'); + h.write('00000000000\0', 124, 'latin1'); + h.write('0', 156, 'latin1'); + h.write('ustar \0', 257, 'latin1'); + h.write('14444444444\0', 345, 'latin1'); // atime in old GNU + const [e] = readTar(Buffer.concat([h, Buffer.alloc(1024)])); + assert.equal(e.path, 'repo/a.md'); + }); }); diff --git a/test/parse-conf.test.ts b/test/parse-conf.test.ts index b62b755..6e19e02 100644 --- a/test/parse-conf.test.ts +++ b/test/parse-conf.test.ts @@ -99,7 +99,7 @@ describe('parseConfString — @include', () => { test('rejects a bundle that sets @fetch, @prompt, @pins or @requires-bin', () => { const cases: [string, RegExp][] = [ ['// @fetch: https://x/y.jar -> {{cache}}/y.jar\n', /must not set @fetch/], - [`// @archive: https://x/y.tar.gz -> {{cache}}/y sha256=${'a'.repeat(64)}\n`, /must not set @archive/], + [`// @archive: https://x/y.tar.gz -> {{cache}}/y tar-sha256=${'a'.repeat(64)}\n`, /must not set @archive/], ['// @prompt: url | text | where\n', /must not set @prompt/], ['// @pins: lombok 1.18.46\n', /must not set @pins/], ['// @requires-bin: shellcheck\n', /must not set @requires-bin/], @@ -232,30 +232,30 @@ describe('parseConfString — @archive', () => { const sha = 'AB'.repeat(32); test('parses url, dest, sha256 and subdir', () => { - const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} subdir=skills\n\n{}`; + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a tar-sha256=${sha} subdir=skills\n\n{}`; const { meta } = parseConfString(src); assert.deepEqual(meta.archive, [ - { url: 'https://example.com/a.tar.gz', dest: '/tmp/a', sha256: sha.toLowerCase(), subdir: 'skills' }, + { url: 'https://example.com/a.tar.gz', dest: '/tmp/a', tarSha256: sha.toLowerCase(), subdir: 'skills' }, ]); }); test('subdir is optional', () => { - const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha}\n\n{}`; + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a tar-sha256=${sha}\n\n{}`; assert.equal(parseConfString(src).meta.archive[0].subdir, null); }); - test('rejects an archive without sha256', () => { + test('rejects an archive without tar-sha256', () => { const src = minimalHeader + '// @archive: https://example.com/a.tar.gz -> /tmp/a\n\n{}'; - assert.throws(() => parseConfString(src), /@archive needs sha256/); + assert.throws(() => parseConfString(src), /@archive needs tar-sha256/); }); test('rejects a subdir that climbs out of the archive', () => { - const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} subdir=../x\n\n{}`; + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a tar-sha256=${sha} subdir=../x\n\n{}`; assert.throws(() => parseConfString(src), /subdir must be a relative path/); }); test('rejects an unknown option', () => { - const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a sha256=${sha} strip=2\n\n{}`; + const src = minimalHeader + `// @archive: https://example.com/a.tar.gz -> /tmp/a tar-sha256=${sha} strip=2\n\n{}`; assert.throws(() => parseConfString(src), /@archive must be/); }); }); From cf506175ddb33976b3fb246e47eebc659ebf6897 Mon Sep 17 00:00:00 2001 From: trick77 <1525566+trick77@users.noreply.github.com> Date: Mon, 26 Oct 2026 07:28:15 +0100 Subject: [PATCH 3/3] Bump planify skill and rules to opencode-planify-german e235864 (1.0.2) Eager diagram rules, missing diagram-design reported in chat, export via export_svg.py into docs/plans/. Plugin pin stays 1.0.1: no plugin code changed. --- presets/instructions-planify-german.conf | 6 +++--- presets/skill-planify-german.conf | 10 +++++----- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/presets/instructions-planify-german.conf b/presets/instructions-planify-german.conf index ff72a75..7ab22eb 100644 --- a/presets/instructions-planify-german.conf +++ b/presets/instructions-planify-german.conf @@ -7,12 +7,12 @@ // offered once the work is done. Appends a rules file to // `instructions`, replacing no built-in prompt. // @author: Jan -// @version: 1.0.1 +// @version: 1.0.2 // @part-of: planify-german // @path: instructions // @mode: append -// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/bb523af9b2679886d72b8d28c8f564e956813a29/instructions/planify.md -> {{cache}}/opencode-planify-german-rules-1.0.1.md sha256=f062418f13605f894e291f5b80dac97e373400e591bac166f1b554e629e92213 +// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/e235864efae408fe07752d6d3d53827281036647/instructions/planify.md -> {{cache}}/opencode-planify-german-rules-1.0.2.md sha256=31f2049890ad7316b76cf6ec144464ff339d2827eb79606ef17982db1b1c3cc6 [ - "{{cache}}/opencode-planify-german-rules-1.0.1.md" + "{{cache}}/opencode-planify-german-rules-1.0.2.md" ] diff --git a/presets/skill-planify-german.conf b/presets/skill-planify-german.conf index 1f77b59..c5caa73 100644 --- a/presets/skill-planify-german.conf +++ b/presets/skill-planify-german.conf @@ -6,14 +6,14 @@ // sha256-verified, and appends that directory to `skills.paths` — no clone needed. // Uninstall by deleting the entry. // @author: Jan -// @version: 1.0.1 +// @version: 1.0.2 // @part-of: planify-german // @path: skills.paths // @mode: append -// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/bb523af9b2679886d72b8d28c8f564e956813a29/skills/planify/SKILL.md -> {{cache}}/opencode-planify-german-skills-1.0.1/planify/SKILL.md sha256=e43bc633bacfccaac949500f55f7263dcbb9db0de95cecc5c73787a3218d7c60 -// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/bb523af9b2679886d72b8d28c8f564e956813a29/skills/planify/schema/plan.schema.json -> {{cache}}/opencode-planify-german-skills-1.0.1/planify/schema/plan.schema.json sha256=1617c2c2789d2ed2421145049b4a4dbe8614c6ef0e9027b85f31643ca6faee73 -// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/bb523af9b2679886d72b8d28c8f564e956813a29/skills/planify/references/beispiel-plan.json -> {{cache}}/opencode-planify-german-skills-1.0.1/planify/references/beispiel-plan.json sha256=f4507cf91ed84c54b323dede8050dcf9c15dfdb5ca9f6f1b78a2c1f6b4036bc3 +// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/e235864efae408fe07752d6d3d53827281036647/skills/planify/SKILL.md -> {{cache}}/opencode-planify-german-skills-1.0.2/planify/SKILL.md sha256=1b349de872d7c2d659ef729535ffadfcf2a10a4f428abeb6fd615ab2bc79ed75 +// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/e235864efae408fe07752d6d3d53827281036647/skills/planify/schema/plan.schema.json -> {{cache}}/opencode-planify-german-skills-1.0.2/planify/schema/plan.schema.json sha256=1617c2c2789d2ed2421145049b4a4dbe8614c6ef0e9027b85f31643ca6faee73 +// @fetch: https://raw.githubusercontent.com/trick77/opencode-planify-german/e235864efae408fe07752d6d3d53827281036647/skills/planify/references/beispiel-plan.json -> {{cache}}/opencode-planify-german-skills-1.0.2/planify/references/beispiel-plan.json sha256=f4507cf91ed84c54b323dede8050dcf9c15dfdb5ca9f6f1b78a2c1f6b4036bc3 [ - "{{cache}}/opencode-planify-german-skills-1.0.1" + "{{cache}}/opencode-planify-german-skills-1.0.2" ]