diff --git a/.changeset/grokbot-official-sideload.md b/.changeset/grokbot-official-sideload.md new file mode 100644 index 000000000..2059ae463 --- /dev/null +++ b/.changeset/grokbot-official-sideload.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": patch +--- + +Sideload the plugin into Grok Bot's synced marketplace on `install grokbot` when Grok Bot agent-data is found: add the plugin folder and a `marketplace.json` entry to the one active `scriptedalchemy/plugins` clone, and write a `.cache-complete` copy under `plugins/cache/scriptedalchemy-plugins`. Folders and entries the installer did not write are never touched, and every write is atomic. Every written path is recorded in the install receipt, and `uninstall grokbot` removes exactly those paths. Change or turn off the target with `--sideload-repo`, `--sideload-slug`, or `--no-sideload` (or `GROK_BOT_SIDELOAD_REPO`, `GROK_BOT_SIDELOAD_SLUG`, `GROK_BOT_SIDELOAD=0`). `GROK_BOT_SIDELOAD_MARKETPLACE` is still read but must name one repository. The next install or uninstall retires the first sideload's `agent-bundle/sideload/.json` record under `~/.grokbot` (or `GROK_BOT_HOME`). `doctor` reports the sideload state as AB7335. (#876) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index c00abc9d5..5e93241f7 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -38,7 +38,7 @@ even when no error diagnostic was reported. | `AB7010`–`AB7015` | npm prepack inventory, artifact freshness, package bin targets, release-version agreement, and installed-dependency hygiene (`AB7014`: a dependency no consumer-runtime evidence requires; `AB7015`: a git, remote-tarball, path, or unrewritten workspace-protocol dependency specifier). | | `AB7200`–`AB7202`, `AB7210`–`AB7211` | Development rebuilds and live host surfaces: rebuild admission and phase failures, development host install sync, and the dev-epoch contract gate (see below). | | `AB7xxx` | Project preparation and development rebuilds (`AB7100`–`AB7102`: a development rebuild's compilation, publication, and cleanup; `AB7101` is also the one-shot `build` / `build()` refusal when source changes during compilation; `AB7103`: the development package build; see below). | -| `AB7300`–`AB7334` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), dangling receipt-owned marketplaces (`AB7333`; see below), and the opt-in Grok Bot report (`AB7334`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `AB7300`–`AB7335` | Read-only install Doctor: host probes, installed inventory, bundle comparison and registration proof, runtime endpoint health and identity, durable-state inventory, static bytes-at-rest validation, foreign-install detection (`AB7321`; see below), Cursor plugin hook registration / marketplace staging (`AB7322`–`AB7324`; see below), host load refusal (`AB7325`; see below), the Cursor Agent Plugins launch proof (`AB7326`; see below), a disabled Claude install (`AB7327`; see below), lifecycle receipts and activation states (`AB7328`–`AB7330`; see below), the operator `.env` layer of an installed pack (`AB7331`; see below), the retired `AB7332` (see below), dangling receipt-owned marketplaces (`AB7333`; see below), and the opt-in Grok Bot report (`AB7334`–`AB7335`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | | `AB8200`–`AB8209` | Workbench development runtime routes (`/api/runtime/**`): `AB8200` development runtime provider configuration, load, or lifecycle failure, `AB8201` runtime/session/run not available, `AB8202` invalid route path, `AB8203` invalid request shape, `AB8204` stale runtime generation (409), `AB8205` runtime request could not be completed, `AB8206` Workbench runtime client failure, `AB8207` Agent Document decoding needs the optional `@agent-bundle/runtime` peer (503), `AB8208` stored Flight could not be decoded as an Agent Document (409), `AB8209` decoded Agent Document over the 16 MiB budget (413) or an invalid document response. | | `AB8210`–`AB8214` | Workbench semantic lifecycle replay routes (`/api/lifecycles`, `/api/lifecycles/replays`): `AB8210` invalid path, `AB8211` malformed replay request or native envelope (400, carries the shared validator message), `AB8212` replay unavailable or could not be completed, `AB8213` stale manifest binding (409; the page repairs it with refresh → explicit re-run), `AB8214` replay over the 16 MiB budget (413). | | `AB8215`–`AB8218` | Workbench read-only host discovery route (`/api/discovery`): `AB8215` invalid path, `AB8216` query string or non-`GET` method (400/405), `AB8217` report over the 16 MiB response limit (413), `AB8218` discovery not available (503). | @@ -1271,11 +1271,14 @@ together with the server-assigned plugin id from `/plugin-skills/cac (`GROK_BOT_AGENT_DATA_DIR`, then `/home/box/agent-data`, `~/.grokbot/agent-data`, and the macOS application-support root). It never touches the account-level install. A plugin that contributes no skill has no skill-index row, so its id reads -as not assigned even when the cache copy exists. +as not assigned even when the cache copy exists. A cache copy `install grokbot` +sideloaded is not reported as an install until Grok Bot's index names it; the +sideload has its own report (`AB7335`). | Code | Severity | Trigger | | --- | --- | --- | | `AB7334` | info / warning | With `--from`: the bundle's Grok Bot state, not installed (and whether a staged marketplace awaits hosting), or installed with each copy's plugin id, manifest version, installed commit, and marketplace; warning when every installed copy has a different version than the bundle. Host the staged marketplace and install the plugin from Grok Bot's Marketplace; for a version warning push the rebuilt marketplace and update the plugin in Grok Bot. | +| `AB7335` | info / warning | A grokbot receipt records a sideload (`install grokbot` wrote the plugin into Grok Bot's marketplace clone, its manifest, and its plugin cache). Info when Grok Bot's plugin index names the sideloaded cache copy (loaded), or when every part is present but not loaded: Grok Bot loads only plugins its account plugin listing returns, and its next plugin sync removes the cache copy otherwise. Warning when a part is gone: the cache copy (removed by that sync), or the clone folder and manifest entry (removed when Grok Bot moves the marketplace to a new commit). Rerun `agent-bundle install grokbot` to restore it; publish the plugin to the marketplace and install it from Grok Bot for a durable install. | ## Read-only runtime identity introspection (`AB7317`–`AB7318`) diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index 3634d5c06..f34b80453 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -36,6 +36,10 @@ interface InstallCommandOptions { readonly replace?: boolean; readonly mode?: InstallMode; readonly scope: string; + /** Commander sets `true` unless `--no-sideload` is given. */ + readonly sideload?: boolean; + readonly sideloadRepo?: string; + readonly sideloadSlug?: string; } interface UninstallCommandOptions { @@ -110,6 +114,18 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo 'same-version content drift is replaced automatically and foreign installs are always refused', ) .option('--mode ', 'Cursor delivery mode: local (default) or marketplace', installMode) + .option( + '--no-sideload', + 'grokbot: skip sideloading into Grok Bot\'s marketplace clone and plugin cache (also GROK_BOT_SIDELOAD=0)', + ) + .option( + '--sideload-repo ', + 'grokbot: GitHub marketplace whose Grok Bot clone receives the sideload (default scriptedalchemy/plugins; GROK_BOT_SIDELOAD_REPO)', + ) + .option( + '--sideload-slug ', + 'grokbot: Grok Bot plugin-cache partition for that marketplace (default -; GROK_BOT_SIDELOAD_SLUG)', + ) .option('--json', 'Write one machine-readable JSON document'); installCommand.action(async (host: InstallHost, commandOptions: InstallCommandOptions) => { const { installBundle: install } = await lifecycle(); @@ -119,6 +135,9 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo replace: commandOptions.replace === true, ...(commandOptions.mode === undefined ? {} : { mode: commandOptions.mode }), scope: installScope(commandOptions.scope), + ...(commandOptions.sideload === false ? { sideload: false } : {}), + ...(commandOptions.sideloadRepo === undefined ? {} : { sideloadRepo: commandOptions.sideloadRepo }), + ...(commandOptions.sideloadSlug === undefined ? {} : { sideloadSlug: commandOptions.sideloadSlug }), }); await (commandOptions.json === true ? machine(result) : show(formatInstallResult(result))); }); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index 9594e2c21..e902c1c50 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -57,6 +57,7 @@ import { type InstalledTreeComparison, type InstalledTreeOwnership, type InstallReceipt, + type InstallReceiptGrokBotSideload, type InstallReceiptMode, type InstallReceiptScope, type InstallRegistration, @@ -75,6 +76,7 @@ import { } from './cursor-hooks-registration.ts'; import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; import { grokBotMarketplaceRoot, grokBotRoot, readGrokBotInventory, type GrokBotInventory } from './grokbot.ts'; +import { type GrokBotSideloadInspection, inspectGrokBotSideload } from './grokbot-sideload.ts'; import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { inspectInstalledStateOwnership, resolveInstalledStateRoots } from './state-root.ts'; @@ -202,6 +204,16 @@ export interface DoctorLifecycle { * host no longer lists the plugin or the staged repository is gone), or * `unknown` (the host inventory was unusable). */ +/** grokbot receipts: what `install grokbot` sideloaded and which parts Grok Bot still has (`AB7335`). */ +export interface DoctorGrokBotSideload extends GrokBotSideloadInspection { + readonly cachePath: string; + readonly commit: string; + readonly manifest: string; + readonly pluginPath: string; + readonly repo: string; + readonly slug: string; +} + export interface DoctorReceiptFinding { readonly contentHash: string; readonly format: string; @@ -211,6 +223,7 @@ export interface DoctorReceiptFinding { readonly plugin: string; readonly registrations: readonly InstallRegistration[]; readonly scope: InstallReceiptScope; + readonly sideload?: DoctorGrokBotSideload; readonly state: 'consistent' | 'orphaned' | 'unknown'; readonly updatedAt: string; readonly version: string; @@ -2769,6 +2782,48 @@ const doctorHost = async ( }); }; +/** `AB7335`: which parts of a receipted Grok Bot sideload remain, and whether Grok Bot loaded it. */ +const grokBotSideloadDiagnostic = (plugin: string, sideload: DoctorGrokBotSideload): Diagnostic => { + const where = `${sideload.repo} @ ${sideload.commit}, cache ${sideload.slug}`; + const missing = [ + ...(sideload.cacheCopy ? [] : [`the cache copy ${sideload.cachePath} (removed by Grok Bot's plugin sync)`]), + ...(sideload.cloneActive + ? [ + ...(sideload.pluginFolder ? [] : [`the clone folder ${sideload.pluginPath}`]), + ...(sideload.listed ? [] : [`the entry in ${sideload.manifest}`]), + ] + : [`the clone @ ${sideload.commit} (Grok Bot moved the marketplace to a new commit and deleted it)`]), + ]; + if (missing.length > 0) { + return diagnostic( + 'AB7335', + `${plugin} sideload on grokbot: incomplete (${where}); missing ${missing.join(', ')}` + + `${sideload.loaded ? ", though Grok Bot's plugin index still names the cache copy" : ''}.`, + 'Rerun `agent-bundle install grokbot` to restore the sideload in the clone Grok Bot is using now.', + 'warning', + 'grokbot', + ); + } + return sideload.loaded + ? diagnostic( + 'AB7335', + `${plugin} sideload on grokbot: loaded — Grok Bot's plugin index names the cache copy ${sideload.cachePath} (${where}).`, + 'No action needed.', + 'info', + 'grokbot', + ) + : diagnostic( + 'AB7335', + `${plugin} sideload on grokbot: written (${where}), not loaded. Grok Bot loads only plugins its account plugin listing ` + + `returns; its next plugin sync (startup, sign-in change, or every 24 hours) removes the cache copy unless the account ` + + `lists ${plugin} from ${sideload.slug}.`, + `For a durable install, publish ${plugin} to ${sideload.repo} and install it from Grok Bot's Marketplace; rerun ` + + '`agent-bundle install grokbot` to restore a pruned sideload.', + 'info', + 'grokbot', + ); +}; + /** * Read-only Grok Bot report: the staged-marketplace receipts Agent Bundle wrote under `~/.grokbot`, and every * completed copy of the plugin in the Grok Bot computer's plugin cache with the server-assigned plugin id and @@ -2778,9 +2833,31 @@ const doctorGrokBot = async (options: DoctorOptions, home: string): Promise - receipt.mode === 'marketplace' && await exists(join(grokBotMarketplaceRoot(root), receipt.plugin)) ? 'consistent' : 'orphaned'); + const sideloads = new Map(); + const receipts = await inspectStoreReceipts('grokbot', root, async (receipt) => { + if (receipt.grokBotSideload !== undefined) sideloads.set(receipt.plugin, receipt.grokBotSideload); + return receipt.mode === 'marketplace' && await exists(join(grokBotMarketplaceRoot(root), receipt.plugin)) ? 'consistent' : 'orphaned'; + }); diagnostics.push(...receipts.diagnostics); + const receiptFindings: DoctorReceiptFinding[] = []; + for (const finding of receipts.receipts) { + const record = sideloads.get(finding.plugin); + if (record === undefined) { + receiptFindings.push(finding); + continue; + } + const sideload: DoctorGrokBotSideload = Object.freeze({ + ...await inspectGrokBotSideload(record, finding.plugin), + cachePath: record.cachePath, + commit: record.commit, + manifest: record.manifest, + pluginPath: record.pluginPath, + repo: record.repo, + slug: record.slug, + }); + receiptFindings.push(Object.freeze({ ...finding, sideload })); + diagnostics.push(grokBotSideloadDiagnostic(finding.plugin, sideload)); + } let identity: PluginIdentity | undefined; let bundleError: unknown; if (options.from !== undefined) { @@ -2853,7 +2930,7 @@ const doctorGrokBot = async (options: DoctorOptions, home: string): Promise { if (result.marketplace !== undefined && (result.host === 'cursor' || result.host === 'grokbot')) { lines.push(`Marketplace: ${result.marketplace}${result.commit === undefined ? '' : ` @ ${result.commit}`}`); } + if (result.sideload !== undefined) { + lines.push(result.sideload.state === 'skipped' + ? `Grok Bot sideload: skipped — ${result.sideload.reason}` + : `Grok Bot sideload: ${result.sideload.state} (${result.sideload.repo} @ ${result.sideload.commit}, cache ${result.sideload.slug})`); + } if (result.nextSteps !== undefined && result.nextSteps.length > 0) { lines.push('Next steps:'); lines.push(...result.nextSteps.map((step, index) => ` ${index + 1}. ${step}`)); diff --git a/packages/agent-bundle/src/install/grokbot-agent-data.ts b/packages/agent-bundle/src/install/grokbot-agent-data.ts new file mode 100644 index 000000000..0d5b21a3d --- /dev/null +++ b/packages/agent-bundle/src/install/grokbot-agent-data.ts @@ -0,0 +1,74 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { join, resolve } from 'node:path'; + +import { isErrno } from '../core/errors.ts'; + +/** + * Read-only access to a Grok Bot computer's `agent-data` directory, shared by the grokbot installer, its + * sideload into Grok Bot's marketplace clone, Doctor, and uninstall. + */ + +/** Where a Grok Bot computer keeps its data, most specific first (`GROK_BOT_AGENT_DATA_DIR` overrides). */ +export const grokBotAgentDataCandidates = ( + environment: Readonly, + home: string, +): readonly string[] => Object.freeze([ + ...(environment['GROK_BOT_AGENT_DATA_DIR'] === undefined || environment['GROK_BOT_AGENT_DATA_DIR'] === '' + ? [] + : [environment['GROK_BOT_AGENT_DATA_DIR']]), + '/home/box/agent-data', + join(home, '.grokbot', 'agent-data'), + join(home, 'Library', 'Application Support', 'Grok Bot', 'agent-data'), + // Absolute and normalized (no `..`, no trailing separator): receipts record paths derived from it verbatim. +].map((candidate) => resolve(candidate))); + +export const directoryExists = async (path: string): Promise => { + try { + return (await stat(path)).isDirectory(); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return false; + throw error; + } +}; + +/** The first candidate that holds a `plugins` directory, or undefined when this computer has no Grok Bot data. */ +export const findGrokBotAgentData = async ( + environment: Readonly, + home: string, +): Promise => { + for (const candidate of grokBotAgentDataCandidates(environment, home)) { + if (await directoryExists(join(candidate, 'plugins'))) return candidate; + } + return undefined; +}; + +export const readJsonFile = async (path: string): Promise => { + try { + return JSON.parse(await readFile(path, 'utf8')) as unknown; + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES') || error instanceof SyntaxError) { + return undefined; + } + throw error; + } +}; + +export const listDirectory = async (path: string): Promise => { + try { + return (await readdir(path)).sort(); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return []; + throw error; + } +}; + +/** + * `//` of a path inside `plugins/cache`. Grok Bot's indexes may record the legacy + * `sand-data` root, so only the cache-relative tail identifies a copy. + */ +export const pluginCacheKey = (installPath: string): string | undefined => { + const segments = installPath.split(/[\\/]/u).filter((segment) => segment !== ''); + const cache = segments.lastIndexOf('cache'); + if (cache < 1 || segments[cache - 1] !== 'plugins' || segments.length !== cache + 4) return undefined; + return segments.slice(cache + 1).join('/'); +}; diff --git a/packages/agent-bundle/src/install/grokbot-sideload.ts b/packages/agent-bundle/src/install/grokbot-sideload.ts new file mode 100644 index 000000000..c6172908f --- /dev/null +++ b/packages/agent-bundle/src/install/grokbot-sideload.ts @@ -0,0 +1,782 @@ +import { randomUUID } from 'node:crypto'; +import { lstat, mkdir, open, readFile, realpath, rename, rm, rmdir, stat, writeFile } from 'node:fs/promises'; +import { basename, dirname, isAbsolute, join, normalize, relative, sep } from 'node:path'; + +import { Predicate } from 'effect'; + +import { DiagnosticError } from '../core/diagnostics.ts'; +import { isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; +import { findGrokBotAgentData, listDirectory, pluginCacheKey, readJsonFile } from './grokbot-agent-data.ts'; +import { type InstallReceiptGrokBotSideload, type TreeInventory, copyInventoryFiles } from './receipt.ts'; + +/** + * Sideload for `install grokbot`: mirrors the Cursor projection into the plugin directories Grok Bot itself + * manages, under `/plugins/`: + * + * - `marketplaces/github.com/////` — the plugin folder inside Grok Bot's (sparse, + * shallow) clone of the marketplace repository, beside the plugins Grok Bot materialized there, plus a + * `plugins` entry in that clone's `.cursor-plugin/marketplace.json` (or `.claude-plugin/marketplace.json`). + * - `cache////` — a per-plugin cache copy with the `.cache-complete` marker Grok Bot's + * cache manager checks before it reuses a copy instead of cloning one. + * + * `` is never invented: it is the clone folder Grok Bot already has (the one its other plugins' cache + * copies use while a re-clone briefly leaves two). Every folder carries `.agent-bundle-sideload.json` naming + * the plugin, and the receipt records the exact manifest entry written: a folder without the marker, an entry + * that differs from the recorded one, or a path reached through a symbolic link is Grok Bot's or another + * plugin's and is never touched. + * + * What Grok Bot's plugin sync (startup, sign-in change, and every 24 hours) does to these paths, as read from + * its host: it loads only the plugins the account's plugin listing returns, and after each pass removes every + * `cache//` folder the listing did not name. A sideloaded cache copy therefore lasts until the + * next pass unless the account lists `` from `` at ``, in which case Grok Bot finds the + * marked copy and loads it as is. The clone folder and manifest entry are untracked by git and survive passes + * and sparse-checkout widening, until the marketplace moves to a new commit: Grok Bot then clones the new + * `` and deletes every sibling clone folder, sideload included. Re-running the installer restores both. + */ + +export const grokBotSideloadMarkerFile = '.agent-bundle-sideload.json'; +export const defaultGrokBotSideloadRepo = 'scriptedalchemy/plugins'; + +const cacheCompleteFile = '.cache-complete'; +/** Grok Bot reads the Cursor manifest first and falls back to the Claude one. */ +const marketplaceManifestPaths = ['.cursor-plugin/marketplace.json', '.claude-plugin/marketplace.json'] as const; +const markerFormat = 'agent-bundle-grokbot-sideload@1'; +const commitPattern = /^[0-9a-f]{40}$/u; +const repoSegmentPattern = /^[a-z0-9_.-]+$/u; +/** Grok Bot replaces anything else in cache slugs and plugin ids with `_`, so only these spell a stable path. */ +const cacheSegmentPattern = /^[A-Za-z0-9_-]+$/u; + +export interface GrokBotSideloadOptions { + /** `false` skips the sideload (`--no-sideload`); defaults to on unless `GROK_BOT_SIDELOAD=0`. */ + readonly sideload?: boolean; + /** `/` of the marketplace clone (`--sideload-repo`, `GROK_BOT_SIDELOAD_REPO`). */ + readonly sideloadRepo?: string; + /** Grok Bot's cache partition for it (`--sideload-slug`, `GROK_BOT_SIDELOAD_SLUG`); defaults to `-`. */ + readonly sideloadSlug?: string; +} + +export interface GrokBotSideloadSettings { + readonly enabled: boolean; + readonly repo: string; + readonly slug: string; +} + +export type GrokBotSideloadResult = + | Readonly<{ readonly reason: string; readonly state: 'skipped' }> + | Readonly; + +const failure = (message: string): DiagnosticError => + new DiagnosticError([{ code: 'AB7003', message, severity: 'error', target: 'grokbot' }]); + +const disabledValues = new Set(['0', 'false', 'no', 'off']); + +export const resolveGrokBotSideloadSettings = ( + options: GrokBotSideloadOptions, + environment: Readonly, +): GrokBotSideloadSettings => { + // `GROK_BOT_SIDELOAD_MARKETPLACE` is the name the first sideload release read; it now names exactly one repository. + const legacy = environment['GROK_BOT_SIDELOAD_MARKETPLACE']?.trim() || undefined; + if (legacy?.includes(',') === true && options.sideloadRepo === undefined && environment['GROK_BOT_SIDELOAD_REPO'] === undefined) { + throw failure(`GROK_BOT_SIDELOAD_MARKETPLACE ${JSON.stringify(legacy)} names several repositories; set GROK_BOT_SIDELOAD_REPO to one /.`); + } + const repo = (options.sideloadRepo ?? environment['GROK_BOT_SIDELOAD_REPO'] ?? legacy ?? defaultGrokBotSideloadRepo) + .trim().toLowerCase().replace(/^(?:https?:\/\/)?github\.com\//u, '').replace(/(?:\.git)?\/?$/u, ''); + const segments = repo.split('/'); + if (segments.length !== 2 || segments.some((segment) => !repoSegmentPattern.test(segment) || /^\.+$/u.test(segment))) { + throw failure(`Sideload repository ${JSON.stringify(repo)} must be a GitHub /.`); + } + const slug = (options.sideloadSlug ?? environment['GROK_BOT_SIDELOAD_SLUG'] ?? segments.join('-')).trim(); + if (!cacheSegmentPattern.test(slug)) { + throw failure(`Sideload slug ${JSON.stringify(slug)} may contain only letters, digits, "-", and "_".`); + } + const fromEnvironment = environment['GROK_BOT_SIDELOAD']; + const enabled = options.sideload ?? !(fromEnvironment !== undefined && disabledValues.has(fromEnvironment.trim().toLowerCase())); + return Object.freeze({ enabled, repo, slug }); +}; + +const isDirectory = async (path: string): Promise => { + try { + return (await lstat(path)).isDirectory(); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return false; + throw error; + } +}; + +/** True only for a real directory (not a link) whose marker names `plugin`: proof the sideload wrote it. */ +export const ownsGrokBotSideloadPath = async (path: string, plugin: string): Promise => { + if (!(await isDirectory(path))) return false; + const marker = await readJsonFile(join(path, grokBotSideloadMarkerFile)); + return Predicate.isObject(marker) && marker['format'] === markerFormat && marker['plugin'] === plugin; +}; + +const markerDocument = (plugin: string): string => `${JSON.stringify({ format: markerFormat, plugin }, null, 2)}\n`; + +const isInside = (root: string, path: string): boolean => { + const tail = relative(root, path); + return tail !== '' && !tail.startsWith('..') && !isAbsolute(tail); +}; + +/** Ancestors of `path` strictly below `root` that do not exist yet, outermost first. */ +const missingAncestors = async (root: string, path: string): Promise => { + const missing: string[] = []; + for (let current = dirname(path); isInside(root, current); current = dirname(current)) { + if (await exists(current)) break; + missing.unshift(current); + } + return missing; +}; + +/** The `` folder Grok Bot is using for the marketplace clone, or why there is none to use. */ +const activeClone = async (options: { + readonly agentData: string; + readonly plugin: string; + readonly repoRoot: string; + readonly slug: string; +}): Promise<{ readonly commit: string } | { readonly reason: string }> => { + const clones: string[] = []; + for (const name of await listDirectory(options.repoRoot)) { + if (commitPattern.test(name) && await exists(join(options.repoRoot, name, '.git'))) clones.push(name); + } + if (clones.length === 1) return { commit: clones[0] as string }; + if (clones.length === 0) { + return { + reason: `Grok Bot has no clone of this marketplace under ${options.repoRoot}; install any plugin from it in Grok Bot ` + + 'first, or pass --sideload-repo for the marketplace Grok Bot already cloned.', + }; + } + // A re-clone briefly leaves two folders before Grok Bot prunes the old one; its plugins' cache copies name the live one. + const cacheRoot = join(options.agentData, 'plugins', 'cache', options.slug); + const used = new Set(); + for (const plugin of await listDirectory(cacheRoot)) { + if (plugin === options.plugin) continue; + for (const version of await listDirectory(join(cacheRoot, plugin))) { + if (clones.includes(version) && await exists(join(cacheRoot, plugin, version, cacheCompleteFile))) used.add(version); + } + } + const [only] = used; + return used.size === 1 && only !== undefined + ? { commit: only } + : { reason: `Grok Bot has ${clones.length} clones under ${options.repoRoot} (${clones.join(', ')}) and none is clearly active; rerun once its plugin sync finishes.` }; +}; + +interface ManifestDocument { + readonly document: Record & { readonly plugins: unknown[] }; + readonly path: string; + readonly text: string; +} + +const readManifest = async (path: string): Promise => { + let text: string; + try { + text = await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return undefined; + throw error; + } + let document: unknown; + try { + document = JSON.parse(text); + } catch { + return undefined; + } + if (!Predicate.isObject(document) || !Array.isArray(document['plugins'])) return undefined; + return { document: document as ManifestDocument['document'], path, text }; +}; + +const entryIndex = (manifest: ManifestDocument, plugin: string): number => + manifest.document.plugins.findIndex((entry) => Predicate.isObject(entry) && entry['name'] === plugin); + +const sameEntry = (left: unknown, right: Readonly> | undefined): boolean => { + if (!Predicate.isObject(left) || right === undefined) return false; + const leftKeys = Object.keys(left).sort(); + const rightKeys = Object.keys(right).sort(); + return leftKeys.length === rightKeys.length && + leftKeys.every((key, position) => key === rightKeys[position] && left[key] === right[key]); +}; + +const listedEntry = (manifest: ManifestDocument, plugin: string): unknown => manifest.document.plugins[entryIndex(manifest, plugin)]; + +/** + * True when `path` resolves to itself below the real `agentData`: no symbolic link between the agent-data root + * and `path` could redirect a removal or write somewhere the record does not name. A missing path is accepted. + */ +const resolvesInPlace = async (agentData: string, path: string): Promise => { + let root: string; + try { + root = await realpath(agentData); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return false; + throw error; + } + const tail = relative(agentData, path); + if (tail === '' || tail.startsWith('..') || isAbsolute(tail)) return false; + // Walk down from the root: every existing component must resolve to exactly its own spelling. + let expected = root; + for (const segment of tail.split(sep)) { + expected = join(expected, segment); + let actual: string; + try { + actual = await realpath(expected); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return true; + throw error; + } + if (actual !== expected) return false; + } + return true; +}; + +const indentOf = (text: string): string => /^\{\r?\n([ \t]+)"/u.exec(text)?.[1] ?? ' '; + +/** Serializes `plugins` into `manifest`, keeping its other fields, indentation, and trailing newline. */ +const manifestText = (manifest: ManifestDocument, plugins: readonly unknown[]): string => + `${JSON.stringify({ ...manifest.document, plugins }, null, indentOf(manifest.text))}${manifest.text.endsWith('\n') ? '\n' : ''}`; + +const readText = async (path: string): Promise => { + try { + return await readFile(path, 'utf8'); + } catch (error) { + if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR')) return undefined; + throw error; + } +}; + +const manifestAttempts = 5; + +/** + * Applies `update` to the manifest and replaces it atomically (exclusive temporary sibling, then rename), keeping + * its mode and layout. Optimistic: the file is read again right before the rename and, when Grok Bot changed it + * meanwhile, `update` runs again on the new contents, so a concurrent write is not overwritten with stale JSON + * (only the instant between that last read and the rename is unguarded; Grok Bot takes no lock to share). + * `update` returns the new `plugins` array, `undefined` to leave the file alone, or throws to refuse. + */ +const updateManifest = async ( + path: string, + update: (manifest: ManifestDocument) => readonly unknown[] | undefined, +): Promise => { + for (let attempt = 0; attempt < manifestAttempts; attempt += 1) { + const manifest = await readManifest(path); + if (manifest === undefined) throw failure(`${path} is no longer a readable marketplace manifest; left untouched.`); + const plugins = update(manifest); + if (plugins === undefined) return false; + const text = manifestText(manifest, plugins); + if (text === manifest.text) return false; + const mode = (await stat(path)).mode & 0o777; + const temporary = join(dirname(path), `.${basename(path)}.${randomUUID()}.tmp`); + try { + const handle = await open(temporary, 'wx', mode); + try { + await handle.writeFile(text, 'utf8'); + } finally { + await handle.close(); + } + if (await readText(path) !== manifest.text) continue; + await rename(temporary, path); + return true; + } finally { + await rm(temporary, { force: true }); + } + } + throw failure(`${path} kept changing while agent-bundle updated it; rerun once Grok Bot's plugin sync settles.`); +}; + +/** Scratch siblings `writeTree` names `..agent-bundle-[.replaced]`; only this installer makes them. */ +const scratchPattern = (destination: string): RegExp => { + const name = basename(destination).replace(/[$()*+.?[\\\]^{|}]/gu, (character) => `\\${character}`); + return new RegExp(`^\\.${name}\\.agent-bundle-[0-9a-f-]{36}(?:\\.replaced)?$`, 'u'); +}; + +/** + * Clears what an interrupted `writeTree` left beside `destination`: half-written stages (only this installer names + * them so) are deleted, and an aside copy is deleted only when it carries this plugin's marker. An unmarked aside + * is someone else's folder caught mid-swap: it goes back to `destination` when that is free and is otherwise kept. + */ +const removeScratch = async (destination: string, plugin: string): Promise => { + const pattern = scratchPattern(destination); + for (const name of await listDirectory(dirname(destination))) { + if (!pattern.test(name)) continue; + const path = join(dirname(destination), name); + if (!name.endsWith('.replaced') || await ownsGrokBotSideloadPath(path, plugin)) { + await rm(path, { force: true, recursive: true }); + } else if (!(await exists(destination))) { + await rename(path, destination); + } + } +}; + +/** + * Lands the plugin tree at `destination`: a fully written, marked sibling is renamed into place, so Grok Bot never + * sees a half-copied folder (or a `.cache-complete` folder missing files). Replacing an earlier sideload copy is + * two renames (the old copy aside, the new one in) with the old copy deleted at once; no portable rename swaps + * directories, so an interrupted swap can leave the destination briefly absent, never partial, and its scratch + * sibling is deleted by the next install or uninstall. Ownership is checked again on the copy actually moved aside: + * a folder that appeared meanwhile without this plugin's marker is put back and the write fails. + */ +const writeTree = async (options: { + readonly agentData: string; + readonly artifact: TreeInventory; + readonly bundleRoot: string; + readonly cacheComplete: boolean; + readonly destination: string; + readonly plugin: string; +}): Promise => { + const { destination, plugin } = options; + if (!(await resolvesInPlace(options.agentData, destination))) throw failure(`${destination} resolves through a symbolic link; sideload refused.`); + await mkdir(dirname(destination), { recursive: true }); + await removeScratch(destination, plugin); + const stage = join(dirname(destination), `.${basename(destination)}.agent-bundle-${randomUUID()}`); + const aside = `${stage}.replaced`; + const foreign = (): DiagnosticError => + failure(`${destination} appeared without agent-bundle's sideload marker while installing; left untouched.`); + // Set only once the folder moved aside is proven to be this plugin's sideload: nothing else is ever deleted. + let asideOwned = false; + try { + await copyInventoryFiles(options.bundleRoot, stage, options.artifact); + await writeFile(join(stage, grokBotSideloadMarkerFile), markerDocument(plugin)); + if (options.cacheComplete) await writeFile(join(stage, cacheCompleteFile), ''); + if (await exists(destination)) { + if (!(await ownsGrokBotSideloadPath(destination, plugin))) throw foreign(); + await rename(destination, aside); + if (!(await ownsGrokBotSideloadPath(aside, plugin))) { + await rename(aside, destination); + throw foreign(); + } + asideOwned = true; + try { + await rename(stage, destination); + } catch (error) { + await rename(aside, destination); + throw error; + } + } else { + await rename(stage, destination); + } + } finally { + await rm(stage, { force: true, recursive: true }); + if (asideOwned) await rm(aside, { force: true, recursive: true }); + } +}; + +const isEmptyDirectory = async (path: string, removing: ReadonlySet): Promise => + await isDirectory(path) && (await listDirectory(path)).every((entry) => removing.has(join(path, entry))); + +export interface GrokBotSideloadRemoval { + /** Owned folders removed (or, with `plan`, that would be), then created parents left empty. */ + readonly directories: readonly string[]; + /** The manifest whose entry was (or would be) removed. */ + readonly manifest?: string; + /** Recorded paths kept because they no longer carry this plugin's marker. */ + readonly retained: readonly string[]; +} + +/** + * Removes what a sideload record names, and nothing else: folders that still carry this plugin's marker and resolve + * in place below agent-data, the manifest entry while it still equals the recorded entry (and its plugin folder is + * not someone else's), and created parents once empty. `keep` names paths a newer sideload reuses. + */ +export const removeGrokBotSideload = async ( + record: InstallReceiptGrokBotSideload, + plugin: string, + options: { readonly keep?: ReadonlySet; readonly plan?: boolean } = {}, +): Promise => { + const keep = options.keep ?? new Set(); + const directories: string[] = []; + const retained: string[] = []; + let pluginFolderOurs = true; + for (const path of [record.cachePath, record.pluginPath]) { + if (keep.has(path)) continue; + if (options.plan !== true && await resolvesInPlace(record.agentData, path)) await removeScratch(path, plugin); + if (!(await exists(path))) continue; + if (await ownsGrokBotSideloadPath(path, plugin) && await resolvesInPlace(record.agentData, path)) { + if (options.plan !== true) await rm(path, { force: true, recursive: true }); + directories.push(path); + } else { + retained.push(path); + if (path === record.pluginPath) pluginFolderOurs = false; + } + } + let manifest: string | undefined; + if (!keep.has(record.manifest) && pluginFolderOurs && await resolvesInPlace(record.agentData, record.manifest)) { + const document = await readManifest(record.manifest); + if (document !== undefined && sameEntry(listedEntry(document, plugin), record.entry)) { + // Re-validated on every attempt, against the manifest as it is right before the rename. + const removed = options.plan === true || await updateManifest(record.manifest, (latest) => + sameEntry(listedEntry(latest, plugin), record.entry) + ? latest.document.plugins.filter((_, position) => position !== entryIndex(latest, plugin)) + : undefined); + if (removed) manifest = record.manifest; + else retained.push(record.manifest); + } else if (document !== undefined && entryIndex(document, plugin) >= 0) { + retained.push(record.manifest); + } + } + const removing = new Set(directories); + for (const directory of [...record.createdDirectories].reverse()) { + if (keep.has(directory) || !(await resolvesInPlace(record.agentData, directory)) || !(await isEmptyDirectory(directory, removing))) continue; + if (options.plan !== true) await rmdir(directory).catch(() => undefined); + removing.add(directory); + directories.push(directory); + } + return Object.freeze({ + directories: Object.freeze(directories), + ...(manifest === undefined ? {} : { manifest }), + retained: Object.freeze(retained), + }); +}; + +interface SideloadTarget extends InstallReceiptGrokBotSideload { + readonly manifestDocument: ManifestDocument; + /** Older sideload copies of this plugin in the same cache folder (from an earlier ``). */ + readonly stale: readonly string[]; +} + +export type GrokBotSideloadPlan = + | Readonly<{ readonly reason: string; readonly state: 'skipped' }> + | Readonly<{ readonly state: 'ready'; readonly target: SideloadTarget }>; + +/** The manifest's `plugin` entry is exactly the one the previous sideload recorded writing there. */ +const ownsManifestEntry = ( + manifest: ManifestDocument, + plugin: string, + previous: InstallReceiptGrokBotSideload | undefined, +): boolean => previous?.manifest === manifest.path && sameEntry(listedEntry(manifest, plugin), previous.entry); + +const skipped = (reason: string): GrokBotSideloadPlan => Object.freeze({ reason, state: 'skipped' }); + +/** + * Read-only: where the sideload would write, or why it is skipped. Skips (never fails) when this computer has no + * Grok Bot data or clone of the marketplace, and when any target path or manifest entry belongs to someone else. + */ +export const planGrokBotSideload = async (options: { + readonly description?: string; + readonly environment: Readonly; + readonly home: string; + readonly plugin: string; + readonly previous?: InstallReceiptGrokBotSideload; + readonly settings: GrokBotSideloadSettings; +}): Promise => { + const { plugin, previous, settings } = options; + if (!settings.enabled) return skipped('disabled by --no-sideload or GROK_BOT_SIDELOAD.'); + if (!cacheSegmentPattern.test(plugin)) return skipped(`Grok Bot cache paths cannot spell the plugin name ${JSON.stringify(plugin)}.`); + const agentData = await findGrokBotAgentData(options.environment, options.home); + if (agentData === undefined) { + return skipped('no Grok Bot agent-data on this computer (GROK_BOT_AGENT_DATA_DIR, /home/box/agent-data, ' + + '~/.grokbot/agent-data, or ~/Library/Application Support/Grok Bot/agent-data).'); + } + const [owner, repo] = settings.repo.split('/') as [string, string]; + const repoRoot = join(agentData, 'plugins', 'marketplaces', 'github.com', owner, repo); + const active = await activeClone({ agentData, plugin, repoRoot, slug: settings.slug }); + if ('reason' in active) return skipped(active.reason); + const clone = join(repoRoot, active.commit); + let manifestDocument: ManifestDocument | undefined; + for (const path of marketplaceManifestPaths) { + manifestDocument = await readManifest(join(clone, path)); + if (manifestDocument !== undefined) break; + } + if (manifestDocument === undefined) return skipped(`the clone ${clone} has no readable marketplace manifest.`); + const metadata = manifestDocument.document['metadata']; + const pluginRoot = Predicate.isObject(metadata) && typeof metadata['pluginRoot'] === 'string' ? metadata['pluginRoot'] : ''; + const pluginPath = join(clone, pluginRoot, plugin); + if (!isInside(clone, pluginPath) || relative(clone, pluginPath).split(sep).includes('.git')) { + return skipped(`the manifest's pluginRoot ${JSON.stringify(pluginRoot)} leaves the clone ${clone}.`); + } + const ownsPluginPath = await ownsGrokBotSideloadPath(pluginPath, plugin); + if (!ownsPluginPath && await exists(pluginPath)) { + return skipped(`${pluginPath} already exists and was not written by agent-bundle (Grok Bot's copy or another plugin's); left untouched.`); + } + if (entryIndex(manifestDocument, plugin) >= 0 && !ownsManifestEntry(manifestDocument, plugin, previous)) { + return skipped(`${manifestDocument.path} already lists a plugin named ${plugin} that agent-bundle did not add; left untouched.`); + } + const cacheRoot = join(agentData, 'plugins', 'cache'); + const cachePluginRoot = join(cacheRoot, settings.slug, plugin); + const cachePath = join(cachePluginRoot, active.commit); + for (const path of [manifestDocument.path, pluginPath, cachePath]) { + if (!(await resolvesInPlace(agentData, path))) return skipped(`${path} resolves through a symbolic link; left untouched.`); + } + const stale: string[] = []; + for (const version of await listDirectory(cachePluginRoot)) { + const path = join(cachePluginRoot, version); + if (version.startsWith('.')) continue; + if (!(await ownsGrokBotSideloadPath(path, plugin))) { + return skipped(`Grok Bot already has its own copy of ${plugin} at ${path} (an account install); the sideload would shadow it, ` + + 'so it was skipped.'); + } + if (path !== cachePath) stale.push(path); + } + const keptCreated = (previous?.createdDirectories ?? []).filter((directory) => + isInside(directory, cachePath) || isInside(directory, pluginPath)); + const created = [...new Set([ + ...keptCreated, + ...await missingAncestors(cacheRoot, cachePath), + ...await missingAncestors(clone, pluginPath), + ])]; + const entry: Readonly> = Object.freeze({ + name: plugin, + source: plugin, + ...(options.description === undefined ? {} : { description: options.description }), + }); + return Object.freeze({ + state: 'ready' as const, + target: Object.freeze({ + agentData, + cachePath, + commit: active.commit, + createdDirectories: Object.freeze(created), + entry, + manifest: manifestDocument.path, + manifestDocument, + pluginPath, + repo: settings.repo, + slug: settings.slug, + stale: Object.freeze(stale), + }), + }); +}; + +export const grokBotSideloadRecord = (target: SideloadTarget): InstallReceiptGrokBotSideload => Object.freeze({ + agentData: target.agentData, + cachePath: target.cachePath, + commit: target.commit, + createdDirectories: target.createdDirectories, + entry: target.entry, + manifest: target.manifest, + pluginPath: target.pluginPath, + repo: target.repo, + slug: target.slug, +}); + +const sameLocation = (left: InstallReceiptGrokBotSideload, right: InstallReceiptGrokBotSideload): boolean => + left.cachePath === right.cachePath && left.pluginPath === right.pluginPath && left.manifest === right.manifest; + +/** + * Writes a ready plan: the cache copy (with `.cache-complete`), the clone folder, then the manifest entry. + * `unchanged` when the previous sideload is already exactly this content at this location. `progress.started` + * turns true before the first write, so a caller that fails afterwards knows to `rollbackGrokBotSideload`. + * Superseded copies are left for `finishGrokBotSideload`, once the receipt names the new location. + */ +export const applyGrokBotSideload = async (options: { + readonly artifact: TreeInventory; + readonly bundleRoot: string; + readonly plugin: string; + readonly previous?: InstallReceiptGrokBotSideload; + readonly previousContentHash?: string; + readonly progress: { started: boolean }; + readonly target: SideloadTarget; +}): Promise => { + const { artifact, plugin, previous, target } = options; + const record = grokBotSideloadRecord(target); + // Re-read the manifest now: Grok Bot may have rewritten it since planning. + const current = await currentManifest(target, plugin, previous); + if ( + previous !== undefined && + sameLocation(previous, record) && + sameEntry(previous.entry, record.entry) && + options.previousContentHash === artifact.hash && + target.stale.length === 0 && + await ownsGrokBotSideloadPath(target.pluginPath, plugin) && + await ownsGrokBotSideloadPath(target.cachePath, plugin) && + await exists(join(target.cachePath, cacheCompleteFile)) && + sameEntry(listedEntry(current, plugin), record.entry) + ) { + return Object.freeze({ ...record, state: 'unchanged' }); + } + options.progress.started = true; + const write = { agentData: target.agentData, artifact, bundleRoot: options.bundleRoot, plugin }; + await writeTree({ ...write, cacheComplete: true, destination: target.cachePath }); + await writeTree({ ...write, cacheComplete: false, destination: target.pluginPath }); + // Validated again on every attempt against the manifest as it is right before the rename. + await updateManifest(target.manifest, (latest) => { + assertEntryOwnable(latest, target, plugin, previous); + const plugins = [...latest.document.plugins]; + const listedAt = entryIndex(latest, plugin); + if (listedAt >= 0) plugins[listedAt] = target.entry; + else plugins.push(target.entry); + return plugins; + }); + return Object.freeze({ ...record, state: 'written' }); +}; + +/** The target manifest as it is now, refusing when its `plugin` entry has become one agent-bundle did not write. */ +const currentManifest = async ( + target: SideloadTarget, + plugin: string, + previous: InstallReceiptGrokBotSideload | undefined, +): Promise => { + if (!(await resolvesInPlace(target.agentData, target.manifest))) { + throw failure(`${target.manifest} resolves through a symbolic link; sideload refused.`); + } + const manifest = await readManifest(target.manifest); + if (manifest === undefined) throw failure(`${target.manifest} is no longer a readable marketplace manifest; sideload refused.`); + assertEntryOwnable(manifest, target, plugin, previous); + return manifest; +}; + +/** Refuses when the manifest's `plugin` entry is neither absent, the one about to be written, nor the recorded one. */ +const assertEntryOwnable = ( + manifest: ManifestDocument, + target: SideloadTarget, + plugin: string, + previous: InstallReceiptGrokBotSideload | undefined, +): void => { + const listed = listedEntry(manifest, plugin); + if (listed !== undefined && !sameEntry(listed, target.entry) && !ownsManifestEntry(manifest, plugin, previous)) { + throw failure(`${target.manifest} gained a plugin named ${plugin} that agent-bundle did not add; left untouched.`); + } +}; + +/** + * After the receipt names the new sideload: deletes this plugin's older sideload copies (an earlier ``) and + * whatever of the previous record the new one does not reuse. + */ +export const finishGrokBotSideload = async (options: { + readonly plugin: string; + readonly previous?: InstallReceiptGrokBotSideload; + readonly target: SideloadTarget; +}): Promise => { + const { plugin, previous, target } = options; + for (const path of target.stale) { + if (await ownsGrokBotSideloadPath(path, plugin) && await resolvesInPlace(target.agentData, path)) { + await rm(path, { force: true, recursive: true }); + } + } + const record = grokBotSideloadRecord(target); + if (previous !== undefined && !sameLocation(previous, record)) { + await removeGrokBotSideload(previous, plugin, { + keep: new Set([record.cachePath, record.pluginPath, record.manifest, ...record.createdDirectories]), + }); + } +}; + +/** + * Undoes a failed `applyGrokBotSideload` before any receipt names it: removes the target's marked folders, its entry + * (only while it is exactly the one written), and created parents, keeping whatever the previous record names at a + * different location. Writing replaced any previous copy at the same location in place, and no copy is kept to put + * back, so the previous receipt can then name absent paths: Doctor reports that and rerunning restores it. + */ +export const rollbackGrokBotSideload = async (options: { + readonly plugin: string; + readonly previous?: InstallReceiptGrokBotSideload; + readonly target: SideloadTarget; +}): Promise => { + const { plugin, previous, target } = options; + const record = grokBotSideloadRecord(target); + const keep = previous === undefined || sameLocation(previous, record) + ? new Set() + : new Set([previous.cachePath, previous.pluginPath, ...previous.createdDirectories]); + await removeGrokBotSideload(record, plugin, { keep }); +}; + +/** How a recorded sideload looks to Grok Bot right now (Doctor). */ +export interface GrokBotSideloadInspection { + readonly cacheCopy: boolean; + readonly cloneActive: boolean; + readonly listed: boolean; + /** Grok Bot's plugin index names the cache copy: the account lists the plugin and Grok Bot loaded this copy. */ + readonly loaded: boolean; + readonly pluginFolder: boolean; +} + +export const inspectGrokBotSideload = async ( + record: InstallReceiptGrokBotSideload, + plugin: string, +): Promise => { + const index = await readJsonFile(join(record.agentData, 'plugin-skills', 'cache.json')); + const key = pluginCacheKey(record.cachePath); + const folders = Predicate.isObject(index) && Array.isArray(index['installFolders']) ? index['installFolders'] as unknown[] : []; + const manifest = await readManifest(record.manifest); + const [owner = '', repo = ''] = record.repo.split('/'); + return Object.freeze({ + cacheCopy: await ownsGrokBotSideloadPath(record.cachePath, plugin) && await exists(join(record.cachePath, cacheCompleteFile)), + cloneActive: await exists(join(record.agentData, 'plugins', 'marketplaces', 'github.com', owner, repo, record.commit, '.git')), + listed: manifest !== undefined && sameEntry(listedEntry(manifest, plugin), record.entry), + loaded: key !== undefined && folders.some((folder) => + Predicate.isObject(folder) && typeof folder['installPath'] === 'string' && pluginCacheKey(folder['installPath']) === key), + pluginFolder: await ownsGrokBotSideloadPath(record.pluginPath, plugin), + }); +}; + +/** + * The first sideload release (#875) kept its own record at `/agent-bundle/sideload/.json` + * and wrote unmarked folders. That record is the only proof those folders are agent-bundle's, so install and + * uninstall retire it: each recorded folder is removed when its path is exactly the layout that release wrote and it + * still holds this plugin's Cursor manifest, and a recorded `addedEntry` is removed only while it is still the + * `{ name, source, description }` entry that release wrote. Then the record itself goes. + */ +export const legacyGrokBotSideloadRecordPath = (root: string, plugin: string): string => + join(root, 'agent-bundle', 'sideload', `${plugin}.json`); + +const legacyEntryShape = (entry: unknown, plugin: string): boolean => + Predicate.isObject(entry) && entry['name'] === plugin && entry['source'] === plugin && + Object.keys(entry).every((key) => key === 'name' || key === 'source' || (key === 'description' && typeof entry[key] === 'string')); + +const holdsPlugin = async (path: string, plugin: string): Promise => { + if (!(await isDirectory(path))) return false; + const manifest = await readJsonFile(join(path, '.cursor-plugin', 'plugin.json')); + return Predicate.isObject(manifest) && manifest['name'] === plugin; +}; + +export const removeLegacyGrokBotSideload = async ( + root: string, + plugin: string, + options: { readonly plan?: boolean } = {}, +): Promise => { + const recordPath = legacyGrokBotSideloadRecordPath(root, plugin); + const record = await readJsonFile(recordPath); + // Exactly the schema #875 wrote, for this plugin: anything else authorizes nothing. + if (!Predicate.isObject(record) || record['plugin'] !== plugin || !Array.isArray(record['entries'])) { + return Object.freeze({ directories: Object.freeze([]), retained: Object.freeze([]) }); + } + const directories: string[] = []; + const retained: string[] = []; + let manifest: string | undefined; + for (const entry of record['entries'] as unknown[]) { + if (!Predicate.isObject(entry)) continue; + const { addedEntry, cachePath, commit, marketplace, pluginPath } = entry; + if ( + typeof cachePath !== 'string' || typeof pluginPath !== 'string' || typeof commit !== 'string' || + typeof marketplace !== 'string' || typeof addedEntry !== 'boolean' + ) { + continue; + } + const segments = marketplace.split('/'); + if (segments.length !== 2 || segments.some((segment) => !repoSegmentPattern.test(segment) || /^\.+$/u.test(segment))) continue; + const [owner, repo] = segments as [string, string]; + if (!commitPattern.test(commit) || !isAbsolute(cachePath) || normalize(cachePath) !== cachePath) continue; + // `/plugins/cache/-//`, so agent-data is five levels up. + const agentData = dirname(dirname(dirname(dirname(dirname(cachePath))))); + const clone = join(agentData, 'plugins', 'marketplaces', 'github.com', owner, repo, commit); + if ( + cachePath !== join(agentData, 'plugins', 'cache', `${owner}-${repo}`, plugin, commit) || + pluginPath !== join(clone, plugin) + ) { + continue; + } + for (const path of [cachePath, pluginPath]) { + if (!(await exists(path))) continue; + if (await holdsPlugin(path, plugin) && await resolvesInPlace(agentData, path)) { + if (options.plan !== true) await rm(path, { force: true, recursive: true }); + directories.push(path); + } else { + retained.push(path); + } + } + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + if (addedEntry !== true || !(await resolvesInPlace(agentData, manifestPath))) continue; + const document = await readManifest(manifestPath); + if (document === undefined || !legacyEntryShape(listedEntry(document, plugin), plugin)) continue; + const removed = options.plan === true || await updateManifest(manifestPath, (latest) => + legacyEntryShape(listedEntry(latest, plugin), plugin) + ? latest.document.plugins.filter((_, position) => position !== entryIndex(latest, plugin)) + : undefined); + if (removed) manifest = manifestPath; + } + if (options.plan !== true) await rm(recordPath, { force: true }); + return Object.freeze({ + directories: Object.freeze(directories), + ...(manifest === undefined ? {} : { manifest }), + record: recordPath, + retained: Object.freeze(retained), + }); +}; diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts index 232a137f9..f180ba0c5 100644 --- a/packages/agent-bundle/src/install/grokbot.ts +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -1,18 +1,28 @@ -import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; +import { rename, rm } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { dirname, join } from 'node:path'; +import { join } from 'node:path'; import { Predicate } from 'effect'; import { DiagnosticError } from '../core/diagnostics.ts'; -import { errorMessage, isErrno } from '../core/errors.ts'; +import { errorMessage } from '../core/errors.ts'; import { exists } from '../core/paths.ts'; import { cursorMarketplaceRoot, stageCursorMarketplace } from './cursor-marketplace.ts'; +import { findGrokBotAgentData, listDirectory, pluginCacheKey, readJsonFile } from './grokbot-agent-data.ts'; +import { + type GrokBotSideloadResult, + applyGrokBotSideload, + finishGrokBotSideload, + ownsGrokBotSideloadPath, + planGrokBotSideload, + removeLegacyGrokBotSideload, + resolveGrokBotSideloadSettings, + rollbackGrokBotSideload, +} from './grokbot-sideload.ts'; import { bundleInventory, readBundleIdentity } from './identity.ts'; import { type InstallReceipt, - type TreeInventory, - copyInventoryFiles, + type InstallReceiptGrokBotSideload, createInstallReceipt, installReceiptStorePath, readInstallReceiptFile, @@ -35,9 +45,10 @@ import type { InstallBundleOptions, InstallCommandRunner, InstallResult } from ' * `grokbot://app/v1/plugin/add?id=` link, all keyed by that * server-assigned id. There is no local-folder import and no CLI verb, so the * installer stages a committed marketplace repository built from the Cursor - * projection, records a receipt, prints the exact remaining steps, and (on a - * Grok Bot computer) sideloads the plugin into a marketplace snapshot Grok Bot - * already syncs; see `sideloadGrokBot` below. + * projection, records a receipt, and prints the exact remaining steps. When + * this computer has Grok Bot data and a clone of the configured marketplace, + * it also sideloads the plugin into that clone and Grok Bot's plugin cache + * (install/grokbot-sideload.ts, which documents what Grok Bot's sync keeps). * Doctor reads the cache and skill index read-only to report the plugin id * and the commit Grok Bot installed. */ @@ -128,6 +139,17 @@ const supersedeOwnedStaging = async (options: { return aside; }; +const withoutState = (result: InstallReceiptGrokBotSideload & { readonly state: string }): InstallReceiptGrokBotSideload => { + const { state: _state, ...record } = result; + return record; +}; + +export const grokBotSideloadStep = (plugin: string, sideload: InstallReceiptGrokBotSideload): string => + `Sideloaded into Grok Bot's ${sideload.repo} marketplace clone @ ${sideload.commit} (${sideload.pluginPath}, listed in ` + + `${sideload.manifest}) and its plugin cache (${sideload.cachePath}). Grok Bot loads plugins only from its account ` + + `plugin listing: until that lists "${plugin}" from ${sideload.slug}, its next plugin sync removes the cache copy, and a ` + + 'new marketplace commit replaces the clone folder. Rerun this install to restore both; `doctor --host grokbot` reports which remain.'; + /** * Stages the Cursor projection as a committed marketplace repository under * `/agent-bundle/marketplaces/` and writes a store @@ -156,10 +178,27 @@ export const installGrokBot = async ( throw error; }); const root = grokBotRoot(options); + const environment = options.environment ?? process.env; + const settings = resolveGrokBotSideloadSettings(options, environment); try { const artifact = await bundleInventory(identity, { restoreModes: true }); const receiptPath = grokBotReceiptPath(root, identity.plugin); const previousReceipt = await readInstallReceiptFile(receiptPath); + const previousSideload = previousReceipt?.plugin === identity.plugin ? previousReceipt.grokBotSideload : undefined; + const manifest = await readJsonFile(join(identity.bundleRoot, '.cursor-plugin', 'plugin.json')); + const description = Predicate.isObject(manifest) && typeof manifest['description'] === 'string' ? manifest['description'] : undefined; + // The first sideload release kept a separate record and wrote unmarked folders: retire them so this sideload can + // take their place under its own markers and receipt. + const retired = settings.enabled ? await removeLegacyGrokBotSideload(root, identity.plugin) : undefined; + // Read-only, before anything is staged: where the sideload will write, or why it is skipped. + const plan = await planGrokBotSideload({ + ...(description === undefined ? {} : { description }), + environment, + home: options.home ?? homedir(), + plugin: identity.plugin, + ...(previousSideload === undefined ? {} : { previous: previousSideload }), + settings, + }); const repoRoot = join(grokBotMarketplaceRoot(root), identity.plugin); const superseded = await supersedeOwnedStaging({ artifact, identity, options, previousReceipt, repoRoot, runner }); let staged: Awaited>; @@ -169,30 +208,60 @@ export const installGrokBot = async ( if (superseded !== undefined) await rename(superseded, repoRoot); throw error; } + let sideload: GrokBotSideloadResult; + const progress = { started: false }; try { + sideload = plan.state === 'ready' + ? await applyGrokBotSideload({ + artifact, + bundleRoot: identity.bundleRoot, + plugin: identity.plugin, + ...(previousSideload === undefined ? {} : { previous: previousSideload }), + ...(previousReceipt === undefined ? {} : { previousContentHash: previousReceipt.contentHash }), + progress, + target: plan.target, + }) + : plan; + // A skipped sideload keeps the previous record: whatever it wrote may still exist, and uninstall must find it. + const sideloadRecord = sideload.state === 'skipped' ? previousSideload : withoutState(sideload); if ( staged.state === 'staged' || previousReceipt === undefined || previousReceipt.contentHash !== artifact.hash || - previousReceipt.registrations[0]?.commit !== staged.commit + previousReceipt.registrations[0]?.commit !== staged.commit || + JSON.stringify(previousReceipt.grokBotSideload) !== JSON.stringify(sideloadRecord) ) { await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + ...(sideloadRecord === undefined ? {} : { grokBotSideload: sideloadRecord }), host: grokBotHost, ...(previousReceipt === undefined ? {} : { installedAt: previousReceipt.installedAt }), inventory: { files: [], hash: artifact.hash }, mode: 'marketplace', plugin: identity.plugin, - registrations: [{ - ...(staged.commit === undefined ? {} : { commit: staged.commit }), - kind: 'grokbot-marketplace-staging', - name: staged.marketplace, - }], + registrations: [ + { + ...(staged.commit === undefined ? {} : { commit: staged.commit }), + kind: 'grokbot-marketplace-staging', + name: staged.marketplace, + }, + ...(sideloadRecord === undefined + ? [] + : [{ commit: sideloadRecord.commit, kind: 'grokbot-sideload' as const, name: sideloadRecord.slug }]), + ], scope: 'user', updatedAt: new Date().toISOString(), version: identity.version, })); } } catch (error) { + // Nothing the receipt does not name stays behind: undo a sideload written before the failure. + if (plan.state === 'ready' && progress.started) { + await rollbackGrokBotSideload({ + plugin: identity.plugin, + ...(previousSideload === undefined ? {} : { previous: previousSideload }), + target: plan.target, + }); + } // Keep the receipt and the repository it names consistent: put the superseded staging back. if (superseded !== undefined) { await rm(staged.destination, { force: true, recursive: true }); @@ -201,7 +270,18 @@ export const installGrokBot = async ( throw error; } if (superseded !== undefined) await rm(superseded, { force: true, recursive: true }); - const sideload = await sideloadGrokBot({ artifact, identity, options, root }); + const cleanup: string[] = []; + if (plan.state === 'ready' && sideload.state === 'written') { + // The receipt now names the new sideload, so the copies it supersedes can go; a failure here strands only + // marked copies the next install or Grok Bot's own pruning removes. + await finishGrokBotSideload({ + plugin: identity.plugin, + ...(previousSideload === undefined ? {} : { previous: previousSideload }), + target: plan.target, + }).catch((error: unknown) => { + cleanup.push(`Could not remove the superseded Grok Bot sideload copies (${errorMessage(error)}); rerun this install to retry.`); + }); + } return { bundleRoot: identity.bundleRoot, ...(staged.commit === undefined ? {} : { commit: staged.commit }), @@ -210,13 +290,20 @@ export const installGrokBot = async ( host: grokBotHost, marketplace: staged.marketplace, mode: 'marketplace', - nextSteps: sideload.length === 0 - ? grokBotNextSteps(staged.destination, identity.plugin) - : [...sideload.map(sideloadStep), ...grokBotNextSteps(staged.destination, identity.plugin)], - ...(sideload.length === 0 ? {} : { sideload }), + nextSteps: [ + ...(sideload.state === 'skipped' ? [] : [grokBotSideloadStep(identity.plugin, sideload)]), + ...(retired?.record === undefined + ? [] + : [`Retired the earlier sideload record ${retired.record}` + + `${retired.directories.length === 0 ? '' : ` and removed ${retired.directories.join(', ')}`}` + + `${retired.retained.length === 0 ? '' : `; kept ${retired.retained.join(', ')} (no longer this plugin's copy)`}.`]), + ...cleanup, + ...grokBotNextSteps(staged.destination, identity.plugin), + ], plugin: identity.plugin, ...(superseded === undefined || previousReceipt === undefined ? {} : { previousContentHash: previousReceipt.contentHash }), receipt: receiptPath, + sideload, state: superseded === undefined ? staged.state : 'replaced', version: identity.version, }; @@ -232,17 +319,6 @@ export const installGrokBot = async ( } }; -/** Where a Grok Bot computer keeps its data, most specific first (`GROK_BOT_AGENT_DATA_DIR` overrides). */ -export const grokBotAgentDataCandidates = ( - environment: Readonly, - home: string, -): readonly string[] => Object.freeze([ - ...(environment['GROK_BOT_AGENT_DATA_DIR'] === undefined ? [] : [environment['GROK_BOT_AGENT_DATA_DIR']]), - '/home/box/agent-data', - join(home, '.grokbot', 'agent-data'), - join(home, 'Library', 'Application Support', 'Grok Bot', 'agent-data'), -]); - /** One copy of the plugin Grok Bot cloned, and the server-assigned id when its skill index names it. */ export interface GrokBotInstalledPlugin { /** `/plugins/cache///`. */ @@ -261,33 +337,6 @@ export type GrokBotInventory = | Readonly<{ readonly agentData: string; readonly entries: readonly GrokBotInstalledPlugin[]; readonly status: 'available' }> | Readonly<{ readonly reason: string; readonly status: 'unavailable' }>; -const directoryExists = async (path: string): Promise => { - try { - return (await stat(path)).isDirectory(); - } catch (error) { - if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return false; - throw error; - } -}; - -const readJsonFile = async (path: string): Promise => { - try { - return JSON.parse(await readFile(path, 'utf8')) as unknown; - } catch (error) { - if (isErrno(error, 'ENOENT') || isErrno(error, 'EACCES') || error instanceof SyntaxError) return undefined; - throw error; - } -}; - -const listDirectory = async (path: string): Promise => { - try { - return (await readdir(path)).sort(); - } catch (error) { - if (isErrno(error, 'ENOENT') || isErrno(error, 'ENOTDIR') || isErrno(error, 'EACCES')) return []; - throw error; - } -}; - /** `//` -> plugin id, from the skill index Grok Bot keeps for installed plugins. */ const skillIndexIds = (document: unknown): ReadonlyMap => { const ids = new Map(); @@ -296,32 +345,22 @@ const skillIndexIds = (document: unknown): ReadonlyMap => { if (!Predicate.isObject(skill)) continue; const { installPath, pluginId } = skill; if (typeof installPath !== 'string' || typeof pluginId !== 'string') continue; - // The index may record the legacy `sand-data` root, so only the cache-relative tail identifies the copy. - const segments = installPath.split(/[\\/]/u).filter((segment) => segment !== ''); - const cache = segments.lastIndexOf('cache'); - if (cache < 1 || segments[cache - 1] !== 'plugins' || segments.length !== cache + 4) continue; - ids.set(segments.slice(cache + 1).join('/'), pluginId); + const key = pluginCacheKey(installPath); + if (key !== undefined) ids.set(key, pluginId); } return ids; }; /** * Every completed copy of `plugin` in Grok Bot's plugin cache, from any - * marketplace, with the plugin id the skill index assigns it. Read-only. + * marketplace, with the plugin id the skill index assigns it. A sideload copy + * counts only once Grok Bot's index names it. Read-only. */ export const readGrokBotInventory = async ( plugin: string, options: { readonly environment?: Readonly; readonly home?: string } = {}, ): Promise => { - const environment = options.environment ?? process.env; - const home = options.home ?? homedir(); - let agentData: string | undefined; - for (const candidate of grokBotAgentDataCandidates(environment, home)) { - if (await directoryExists(join(candidate, 'plugins'))) { - agentData = candidate; - break; - } - } + const agentData = await findGrokBotAgentData(options.environment ?? process.env, options.home ?? homedir()); if (agentData === undefined) { return Object.freeze({ reason: 'No Grok Bot plugin cache on this computer (set GROK_BOT_AGENT_DATA_DIR, or run doctor on the Grok Bot computer).', @@ -339,6 +378,8 @@ export const readGrokBotInventory = async ( const manifest = await readJsonFile(join(installPath, '.cursor-plugin', 'plugin.json')); if (!Predicate.isObject(manifest) || manifest['name'] !== plugin) continue; const pluginId = ids.get(`${marketplace}/${plugin}/${version}`); + // `install grokbot`'s own sideload copy is not an install Grok Bot made, until Grok Bot indexes it. + if (pluginId === undefined && await ownsGrokBotSideloadPath(installPath, plugin)) continue; entries.push(Object.freeze({ installPath, marketplace, @@ -350,187 +391,3 @@ export const readGrokBotInventory = async ( } return Object.freeze({ agentData, entries: Object.freeze(entries), status: 'available' }); }; - -/** - * Unofficial sideload into a marketplace Grok Bot already syncs. - * - * Grok Bot keeps a sparse snapshot of each account marketplace under - * `/plugins/marketplaces/github.com////` and a - * completed copy of every installed plugin under - * `/plugins/cache/-///`. When the bundle's - * repository owner (or `GROK_BOT_SIDELOAD_MARKETPLACE=owner/repo[,owner/repo]`) - * names a marketplace that snapshot exists for, the installer mirrors the - * plugin into it exactly as Grok Bot lays out its official plugins: the plugin - * folder beside its siblings, an entry in that snapshot's - * `.cursor-plugin/marketplace.json`, and a cache copy with `.cache-complete`. - * The plugin id stays server-assigned: until the hosted marketplace lists the - * plugin and the account installs it, Grok Bot's sync may prune these files, - * and re-running the installer restores them. `GROK_BOT_SIDELOAD=0` disables it. - * A record under `/agent-bundle/sideload/.json` lets - * uninstall remove exactly what was written. - */ -export interface GrokBotSideloadEntry { - /** `/` of the Grok Bot marketplace snapshot. */ - readonly marketplace: string; - /** Snapshot commit the plugin was mirrored under. */ - readonly commit: string; - /** `/`. */ - readonly pluginPath: string; - /** `/plugins/cache/-//`. */ - readonly cachePath: string; - /** Whether this install added the plugin to the snapshot's marketplace.json (false when already listed). */ - readonly addedEntry: boolean; -} - -interface GrokBotSideloadRecord { - readonly entries: readonly GrokBotSideloadEntry[]; - readonly plugin: string; - readonly version: string; -} - -export const grokBotSideloadRecordPath = (root: string, plugin: string): string => - join(root, 'agent-bundle', 'sideload', `${plugin}.json`); - -const sideloadStep = (entry: GrokBotSideloadEntry): string => - `Sideloaded into Grok Bot's ${entry.marketplace} marketplace snapshot @ ${entry.commit}: ${entry.pluginPath} ` + - `and ${entry.cachePath} (unofficial; Grok Bot assigns the plugin id once the hosted marketplace lists the plugin).`; - -const repositoryOwner = (repository: unknown): string | undefined => { - const url = typeof repository === 'string' - ? repository - : Predicate.isObject(repository) && typeof repository['url'] === 'string' ? repository['url'] : undefined; - return url?.match(/github\.com[/:]([^/]+)\//iu)?.[1]?.toLowerCase(); -}; - -const readSideloadRecord = async (path: string): Promise => { - const document = await readJsonFile(path); - if (!Predicate.isObject(document) || !Array.isArray(document['entries'])) return undefined; - return document as unknown as GrokBotSideloadRecord; -}; - -const grokBotAgentData = async ( - environment: Readonly, - home: string, -): Promise => { - for (const candidate of grokBotAgentDataCandidates(environment, home)) { - if (await directoryExists(join(candidate, 'plugins'))) return candidate; - } - return undefined; -}; - -/** `[owner, repo]` marketplace snapshots to sideload into, from the override or the bundle's repository owner. */ -const sideloadTargets = async ( - marketplacesRoot: string, - environment: Readonly, - bundleRoot: string, -): Promise => { - const override = environment['GROK_BOT_SIDELOAD_MARKETPLACE']; - if (override !== undefined && override.trim() !== '') { - return override.split(',').map((value) => value.trim().toLowerCase().split('/')) - .filter((parts): parts is [string, string] => parts.length === 2 && parts[0] !== '' && parts[1] !== '') - .map(([owner, repo]) => [owner, repo] as const); - } - const manifest = await readJsonFile(join(bundleRoot, '.cursor-plugin', 'plugin.json')); - const owner = Predicate.isObject(manifest) ? repositoryOwner(manifest['repository']) : undefined; - if (owner === undefined) return []; - return (await listDirectory(join(marketplacesRoot, owner))).map((repo) => [owner, repo] as const); -}; - -const marketplaceManifestFile = (snapshot: string): string => join(snapshot, '.cursor-plugin', 'marketplace.json'); - -const writeTree = async (bundleRoot: string, destination: string, artifact: TreeInventory): Promise => { - const stage = `${destination}.agent-bundle-stage-${process.pid}`; - await rm(stage, { force: true, recursive: true }); - await copyInventoryFiles(bundleRoot, stage, artifact); - await rm(destination, { force: true, recursive: true }); - await mkdir(dirname(destination), { recursive: true }); - await rename(stage, destination); -}; - -const sideloadGrokBot = async (options: { - readonly artifact: TreeInventory; - readonly identity: { readonly bundleRoot: string; readonly plugin: string; readonly version: string }; - readonly options: InstallBundleOptions; - readonly root: string; -}): Promise => { - const environment = options.options.environment ?? process.env; - if (environment['GROK_BOT_SIDELOAD'] === '0') return []; - const agentData = await grokBotAgentData(environment, options.options.home ?? homedir()); - if (agentData === undefined) return []; - const { artifact, identity } = options; - const marketplacesRoot = join(agentData, 'plugins', 'marketplaces', 'github.com'); - const recordPath = grokBotSideloadRecordPath(options.root, identity.plugin); - const previous = await readSideloadRecord(recordPath); - const owned = new Set(previous?.entries.flatMap((entry) => [entry.pluginPath, entry.cachePath]) ?? []); - const previouslyAdded = new Set(previous?.entries.filter((entry) => entry.addedEntry).map((entry) => entry.pluginPath) ?? []); - const description = await readManifestDescription(identity.bundleRoot); - const entries: GrokBotSideloadEntry[] = []; - for (const [owner, repo] of await sideloadTargets(marketplacesRoot, environment, identity.bundleRoot)) { - for (const commit of await listDirectory(join(marketplacesRoot, owner, repo))) { - const snapshot = join(marketplacesRoot, owner, repo, commit); - const manifestPath = marketplaceManifestFile(snapshot); - const manifest = await readJsonFile(manifestPath); - if (!Predicate.isObject(manifest) || !Array.isArray(manifest['plugins'])) continue; - const plugins = manifest['plugins'] as unknown[]; - const pluginPath = join(snapshot, identity.plugin); - const listed = plugins.some((entry) => Predicate.isObject(entry) && entry['name'] === identity.plugin); - // The hosted marketplace already ships this plugin at this commit: Grok Bot owns it, leave it alone. - if (listed && !owned.has(pluginPath) && await exists(pluginPath)) continue; - const cachePath = join(agentData, 'plugins', 'cache', `${owner}-${repo}`, identity.plugin, commit); - if (!owned.has(cachePath) && await exists(join(cachePath, '.cache-complete'))) continue; - await writeTree(identity.bundleRoot, pluginPath, artifact); - if (!listed) { - plugins.push({ name: identity.plugin, source: identity.plugin, ...(description === undefined ? {} : { description }) }); - await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); - } - await writeTree(identity.bundleRoot, cachePath, artifact); - await writeFile(join(cachePath, '.cache-complete'), ''); - entries.push(Object.freeze({ - addedEntry: !listed || previouslyAdded.has(pluginPath), - cachePath, - commit, - marketplace: `${owner}/${repo}`, - pluginPath, - })); - } - } - if (entries.length > 0 || previous !== undefined) { - await mkdir(dirname(recordPath), { recursive: true }); - await writeFile(recordPath, `${JSON.stringify({ entries, plugin: identity.plugin, version: identity.version }, null, 2)}\n`); - } - return Object.freeze(entries); -}; - -const readManifestDescription = async (bundleRoot: string): Promise => { - const manifest = await readJsonFile(join(bundleRoot, '.cursor-plugin', 'plugin.json')); - return Predicate.isObject(manifest) && typeof manifest['description'] === 'string' ? manifest['description'] : undefined; -}; - -/** Removes what `install grokbot` sideloaded (plugin folders, marketplace entries it added, cache copies) and its record. */ -export const removeGrokBotSideload = async (root: string, plugin: string): Promise => { - const recordPath = grokBotSideloadRecordPath(root, plugin); - const record = await readSideloadRecord(recordPath); - if (record === undefined) return []; - const removed: string[] = []; - for (const entry of record.entries) { - if (entry.addedEntry) { - const manifestPath = marketplaceManifestFile(dirname(entry.pluginPath)); - const manifest = await readJsonFile(manifestPath); - if (Predicate.isObject(manifest) && Array.isArray(manifest['plugins'])) { - const plugins = manifest['plugins'] as unknown[]; - const kept = plugins.filter((item) => !(Predicate.isObject(item) && item['name'] === plugin)); - if (kept.length !== plugins.length) { - await writeFile(manifestPath, `${JSON.stringify({ ...manifest, plugins: kept }, null, 2)}\n`); - } - } - } - for (const path of [entry.pluginPath, entry.cachePath]) { - if (await exists(path)) { - await rm(path, { force: true, recursive: true }); - removed.push(path); - } - } - } - await rm(recordPath, { force: true }); - return Object.freeze(removed); -}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 831344e4b..c9d6ac870 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,7 +12,8 @@ import { runPromise } from '../effect/boundary.ts'; import { liftPromise, type LiftedRejection } from '../effect/lift.ts'; import { claudePluginRowErrors } from '../host-contracts/claude-plugin-validation.ts'; import { stageCursorMarketplace } from './cursor-marketplace.ts'; -import { type GrokBotSideloadEntry, installGrokBot } from './grokbot.ts'; +import { installGrokBot } from './grokbot.ts'; +import type { GrokBotSideloadOptions, GrokBotSideloadResult } from './grokbot-sideload.ts'; import { bundleInventory, failure, @@ -75,7 +76,8 @@ export interface InstallCommandRunner { ): Promise; } -export interface InstallBundleOptions { +/** `sideload`, `sideloadRepo`, and `sideloadSlug` apply to the grokbot host only (install/grokbot-sideload.ts). */ +export interface InstallBundleOptions extends GrokBotSideloadOptions { readonly commandRunner?: InstallCommandRunner; /** Process environment consulted for host cache roots (`CODEX_HOME`); defaults to `process.env`. */ readonly environment?: Readonly; @@ -115,8 +117,8 @@ export interface InstallResult { * in the host root's `agent-bundle/receipts` store for host-CLI and marketplace deliveries (#101). */ readonly receipt?: string; - /** grokbot only: copies mirrored into Grok Bot's own marketplace snapshot and plugin cache (see install/grokbot.ts). */ - readonly sideload?: readonly GrokBotSideloadEntry[]; + /** grokbot only: what was sideloaded into Grok Bot's marketplace clone and plugin cache, or why it was skipped. */ + readonly sideload?: GrokBotSideloadResult; /** * `staged` means the marketplace repository is ready and Cursor's import step is still pending * (`mode: 'marketplace'`). @@ -1269,6 +1271,9 @@ const installProgram = Effect.fnUntraced(function*( options.host, )); } + if (options.sideload !== undefined || options.sideloadRepo !== undefined || options.sideloadSlug !== undefined) { + return yield* Effect.fail(failure('AB7003', 'Sideload options apply to the grokbot host only.', options.host)); + } const host = options.host; const identity = yield* liftPromise(() => readBundleIdentity(options.from, host)); switch (options.host) { diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 308613d64..39c46dc8b 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -15,7 +15,7 @@ import { rmdir, writeFile, } from 'node:fs/promises'; -import { basename, dirname, isAbsolute, join, resolve, sep } from 'node:path'; +import { basename, dirname, isAbsolute, join, normalize, resolve, sep } from 'node:path'; import { stableJson } from '../core/digest.ts'; import { isErrno } from '../core/errors.ts'; @@ -70,7 +70,8 @@ export type InstallRegistrationKind = | 'codex-plugin' | 'cursor-local-plugin' | 'cursor-marketplace-staging' - | 'grokbot-marketplace-staging'; + | 'grokbot-marketplace-staging' + | 'grokbot-sideload'; export const installRegistrationKinds: readonly InstallRegistrationKind[] = Object.freeze([ 'amp-project-plugin', @@ -82,10 +83,14 @@ export const installRegistrationKinds: readonly InstallRegistrationKind[] = Obje 'cursor-local-plugin', 'cursor-marketplace-staging', 'grokbot-marketplace-staging', + 'grokbot-sideload', ]); export interface InstallRegistration { - /** Staged marketplace HEAD commit (`cursor-marketplace-staging`, `grokbot-marketplace-staging`). */ + /** + * Staged marketplace HEAD commit (`cursor-marketplace-staging`, `grokbot-marketplace-staging`), or the Grok Bot + * marketplace clone commit the plugin was sideloaded under (`grokbot-sideload`). + */ readonly commit?: string; /** `@` for plugin registrations. */ readonly id?: string; @@ -97,6 +102,32 @@ export interface InstallRegistration { } +/** + * What `install grokbot` wrote into Grok Bot's own plugin directories (install/grokbot-sideload.ts), so + * `uninstall grokbot` removes exactly that: the plugin folder in the marketplace clone, its entry in the clone's + * marketplace manifest, the plugin-cache copy, and the parent directories the sideload created. + */ +export interface InstallReceiptGrokBotSideload { + /** The Grok Bot agent-data directory the paths below live in. */ + readonly agentData: string; + /** `/plugins/cache///`, written with `.cache-complete`. */ + readonly cachePath: string; + /** The marketplace clone commit (Grok Bot's active `` folder) the plugin was written under. */ + readonly commit: string; + /** Directories the sideload created (deepest last); uninstall removes them only when empty. */ + readonly createdDirectories: readonly string[]; + /** The exact `plugins` entry the sideload wrote; uninstall removes an entry only while it still equals this. */ + readonly entry: Readonly>; + /** The clone's marketplace manifest the sideload added a `plugins` entry to. */ + readonly manifest: string; + /** `/plugins/marketplaces/github.com////`. */ + readonly pluginPath: string; + /** `/` of the marketplace clone. */ + readonly repo: string; + /** Grok Bot's plugin-cache partition for that marketplace. */ + readonly slug: string; +} + /** * Recorded by the emitted `install.mjs` when it installs an Agent Plugins * pack into `~/.cursor/plugins/local`: Cursor 3.18.25 expands none of the @@ -142,6 +173,8 @@ export interface InstallReceiptState { export interface InstallReceipt { readonly contentHash: string; readonly cursorExpansion?: InstallReceiptCursorExpansion; + /** grokbot only: what the installer sideloaded into Grok Bot's marketplace clone and plugin cache. */ + readonly grokBotSideload?: InstallReceiptGrokBotSideload; /** * Directories the installer created (POSIX-relative, sorted). Only these * are ever pruned when they empty out; a directory that existed before the @@ -639,6 +672,76 @@ const readReceiptState = (value: unknown): InstallReceiptState | undefined => { }); }; +const grokBotCommitPattern = /^[0-9a-f]{40}$/u; +const grokBotRepoSegmentPattern = /^[a-z0-9_.-]+$/u; +const grokBotCacheSegmentPattern = /^[A-Za-z0-9_-]+$/u; + +/** `path` is strictly below `root` (both already normalized). */ +const isStrictlyAbove = (root: string, path: string): boolean => path.startsWith(`${root}${sep}`); + +const isNormalAbsolute = (value: unknown): value is string => + typeof value === 'string' && isAbsolute(value) && normalize(value) === value && !value.endsWith(sep); + +/** + * A sideload record authorizes recursive deletion, so it is accepted only when every path is exactly the one the + * installer derives from `agentData`, `repo`, `slug`, `commit`, and the receipt plugin: the cache copy + * `/plugins/cache///`, a `` folder inside the clone + * `/plugins/marketplaces/github.com///`, that clone's Cursor or Claude marketplace + * manifest, and created directories that are strict ancestors of those two folders inside the cache or the clone. + */ +export const readGrokBotSideload = (value: unknown, plugin: string): InstallReceiptGrokBotSideload | undefined => { + if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; + const record = value as Record; + const { agentData, cachePath, commit, createdDirectories, entry, manifest, pluginPath, repo, slug } = record; + if ( + !grokBotCacheSegmentPattern.test(plugin) || + !isNormalAbsolute(agentData) || + !isNormalAbsolute(cachePath) || + !isNormalAbsolute(manifest) || + !isNormalAbsolute(pluginPath) || + typeof commit !== 'string' || !grokBotCommitPattern.test(commit) || + typeof slug !== 'string' || !grokBotCacheSegmentPattern.test(slug) || + typeof repo !== 'string' || + !Array.isArray(createdDirectories) || + !createdDirectories.every(isNormalAbsolute) || + entry === null || typeof entry !== 'object' || Array.isArray(entry) || + !Object.values(entry).every((field) => typeof field === 'string') || + (entry as Record)['name'] !== plugin + ) { + return undefined; + } + const segments = repo.split('/'); + if (segments.length !== 2 || segments.some((segment) => !grokBotRepoSegmentPattern.test(segment) || /^\.+$/u.test(segment))) { + return undefined; + } + const [owner, name] = segments as [string, string]; + const cacheRoot = join(agentData, 'plugins', 'cache'); + const clone = join(agentData, 'plugins', 'marketplaces', 'github.com', owner, name, commit); + const pluginTail = pluginPath.startsWith(`${clone}${sep}`) ? pluginPath.slice(clone.length + 1).split(sep) : []; + if ( + cachePath !== join(cacheRoot, slug, plugin, commit) || + pluginTail.length === 0 || pluginTail.at(-1) !== plugin || + pluginTail.some((segment) => segment === '' || segment === '.' || segment === '..' || segment === '.git') || + (manifest !== join(clone, '.cursor-plugin', 'marketplace.json') && manifest !== join(clone, '.claude-plugin', 'marketplace.json')) || + !createdDirectories.every((directory) => + (isStrictlyAbove(directory, cachePath) && directory.startsWith(`${cacheRoot}${sep}`)) || + (isStrictlyAbove(directory, pluginPath) && directory.startsWith(`${clone}${sep}`))) + ) { + return undefined; + } + return Object.freeze({ + agentData, + cachePath, + commit, + createdDirectories: Object.freeze([...createdDirectories]), + entry: Object.freeze({ ...(entry as Record) }), + manifest, + pluginPath, + repo, + slug, + }); +}; + const readRegistration = (value: unknown): InstallRegistration | undefined => { if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined; const record = value as Record; @@ -680,6 +783,10 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { return undefined; } const cursorExpansion = readCursorExpansion(record['cursorExpansion']); + const grokBotSideload = typeof record['plugin'] === 'string' + ? readGrokBotSideload(record['grokBotSideload'], record['plugin']) + : undefined; + if (record['grokBotSideload'] !== undefined && grokBotSideload === undefined) return undefined; const state = readReceiptState(record['state']); if (record['state'] !== undefined && state === undefined) return undefined; if ( @@ -711,6 +818,7 @@ const receiptFromDocument = (value: unknown): InstallReceipt | undefined => { directories: Object.freeze([...record['directories']]), files: Object.freeze([...record['files']]), format: installReceiptFormat, + ...(grokBotSideload === undefined ? {} : { grokBotSideload }), host: record['host'], hostDirectories: Object.freeze([...record['hostDirectories']]), installedAt: record['installedAt'], @@ -757,6 +865,7 @@ export const readInstallReceipt = (destination: string): Promise => { + const target = grokBotStagingHost(options, identity); + const receipt = await readInstallReceiptFile(grokBotReceiptPath(target.root, identity.plugin)); + const sideload = receipt?.plugin === identity.plugin ? receipt.grokBotSideload : undefined; + const legacy = await exists(legacyGrokBotSideloadRecordPath(target.root, identity.plugin)); + if (sideload === undefined && !legacy) return uninstallStagedMarketplace(options, identity, policy, target); + const planned = options.plan === true; + if (!planned) await uninstallStagedMarketplace({ ...options, plan: true }, identity, policy, target); + const removals = [ + ...(sideload === undefined ? [] : [await removeGrokBotSideload(sideload, identity.plugin, { plan: planned })]), + // A record the first sideload release (#875) kept beside the receipt. + ...(legacy ? [await removeLegacyGrokBotSideload(target.root, identity.plugin, { plan: planned })] : []), + ]; + const staged = await uninstallStagedMarketplace(options, identity, policy, target); + const directories = removals.flatMap((removal) => removal.directories); + const retained = removals.flatMap((removal) => removal.retained); + const manifests = removals.flatMap((removal) => removal.manifest === undefined ? [] : [removal.manifest]); + const touched = directories.length > 0 || manifests.length > 0; + const where = sideload === undefined ? "Grok Bot's marketplace clone" : `Grok Bot's ${sideload.repo} clone @ ${sideload.commit}`; + const detail = [ + touched + ? `${planned ? 'would remove' : 'removed'} the sideload from ${where}` + + `${manifests.length === 0 ? '' : ` and its entry in ${manifests.join(', ')}`}` + : 'nothing sideloaded remains (Grok Bot pruned it, or it was removed by hand)', + ...(retained.length === 0 ? [] : [`kept ${retained.join(', ')}: no longer this plugin's sideload`]), + ].join('; '); + return Object.freeze({ + ...staged, + registrations: Object.freeze([ + ...staged.registrations, + Object.freeze({ + action: touched ? planned ? 'planned' as const : 'removed' as const : 'already-absent' as const, + ...(sideload === undefined ? {} : { commit: sideload.commit }), + detail, + kind: 'grokbot-sideload' as const, + name: sideload?.slug ?? 'legacy-sideload', + }), + ]), + removed: Object.freeze({ + directories: Object.freeze([...staged.removed.directories, ...directories]), + files: staged.removed.files, + }), + retained: Object.freeze([...staged.retained, ...retained]), + }); +}; + const uninstallStagedMarketplace = async ( options: UninstallBundleOptions, identity: PluginIdentity, @@ -1682,13 +1738,7 @@ const uninstallProgram = Effect.fnUntraced(function*( } // The grokbot host stages the Cursor projection, so the Cursor projection identifies the plugin. const cursorIdentity = yield* liftPromise(() => readBundleIdentity(options.from, 'cursor')); - const staged = yield* liftPromise(() => - uninstallStagedMarketplace(options, cursorIdentity, policy, grokBotStagingHost(options, cursorIdentity))); - if (staged.state !== 'uninstalled') return staged; - const sideloaded = yield* liftPromise(() => removeGrokBotSideload(grokBotRoot(options), cursorIdentity.plugin)); - return sideloaded.length === 0 - ? staged - : { ...staged, removed: { ...staged.removed, directories: [...staged.removed.directories, ...sideloaded] } }; + return yield* liftPromise(() => uninstallGrokBot(options, cursorIdentity, policy)); } const host = options.host; const identity = yield* liftPromise(() => readBundleIdentity(options.from, host)); diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index 4aa19f666..cd6aadba6 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -400,93 +400,3 @@ it('rolls a restage back to the superseded repository when the receipt cannot be await box.cleanup(); } }); - -it('sideloads into the Grok Bot marketplace snapshot named by the bundle repository owner, and uninstall reverses it', async () => { - const box = await sandbox(); - try { - await writeJson(join(box.from, '.cursor-plugin/plugin.json'), { - description: 'Fixture for Grok Bot.', - name: 'grok-fixture', - repository: 'https://github.com/ScriptedAlchemy/grok-fixture', - version: '1.2.3', - }); - await writeInstallFixtureManifest(box.from, { name: 'grok-fixture', version: '1.2.3' }, [{ host: 'cursor' }]); - const commit = '0401b288f8d5b444bc445ed4c3ac2b94fb24e02c'; - const snapshot = join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'scriptedalchemy', 'plugins', commit); - await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { - name: 'cursor-plugins', - plugins: [{ description: 'p', name: 'pstack', source: 'pstack' }], - }); - await writeJson(join(snapshot, 'pstack', '.cursor-plugin', 'plugin.json'), { name: 'pstack' }); - - const result = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); - const cachePath = join(box.agentData, 'plugins', 'cache', 'scriptedalchemy-plugins', 'grok-fixture', commit); - expect(result.sideload).toEqual([{ - addedEntry: true, - cachePath, - commit, - marketplace: 'scriptedalchemy/plugins', - pluginPath: join(snapshot, 'grok-fixture'), - }]); - expect(await readFile(join(snapshot, 'grok-fixture', 'payload.txt'), 'utf8')).toBe('payload\n'); - expect(await readFile(join(cachePath, 'payload.txt'), 'utf8')).toBe('payload\n'); - await access(join(cachePath, '.cache-complete')); - expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins).toEqual([ - { description: 'p', name: 'pstack', source: 'pstack' }, - { description: 'Fixture for Grok Bot.', name: 'grok-fixture', source: 'grok-fixture' }, - ]); - expect(formatInstallResult(result)).toContain(`Sideloaded into Grok Bot's scriptedalchemy/plugins marketplace snapshot @ ${commit}`); - const inventory = await readGrokBotInventory('grok-fixture', { environment: box.environment, home: box.home }); - expect(inventory).toMatchObject({ entries: [{ installPath: cachePath, marketplace: 'scriptedalchemy-plugins' }], status: 'available' }); - - // Re-running restores a pruned sideload and keeps a single marketplace entry. - await removeTree(cachePath); - const again = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); - expect(again.sideload?.[0]).toMatchObject({ addedEntry: true, cachePath }); - await access(join(cachePath, '.cache-complete')); - expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins).toHaveLength(2); - - const removed = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); - expect(removed.removed.directories).toEqual(expect.arrayContaining([join(snapshot, 'grok-fixture'), cachePath])); - await expect(access(cachePath)).rejects.toMatchObject({ code: 'ENOENT' }); - await expect(access(join(snapshot, 'grok-fixture'))).rejects.toMatchObject({ code: 'ENOENT' }); - expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins) - .toEqual([{ description: 'p', name: 'pstack', source: 'pstack' }]); - await access(join(snapshot, 'pstack', '.cursor-plugin', 'plugin.json')); - } finally { - await box.cleanup(); - } -}); - -it('leaves a plugin the hosted marketplace already ships alone and honors GROK_BOT_SIDELOAD=0', async () => { - const box = await sandbox(); - try { - const commit = '691058d3f00529b2f435b13400e75100ada7709e'; - const snapshot = join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'acme', 'plugins', commit); - await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { - plugins: [{ name: 'grok-fixture', source: 'grok-fixture' }], - }); - await writeJson(join(snapshot, 'grok-fixture', '.cursor-plugin', 'plugin.json'), { name: 'grok-fixture', version: '1.0.0' }); - const hosted = await installBundle({ - environment: { ...box.environment, GROK_BOT_SIDELOAD_MARKETPLACE: 'acme/plugins' }, - from: box.from, - home: box.home, - host: 'grokbot', - }); - expect(hosted.sideload).toBeUndefined(); - expect(JSON.parse(await readFile(join(snapshot, 'grok-fixture', '.cursor-plugin', 'plugin.json'), 'utf8')).version).toBe('1.0.0'); - - await removeTree(join(snapshot, 'grok-fixture')); - await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { plugins: [] }); - const disabled = await installBundle({ - environment: { ...box.environment, GROK_BOT_SIDELOAD: '0', GROK_BOT_SIDELOAD_MARKETPLACE: 'acme/plugins' }, - from: box.from, - home: box.home, - host: 'grokbot', - }); - expect(disabled.sideload).toBeUndefined(); - await expect(access(join(snapshot, 'grok-fixture'))).rejects.toMatchObject({ code: 'ENOENT' }); - } finally { - await box.cleanup(); - } -}); diff --git a/packages/agent-bundle/tests/grokbot-sideload.test.ts b/packages/agent-bundle/tests/grokbot-sideload.test.ts new file mode 100644 index 000000000..3039b412a --- /dev/null +++ b/packages/agent-bundle/tests/grokbot-sideload.test.ts @@ -0,0 +1,593 @@ +import { access, chmod, mkdir, mkdtemp, readFile, readdir, realpath, symlink, writeFile } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +import { expect, it } from '@rstest/core'; + +import { DiagnosticError } from '../src/core/diagnostics.ts'; +import { runDoctor } from '../src/install/doctor.ts'; +import { formatInstallResult, formatUninstallResult } from '../src/install/format.ts'; +import { grokBotReceiptPath, readGrokBotInventory } from '../src/install/grokbot.ts'; +import { + applyGrokBotSideload, + grokBotSideloadMarkerFile, + planGrokBotSideload, + resolveGrokBotSideloadSettings, +} from '../src/install/grokbot-sideload.ts'; +import { bundleInventory, readBundleIdentity } from '../src/install/identity.ts'; +import { installBundle, type InstallResult } from '../src/install/install.ts'; +import { readGrokBotSideload, readInstallReceiptFile } from '../src/install/receipt.ts'; +import { uninstallBundle } from '../src/install/uninstall.ts'; +import { runInstallCli } from '../src/install/index.ts'; +import { writeInstallFixtureManifest } from './support/install-fixture.ts'; +import { removeTree } from './support/remove-tree.ts'; + +const commit = '0401b288f8d5b444bc445ed4c3ac2b94fb24e02c'; +const nextCommit = '1111111111111111111111111111111111111111'; + +const writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +const missing = async (path: string): Promise => { + await expect(access(path), path).rejects.toMatchObject({ code: 'ENOENT' }); +}; + +interface Sandbox { + readonly agentData: string; + readonly cleanup: () => Promise; + readonly environment: Readonly; + readonly from: string; + readonly home: string; +} + +/** A built Cursor projection named `grok-fixture` and an isolated home with an empty Grok Bot agent-data root. */ +const sandbox = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-grokbot-sideload-'))); + const from = join(root, 'bundle'); + await mkdir(join(from, 'skills', 'talk'), { recursive: true }); + await writeFile(join(from, 'skills', 'talk', 'SKILL.md'), '---\nname: talk\ndescription: Talk.\n---\nTalk.\n'); + await writeJson(join(from, '.cursor-plugin/plugin.json'), { description: 'Grok fixture.', name: 'grok-fixture', version: '1.2.3' }); + await writeInstallFixtureManifest(from, { name: 'grok-fixture', version: '1.2.3' }, [{ host: 'cursor' }]); + const agentData = join(root, 'agent-data'); + await mkdir(join(agentData, 'plugins', 'cache'), { recursive: true }); + const home = join(root, 'home'); + await mkdir(home); + return { agentData, cleanup: () => removeTree(root), environment: { GROK_BOT_AGENT_DATA_DIR: agentData }, from, home }; +}; + +const repoRoot = (box: Sandbox): string => join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'scriptedalchemy', 'plugins'); +const clonePath = (box: Sandbox, sha = commit): string => join(repoRoot(box), sha); +const cacheSlug = (box: Sandbox): string => join(box.agentData, 'plugins', 'cache', 'scriptedalchemy-plugins'); + +const upstreamManifest = { + name: 'cursor-plugins', + owner: { name: 'Cursor' }, + plugins: [ + { description: 'Go deep first.', name: 'pstack', source: 'pstack' }, + { minClientVersions: { grokbot: '0.49.0' }, name: 'finance', source: 'third_party/finance' }, + ], +}; + +/** What Grok Bot keeps after installing `pstack` from ScriptedAlchemy/plugins: a sparse clone at `sha` and a cache copy. */ +const simulateGrokBotMarketplace = async (box: Sandbox, sha = commit): Promise => { + const clone = clonePath(box, sha); + await writeFile(join(await mkdirp(join(clone, '.git')), 'HEAD'), `${sha}\n`); + await writeJson(join(clone, '.cursor-plugin', 'marketplace.json'), upstreamManifest); + await writeFile(join(await mkdirp(join(clone, 'pstack')), 'README.md'), 'pstack\n'); + const cache = join(cacheSlug(box), 'pstack', sha); + await writeFile(join(await mkdirp(cache), 'README.md'), 'pstack\n'); + await writeFile(join(cache, '.cache-complete'), ''); + return clone; +}; + +const mkdirp = async (path: string): Promise => { + await mkdir(path, { recursive: true }); + return path; +}; + +const sideloadOf = (result: InstallResult): Extract, { readonly cachePath: string }> => { + if (result.sideload === undefined || result.sideload.state === 'skipped') { + throw new Error(`expected a sideload, got ${JSON.stringify(result.sideload)}`); + } + return result.sideload; +}; + +it('install grokbot sideloads into the active marketplace clone and plugin cache, idempotently, and uninstall removes exactly that', async () => { + const box = await sandbox(); + try { + const clone = await simulateGrokBotMarketplace(box); + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + const originalManifest = await readFile(manifestPath, 'utf8'); + const result = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + const sideload = sideloadOf(result); + expect(sideload.state).toBe('written'); + const pluginPath = join(clone, 'grok-fixture'); + const cachePath = join(cacheSlug(box), 'grok-fixture', commit); + expect(sideload).toEqual({ + agentData: box.agentData, + cachePath, + commit, + createdDirectories: [join(cacheSlug(box), 'grok-fixture')], + entry: { description: 'Grok fixture.', name: 'grok-fixture', source: 'grok-fixture' }, + manifest: manifestPath, + pluginPath, + repo: 'scriptedalchemy/plugins', + slug: 'scriptedalchemy-plugins', + state: 'written', + }); + for (const root of [pluginPath, cachePath]) { + expect(await readFile(join(root, 'skills', 'talk', 'SKILL.md'), 'utf8')).toContain('name: talk'); + expect(JSON.parse(await readFile(join(root, grokBotSideloadMarkerFile), 'utf8'))).toMatchObject({ plugin: 'grok-fixture' }); + } + await access(join(cachePath, '.cache-complete')); + await missing(join(pluginPath, '.cache-complete')); + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toEqual({ + ...upstreamManifest, + plugins: [...upstreamManifest.plugins, { description: 'Grok fixture.', name: 'grok-fixture', source: 'grok-fixture' }], + }); + // No staging or replaced siblings are left beside the written folders. + expect(await readdir(clone)).toEqual(['.cursor-plugin', '.git', 'grok-fixture', 'pstack']); + expect(await readdir(join(cacheSlug(box), 'grok-fixture'))).toEqual([commit]); + expect(await readdir(dirname(manifestPath))).toEqual(['marketplace.json']); + expect(result.nextSteps?.[0]).toContain('its next plugin sync removes the cache copy'); + expect(formatInstallResult(result)).toContain(`Grok Bot sideload: written (scriptedalchemy/plugins @ ${commit}, cache scriptedalchemy-plugins)`); + + const receipt = await readInstallReceiptFile(grokBotReceiptPath(join(box.home, '.grokbot'), 'grok-fixture')); + expect(receipt?.grokBotSideload).toEqual({ + agentData: box.agentData, + cachePath, + commit, + createdDirectories: [join(cacheSlug(box), 'grok-fixture')], + entry: { description: 'Grok fixture.', name: 'grok-fixture', source: 'grok-fixture' }, + manifest: manifestPath, + pluginPath, + repo: 'scriptedalchemy/plugins', + slug: 'scriptedalchemy-plugins', + }); + expect(receipt?.registrations).toEqual([ + expect.objectContaining({ kind: 'grokbot-marketplace-staging' }), + { commit, kind: 'grokbot-sideload', name: 'scriptedalchemy-plugins' }, + ]); + // A sideload copy is not an install Grok Bot made. + expect(await readGrokBotInventory('grok-fixture', { environment: box.environment, home: box.home })).toMatchObject({ entries: [] }); + + const again = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(again).toMatchObject({ sideload: { state: 'unchanged' }, state: 'already-installed' }); + + const doctor = await runDoctor({ environment: box.environment, from: box.from, home: box.home, hosts: ['grokbot'] }); + const grokbot = doctor.hosts.find((host) => host.host === 'grokbot'); + expect(grokbot?.receipts[0]?.sideload).toMatchObject({ + cacheCopy: true, cloneActive: true, listed: true, loaded: false, pluginFolder: true, + }); + expect(grokbot?.diagnostics.find((entry) => entry.code === 'AB7335')).toMatchObject({ + message: expect.stringContaining('written'), + severity: 'info', + }); + + const planned = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot', plan: true }); + expect(planned.removed.directories).toEqual(expect.arrayContaining([cachePath, pluginPath, join(cacheSlug(box), 'grok-fixture')])); + expect(planned.registrations.at(-1)).toMatchObject({ action: 'planned', kind: 'grokbot-sideload' }); + await access(cachePath); + await access(pluginPath); + + const removed = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(removed.state).toBe('uninstalled'); + expect(removed.registrations.at(-1)).toMatchObject({ action: 'removed', commit, kind: 'grokbot-sideload' }); + expect(formatUninstallResult(removed)).toContain(`and its entry in ${manifestPath}`); + await missing(pluginPath); + await missing(join(cacheSlug(box), 'grok-fixture')); + expect(await readFile(manifestPath, 'utf8')).toBe(originalManifest); + // Grok Bot's own plugin is untouched. + await access(join(clone, 'pstack', 'README.md')); + await access(join(cacheSlug(box), 'pstack', commit, '.cache-complete')); + } finally { + await box.cleanup(); + } +}); + +it('skips the sideload without failing when there is no clone, and never touches folders or entries it does not own', async () => { + const box = await sandbox(); + try { + const none = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(none.sideload).toMatchObject({ reason: expect.stringContaining('has no clone of this marketplace'), state: 'skipped' }); + expect(formatInstallResult(none)).toContain('Grok Bot sideload: skipped'); + + const clone = await simulateGrokBotMarketplace(box); + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + // Grok Bot already installed a plugin with this name from the account: its cache folder carries no marker. + const own = join(cacheSlug(box), 'grok-fixture', commit); + await writeFile(join(await mkdirp(own), '.cache-complete'), ''); + const shadow = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(shadow.sideload).toMatchObject({ reason: expect.stringContaining('account install'), state: 'skipped' }); + expect(await readdir(own)).toEqual(['.cache-complete']); + await missing(join(clone, 'grok-fixture')); + await removeTree(join(cacheSlug(box), 'grok-fixture')); + + // A folder of the same name in the clone that agent-bundle did not write. + await writeFile(join(await mkdirp(join(clone, 'grok-fixture')), 'README.md'), 'upstream\n'); + const foreign = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(foreign.sideload).toMatchObject({ reason: expect.stringContaining('was not written by agent-bundle'), state: 'skipped' }); + expect(await readdir(join(clone, 'grok-fixture'))).toEqual(['README.md']); + await removeTree(join(clone, 'grok-fixture')); + + // A manifest entry of the same name that agent-bundle did not add. + await writeJson(manifestPath, { ...upstreamManifest, plugins: [...upstreamManifest.plugins, { name: 'grok-fixture', source: 'elsewhere' }] }); + const listed = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(listed.sideload).toMatchObject({ reason: expect.stringContaining('did not add'), state: 'skipped' }); + await missing(join(cacheSlug(box), 'grok-fixture')); + } finally { + await box.cleanup(); + } +}); + +it('configures the sideload repository and slug, honours --no-sideload and GROK_BOT_SIDELOAD=0, and rejects bad values', async () => { + expect(resolveGrokBotSideloadSettings({}, {})).toEqual({ enabled: true, repo: 'scriptedalchemy/plugins', slug: 'scriptedalchemy-plugins' }); + expect(resolveGrokBotSideloadSettings({}, { GROK_BOT_SIDELOAD: '0', GROK_BOT_SIDELOAD_REPO: 'https://github.com/Acme/Market.git' })) + .toEqual({ enabled: false, repo: 'acme/market', slug: 'acme-market' }); + expect(resolveGrokBotSideloadSettings({ sideload: true, sideloadSlug: 'acme_x' }, { GROK_BOT_SIDELOAD: 'off' })) + .toEqual({ enabled: true, repo: 'scriptedalchemy/plugins', slug: 'acme_x' }); + expect(() => resolveGrokBotSideloadSettings({ sideloadRepo: '../etc' }, {})).toThrow(DiagnosticError); + expect(() => resolveGrokBotSideloadSettings({ sideloadSlug: 'a/b' }, {})).toThrow(/may contain only/u); + + const box = await sandbox(); + try { + const acme = join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'acme', 'market', commit); + await writeFile(join(await mkdirp(join(acme, '.git')), 'HEAD'), `${commit}\n`); + await writeJson(join(acme, '.claude-plugin', 'marketplace.json'), { name: 'acme', plugins: [] }); + await expect(installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'cursor', sideload: false })) + .rejects.toThrow('Sideload options apply to the grokbot host only.'); + const off = await installBundle({ environment: { ...box.environment, GROK_BOT_SIDELOAD: '0' }, from: box.from, home: box.home, host: 'grokbot' }); + expect(off.sideload).toMatchObject({ state: 'skipped' }); + await missing(join(acme, 'grok-fixture')); + + let stdout = ''; + const previous = process.env['GROK_BOT_AGENT_DATA_DIR']; + const previousHome = process.env['GROK_BOT_HOME']; + process.env['GROK_BOT_AGENT_DATA_DIR'] = box.agentData; + process.env['GROK_BOT_HOME'] = join(box.home, '.grokbot'); + try { + const run = (argv: readonly string[]): Promise => + runInstallCli(argv, { from: box.from, name: 'fixture-install', stderr: () => undefined, stdout: (text) => { stdout += text; } }); + expect(await run(['install', 'grokbot', '--no-sideload'])).toBe(0); + expect(stdout).toContain('Grok Bot sideload: skipped — disabled'); + stdout = ''; + expect(await run(['install', 'grokbot', '--sideload-repo', 'acme/market', '--sideload-slug', 'acme'])).toBe(0); + expect(stdout).toContain(`Grok Bot sideload: written (acme/market @ ${commit}, cache acme)`); + } finally { + if (previous === undefined) delete process.env['GROK_BOT_AGENT_DATA_DIR']; + else process.env['GROK_BOT_AGENT_DATA_DIR'] = previous; + if (previousHome === undefined) delete process.env['GROK_BOT_HOME']; + else process.env['GROK_BOT_HOME'] = previousHome; + } + // The Claude manifest fallback is the one updated, keeping its two-space layout and trailing newline. + expect(await readFile(join(acme, '.claude-plugin', 'marketplace.json'), 'utf8')).toBe( + `${JSON.stringify({ name: 'acme', plugins: [{ name: 'grok-fixture', source: 'grok-fixture', description: 'Grok fixture.' }] }, null, 2)}\n`, + ); + await access(join(box.agentData, 'plugins', 'cache', 'acme', 'grok-fixture', commit, '.cache-complete')); + } finally { + await box.cleanup(); + } +}); + +it('follows Grok Bot to a new marketplace commit, picks the live clone mid-reclone, and reports what its sync removed', async () => { + const box = await sandbox(); + try { + await simulateGrokBotMarketplace(box); + const first = sideloadOf(await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' })); + + // Grok Bot's sync prunes the cache folder of a plugin its account listing does not name. + await removeTree(join(cacheSlug(box), 'grok-fixture')); + let doctor = await runDoctor({ environment: box.environment, from: box.from, home: box.home, hosts: ['grokbot'] }); + expect(doctor.hosts[0]?.diagnostics.find((entry) => entry.code === 'AB7335')).toMatchObject({ + message: expect.stringContaining('removed by Grok Bot\'s plugin sync'), + severity: 'warning', + }); + + // Mid-reclone: a second clone exists and pstack's cache copy already uses it. + await simulateGrokBotMarketplace(box, nextCommit); + await removeTree(join(cacheSlug(box), 'pstack', commit)); + const moved = sideloadOf(await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' })); + expect(moved).toMatchObject({ commit: nextCommit, pluginPath: join(clonePath(box, nextCommit), 'grok-fixture'), state: 'written' }); + // The superseded sideload in the old clone is removed, entry included; nothing else there changes. + await missing(first.pluginPath); + expect(JSON.parse(await readFile(first.manifest, 'utf8'))).toEqual(upstreamManifest); + expect(await readdir(join(cacheSlug(box), 'grok-fixture'))).toEqual([nextCommit]); + + // Grok Bot indexed the copy: the account lists the plugin and Grok Bot loaded the sideloaded files. + await writeJson(join(box.agentData, 'plugin-skills', 'cache.json'), { + installFolders: [{ installPath: `/home/box/sand-data/plugins/cache/scriptedalchemy-plugins/grok-fixture/${nextCommit}`, pluginId: '7' }], + skills: [], + }); + doctor = await runDoctor({ environment: box.environment, from: box.from, home: box.home, hosts: ['grokbot'] }); + expect(doctor.hosts[0]?.diagnostics.find((entry) => entry.code === 'AB7335')).toMatchObject({ + message: expect.stringContaining('loaded'), + severity: 'info', + }); + + // Two clones whose plugins' cache copies use both commits: no clone is clearly the live one. + await removeTree(clonePath(box, commit)); + await simulateGrokBotMarketplace(box, commit); + const ambiguous = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(ambiguous.sideload).toMatchObject({ reason: expect.stringContaining('none is clearly active'), state: 'skipped' }); + // The skipped run keeps the previous record, so uninstall still finds the sideload. + const removed = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(removed.registrations.at(-1)).toMatchObject({ action: 'removed', commit: nextCommit, kind: 'grokbot-sideload' }); + await missing(moved.pluginPath); + await missing(join(cacheSlug(box), 'grok-fixture')); + } finally { + await box.cleanup(); + } +}); + +it('accepts a receipt sideload record only when every path derives from its own fields', () => { + const agentData = join(tmpdir(), 'grok-agent-data'); + const clone = join(agentData, 'plugins', 'marketplaces', 'github.com', 'scriptedalchemy', 'plugins', commit); + const cacheRoot = join(agentData, 'plugins', 'cache'); + const valid = { + agentData, + cachePath: join(cacheRoot, 'scriptedalchemy-plugins', 'grok-fixture', commit), + commit, + createdDirectories: [join(cacheRoot, 'scriptedalchemy-plugins', 'grok-fixture')], + entry: { name: 'grok-fixture', source: 'grok-fixture' }, + manifest: join(clone, '.cursor-plugin', 'marketplace.json'), + pluginPath: join(clone, 'grok-fixture'), + repo: 'scriptedalchemy/plugins', + slug: 'scriptedalchemy-plugins', + }; + expect(readGrokBotSideload(valid, 'grok-fixture')).toEqual(valid); + expect(readGrokBotSideload({ ...valid, manifest: join(clone, '.claude-plugin', 'marketplace.json') }, 'grok-fixture')).toBeDefined(); + expect(readGrokBotSideload({ ...valid, pluginPath: join(clone, 'plugins', 'grok-fixture') }, 'grok-fixture')).toBeDefined(); + for (const corrupt of [ + { cachePath: join(cacheRoot, 'scriptedalchemy-plugins', 'pstack', commit) }, + { cachePath: join(tmpdir(), 'elsewhere') }, + { pluginPath: join(clone, 'pstack') }, + { pluginPath: join(clone, '.git', 'grok-fixture') }, + { pluginPath: join(dirname(clone), nextCommit, 'grok-fixture') }, + { manifest: join(clone, 'pstack', 'marketplace.json') }, + { createdDirectories: [join(cacheRoot, 'scriptedalchemy-plugins', 'pstack')] }, + { createdDirectories: [cacheRoot] }, + { createdDirectories: [clone] }, + { createdDirectories: [join(cacheRoot, 'scriptedalchemy-plugins', 'grok-fixture', commit)] }, + { createdDirectories: [join(clone, 'grok-fixture')] }, + { commit: 'main' }, + { repo: 'scriptedalchemy/../etc' }, + { slug: '../x' }, + { entry: { name: 'pstack', source: 'pstack' } }, + { entry: { name: 'grok-fixture', source: { path: 'x' } } }, + { agentData: `${agentData}${'/'}` }, + ]) { + expect(readGrokBotSideload({ ...valid, ...corrupt }, 'grok-fixture')).toBeUndefined(); + } + expect(readGrokBotSideload(valid, 'pstack')).toBeUndefined(); +}); + +it('leaves a replaced manifest entry alone and never reports a pruned sideload as loaded from a stale index', async () => { + const box = await sandbox(); + try { + const clone = await simulateGrokBotMarketplace(box); + const sideload = sideloadOf(await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' })); + // The clone folder disappears and the same name comes back as an entry agent-bundle did not write. + await removeTree(sideload.pluginPath); + const replaced = { ...upstreamManifest, plugins: [...upstreamManifest.plugins, { name: 'grok-fixture', source: './upstream/grok-fixture' }] }; + await writeJson(sideload.manifest, replaced); + await writeJson(join(box.agentData, 'plugin-skills', 'cache.json'), { + installFolders: [{ installPath: sideload.cachePath, pluginId: '9' }], + skills: [], + }); + const doctor = await runDoctor({ environment: box.environment, from: box.from, home: box.home, hosts: ['grokbot'] }); + expect(doctor.hosts[0]?.diagnostics.find((entry) => entry.code === 'AB7335')).toMatchObject({ + message: expect.stringMatching(/incomplete.*the clone folder.*the entry in.*still names the cache copy/u), + severity: 'warning', + }); + // A rerun does not claim the foreign entry either. + const rerun = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(rerun.sideload).toMatchObject({ reason: expect.stringContaining('did not add'), state: 'skipped' }); + const removed = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(removed.retained).toContain(sideload.manifest); + expect(JSON.parse(await readFile(sideload.manifest, 'utf8'))).toEqual(replaced); + await missing(join(cacheSlug(box), 'grok-fixture')); + expect(await readdir(clone)).toEqual(['.cursor-plugin', '.git', 'pstack']); + } finally { + await box.cleanup(); + } +}); + +it('re-reads the manifest before writing: keeps entries Grok Bot added meanwhile and refuses a foreign same-name entry', async () => { + const box = await sandbox(); + try { + const clone = await simulateGrokBotMarketplace(box); + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + const identity = await readBundleIdentity(box.from, 'cursor'); + const artifact = await bundleInventory(identity); + const settings = resolveGrokBotSideloadSettings({}, box.environment); + const plan = async () => { + const planned = await planGrokBotSideload({ environment: box.environment, home: box.home, plugin: 'grok-fixture', settings }); + if (planned.state !== 'ready') throw new Error(planned.reason); + return planned.target; + }; + + let target = await plan(); + const foreign = { ...upstreamManifest, plugins: [...upstreamManifest.plugins, { name: 'grok-fixture', source: 'theirs' }] }; + await writeJson(manifestPath, foreign); + const progress = { started: false }; + await expect(applyGrokBotSideload({ artifact, bundleRoot: identity.bundleRoot, plugin: 'grok-fixture', progress, target })) + .rejects.toThrow(/did not add/u); + expect(progress.started).toBe(false); + await missing(join(cacheSlug(box), 'grok-fixture')); + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toEqual(foreign); + + await writeJson(manifestPath, upstreamManifest); + target = await plan(); + const grown = { ...upstreamManifest, plugins: [...upstreamManifest.plugins, { name: 'advisor', source: 'advisor' }] }; + await writeJson(manifestPath, grown); + await applyGrokBotSideload({ artifact, bundleRoot: identity.bundleRoot, plugin: 'grok-fixture', progress, target }); + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toEqual({ + ...grown, + plugins: [...grown.plugins, { name: 'grok-fixture', source: 'grok-fixture' }], + }); + } finally { + await box.cleanup(); + } +}); + +const permissionsApply = process.platform !== 'win32' && process.getuid?.() !== 0; + +it.skipIf(!permissionsApply)('rolls a sideload back when the manifest or the receipt cannot be written, leaving nothing unrecorded', async () => { + const box = await sandbox(); + const locked: string[] = []; + const lock = async (path: string): Promise => { + await chmod(path, 0o555); + locked.push(path); + }; + try { + const clone = await simulateGrokBotMarketplace(box); + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + const originalManifest = await readFile(manifestPath, 'utf8'); + const install = () => installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + + // The manifest rename fails after both folders landed: they are removed again, the manifest is untouched. + await lock(dirname(manifestPath)); + await expect(install()).rejects.toThrow(); + await missing(join(clone, 'grok-fixture')); + await missing(join(cacheSlug(box), 'grok-fixture')); + expect(await readFile(manifestPath, 'utf8')).toBe(originalManifest); + expect((await readInstallReceiptFile(grokBotReceiptPath(join(box.home, '.grokbot'), 'grok-fixture')))?.grokBotSideload).toBeUndefined(); + await chmod(locked.pop() as string, 0o755); + + // The receipt write fails after the sideload landed: it is rolled back, and the earlier receipt stays valid. + const first = sideloadOf(await install()); + const receiptPath = grokBotReceiptPath(join(box.home, '.grokbot'), 'grok-fixture'); + const before = await readFile(receiptPath, 'utf8'); + await writeFile(join(box.from, 'skills', 'talk', 'SKILL.md'), '---\nname: talk\ndescription: Talk more.\n---\nTalk.\n'); + await writeInstallFixtureManifest(box.from, { name: 'grok-fixture', version: '1.2.3' }, [{ host: 'cursor' }]); + await lock(dirname(receiptPath)); + await expect(install()).rejects.toThrow(); + expect(await readFile(receiptPath, 'utf8')).toBe(before); + await missing(first.pluginPath); + await missing(first.cachePath); + expect(await readFile(manifestPath, 'utf8')).toBe(originalManifest); + await chmod(locked.pop() as string, 0o755); + const doctor = await runDoctor({ environment: box.environment, from: box.from, home: box.home, hosts: ['grokbot'] }); + expect(doctor.hosts[0]?.diagnostics.find((entry) => entry.code === 'AB7335')).toMatchObject({ severity: 'warning' }); + expect(sideloadOf(await install()).state).toBe('written'); + } finally { + for (const path of locked) await chmod(path, 0o755); + await box.cleanup(); + } +}); + +it.skipIf(process.platform === 'win32')('skips a target reached through a symbolic link', async () => { + const box = await sandbox(); + try { + await simulateGrokBotMarketplace(box); + const elsewhere = await mkdirp(join(box.home, 'elsewhere')); + await symlink(elsewhere, join(cacheSlug(box), 'grok-fixture')); + const result = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(result.sideload).toMatchObject({ state: 'skipped' }); + expect(await readdir(elsewhere)).toEqual([]); + } finally { + await box.cleanup(); + } +}); + +it('normalizes GROK_BOT_AGENT_DATA_DIR and clears interrupted swaps without deleting a folder it cannot prove is its own', async () => { + const box = await sandbox(); + try { + const clone = await simulateGrokBotMarketplace(box); + const environment = { GROK_BOT_AGENT_DATA_DIR: `${box.agentData}/plugins/../` }; + const install = () => installBundle({ environment, from: box.from, home: box.home, host: 'grokbot' }); + const first = sideloadOf(await install()); + expect(first.agentData).toBe(box.agentData); + const receiptPath = grokBotReceiptPath(join(box.home, '.grokbot'), 'grok-fixture'); + expect((await readInstallReceiptFile(receiptPath))?.grokBotSideload).toMatchObject({ agentData: box.agentData }); + + // A marked aside left by an interrupted swap is deleted by the next write. + const scratch = (name: string): string => join(clone, `.grok-fixture.agent-bundle-${name}.replaced`); + const ownAside = scratch('00000000-0000-4000-8000-000000000000'); + await writeJson(join(ownAside, grokBotSideloadMarkerFile), { format: 'agent-bundle-grokbot-sideload@1', plugin: 'grok-fixture' }); + await writeFile(join(box.from, 'skills', 'talk', 'SKILL.md'), '---\nname: talk\ndescription: Talk again.\n---\nTalk.\n'); + await writeInstallFixtureManifest(box.from, { name: 'grok-fixture', version: '1.2.3' }, [{ host: 'cursor' }]); + expect(sideloadOf(await install()).state).toBe('written'); + await missing(ownAside); + + // An unmarked aside is someone else's folder caught mid-swap: it is put back where it was, never deleted. + await uninstallBundle({ environment, from: box.from, home: box.home, host: 'grokbot' }); + const foreignAside = scratch('11111111-1111-4111-8111-111111111111'); + await writeFile(join(await mkdirp(foreignAside), 'theirs.txt'), 'theirs\n'); + await expect(install()).rejects.toThrow(/without agent-bundle's sideload marker/u); + expect(await readFile(join(clone, 'grok-fixture', 'theirs.txt'), 'utf8')).toBe('theirs\n'); + await missing(foreignAside); + await missing(join(cacheSlug(box), 'grok-fixture')); + } finally { + await box.cleanup(); + } +}); + +it('retires the first sideload release record: its folders and entry are replaced by marked, receipted ones', async () => { + const box = await sandbox(); + try { + const clone = await simulateGrokBotMarketplace(box); + const manifestPath = join(clone, '.cursor-plugin', 'marketplace.json'); + // What #875 left: unmarked copies, its `{ name, source, description }` entry, and a record beside the receipts. + const pluginPath = join(clone, 'grok-fixture'); + const cachePath = join(cacheSlug(box), 'grok-fixture', commit); + for (const path of [pluginPath, cachePath]) { + await writeJson(join(path, '.cursor-plugin', 'plugin.json'), { name: 'grok-fixture', version: '1.0.0' }); + } + await writeFile(join(cachePath, '.cache-complete'), ''); + const legacyEntry = { description: 'Old.', name: 'grok-fixture', source: 'grok-fixture' }; + await writeJson(manifestPath, { ...upstreamManifest, plugins: [...upstreamManifest.plugins, legacyEntry] }); + const record = join(box.home, '.grokbot', 'agent-bundle', 'sideload', 'grok-fixture.json'); + const entries = [{ addedEntry: true, cachePath, commit, marketplace: 'scriptedalchemy/plugins', pluginPath }]; + await writeJson(record, { entries, plugin: 'grok-fixture', version: '1.0.0' }); + + // Records #875 could not have written authorize nothing: another plugin's, or a marketplace with extra segments. + const untouched = async (document: unknown): Promise => { + await writeJson(record, document); + const skipped = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(skipped.sideload).toMatchObject({ state: 'skipped' }); + await access(join(pluginPath, '.cursor-plugin', 'plugin.json')); + await access(join(cachePath, '.cursor-plugin', 'plugin.json')); + expect(JSON.parse(await readFile(manifestPath, 'utf8')).plugins).toContainEqual(legacyEntry); + }; + await untouched({ entries, plugin: 'other-plugin', version: '1.0.0' }); + await untouched({ entries: [{ ...entries[0], marketplace: 'scriptedalchemy/plugins/extra' }], plugin: 'grok-fixture', version: '1.0.0' }); + await untouched({ entries: [{ ...entries[0], addedEntry: 'yes' }], plugin: 'grok-fixture', version: '1.0.0' }); + await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + await writeJson(record, { entries, plugin: 'grok-fixture', version: '1.0.0' }); + + // The old env var still names the marketplace (one repository only). + const environment = { ...box.environment, GROK_BOT_SIDELOAD_MARKETPLACE: 'scriptedalchemy/plugins' }; + const result = await installBundle({ environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(sideloadOf(result)).toMatchObject({ cachePath, pluginPath, state: 'written' }); + expect(result.nextSteps).toContainEqual(expect.stringContaining(`Retired the earlier sideload record ${record}`)); + await missing(record); + expect(JSON.parse(await readFile(join(pluginPath, grokBotSideloadMarkerFile), 'utf8'))).toMatchObject({ plugin: 'grok-fixture' }); + expect(JSON.parse(await readFile(manifestPath, 'utf8')).plugins).toEqual([ + ...upstreamManifest.plugins, + { description: 'Grok fixture.', name: 'grok-fixture', source: 'grok-fixture' }, + ]); + expect(() => resolveGrokBotSideloadSettings({}, { GROK_BOT_SIDELOAD_MARKETPLACE: 'a/b,c/d' })).toThrow(/several repositories/u); + + // Uninstall with only a legacy record (no receipt sideload) removes what it names, nothing else. + await uninstallBundle({ environment, from: box.from, home: box.home, host: 'grokbot' }); + for (const path of [pluginPath, cachePath]) { + await writeJson(join(path, '.cursor-plugin', 'plugin.json'), { name: 'grok-fixture', version: '1.0.0' }); + } + await writeJson(manifestPath, { ...upstreamManifest, plugins: [...upstreamManifest.plugins, legacyEntry] }); + await writeJson(record, { entries, plugin: 'grok-fixture', version: '1.0.0' }); + await installBundle({ environment: { ...environment, GROK_BOT_SIDELOAD: '0' }, from: box.from, home: box.home, host: 'grokbot' }); + await access(record); + const removed = await uninstallBundle({ environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(removed.registrations.at(-1)).toMatchObject({ action: 'removed', kind: 'grokbot-sideload' }); + await missing(record); + await missing(pluginPath); + await missing(cachePath); + expect(JSON.parse(await readFile(manifestPath, 'utf8'))).toEqual(upstreamManifest); + await access(join(clone, 'pstack', 'README.md')); + } finally { + await box.cleanup(); + } +}); diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index 462e3cbdb..eee6af9e2 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -39,7 +39,7 @@ plugin name for `cursor`. | Claude Code | Delegates to `claude plugin marketplace add` and `claude plugin install`. Claude Code loads `hooks/hooks.json` and `.mcp.json` from the plugin root on its own, so `.claude-plugin/plugin.json` carries no `hooks` pointer (Claude Code reports one at that file as a duplicate hooks file); the Codex and Cursor documents beside their own manifests in the same root are never read by Claude Code. For a `directory` marketplace, Claude Code resolves `${CLAUDE_PLUGIN_ROOT}` to that directory itself, not to its plugin cache, so rebuilding it changes the hooks a running session executes. | `user`, `project`, `local` | | Codex | Delegates to `codex plugin marketplace add` and `codex plugin add`. `.codex-plugin/plugin.json` points at its own `.codex-plugin/hooks.json` and `.codex-plugin/mcp.json`, so Codex never falls back to discovering another host's documents in the root. | `user` | | Cursor | Copies the root into `~/.cursor/plugins/local/` (`--mode local`, the default), because Cursor publishes no non-interactive install verb; `--mode marketplace` instead stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step. Either way Cursor loads the hook and MCP documents `.cursor-plugin/plugin.json` names, `.cursor-plugin/hooks.json` and `.cursor-plugin/mcp.json`, beside the manifest, so plugin hooks run with `${CURSOR_PLUGIN_ROOT}` substituted and need no `~/.cursor/hooks.json` entry. | `user` | -| Grok Bot | Stages the Cursor projection (the bundle must list `cursor` in `targets`) as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` (`$GROK_BOT_HOME` overrides `~/.grokbot`) and records a store receipt. Grok Bot installs Cursor-format plugins into the user's account only from hosted Git marketplaces, through its Marketplace UI, the in-chat plugin install tool, or `grokbot://app/v1/plugin/add?id=`, all keyed by a server-assigned plugin id; there is no local-folder import or CLI verb, so `install grokbot` prints the remaining steps (host the repository, add it as a marketplace, install from Grok Bot) instead of claiming a registration. `doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`); `uninstall grokbot` removes the staging and receipt and names the plugin id to uninstall in Grok Bot. | `user` | +| Grok Bot | Stages the Cursor projection (the bundle must list `cursor` in `targets`) as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` (`$GROK_BOT_HOME` overrides `~/.grokbot`) and records a store receipt. Grok Bot installs Cursor-format plugins into the user's account only from hosted Git marketplaces, through its Marketplace UI, the in-chat plugin install tool, or `grokbot://app/v1/plugin/add?id=`, all keyed by a server-assigned plugin id; there is no local-folder import or CLI verb, so `install grokbot` prints the remaining steps (host the repository, add it as a marketplace, install from Grok Bot) instead of claiming a registration. On a computer with Grok Bot agent-data it also sideloads the plugin into Grok Bot's own clone of the `scriptedalchemy/plugins` marketplace (its manifest included) and plugin cache, under the clone commit Grok Bot already uses; Grok Bot loads that copy only once its account plugin listing names the plugin, and its plugin sync otherwise removes the cache copy (`--no-sideload`, `--sideload-repo`, and `--sideload-slug` in the [CLI reference](../../reference/cli.mdx#install)). `doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`) and the sideload state (`AB7335`); `uninstall grokbot` removes the staging, the receipted sideload, and the receipt, and names the plugin id to uninstall in Grok Bot. | `user` | Because the Claude and Codex projections always ship local marketplace manifests, their public CLIs can install the root directly. The installer fails with a typed diagnostic when the diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 3170f58be..bc4b213f6 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -186,7 +186,7 @@ already on disk. ```sh agent-bundle install [--from ] [--scope ] [--mode ] \ - [--replace] [--json] + [--replace] [--no-sideload] [--sideload-repo ] [--sideload-slug ] [--json] ``` | Option | Default | Meaning | @@ -196,6 +196,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Amp accepts `project` and `user` (its system root); Codex, Cursor, and Grok Bot are user-scoped. Run Amp project-scope installation from the destination project root. | | `--mode ` | `local` | Cursor only. `local` copies the bundle into `~/.cursor/plugins/local/`; `marketplace` stages a committed local marketplace repository under `~/.cursor/agent-bundle/marketplaces/` and prints the Customize → Plugins → "Add Plugins from Local Repository" step that makes Cursor manage the plugin as a marketplace install. | | `--replace` | off | Replace an existing agent-bundle install of this plugin even when its version differs. Without it, an identical copy is an `already-installed` no-op, a same-version copy whose content hash differs is replaced automatically, and a different version is `AB7005`. Foreign directories are always refused (`AB7005`). | +| `--no-sideload` | sideload on | `grokbot` only. On a computer with Grok Bot agent-data (`GROK_BOT_AGENT_DATA_DIR`, then `/home/box/agent-data`, `~/.grokbot/agent-data`, the macOS application-support root), `install grokbot` also writes the plugin into Grok Bot's own clone of the marketplace, `/plugins/marketplaces/github.com////`, adds it to that clone's `.cursor-plugin/marketplace.json` (or `.claude-plugin/marketplace.json`), and writes the cache copy `/plugins/cache///` with `.cache-complete`. `` is the clone folder Grok Bot already has. Each folder carries `.agent-bundle-sideload.json` and the receipt records the exact manifest entry written: a folder without the marker, an entry that differs from the recorded one, or a path reached through a symbolic link is never touched, and such a conflict, a missing clone, or missing agent-data skips the sideload instead of failing. Folders land by rename from a fully written sibling, and the manifest is re-read immediately before its atomic rename so entries Grok Bot added meanwhile are kept. If the manifest or the receipt cannot be written, the sideload is rolled back, so nothing unrecorded stays behind. The receipt records every path, reruns are idempotent, and `uninstall grokbot` removes exactly them. Grok Bot loads only plugins its account plugin listing returns: until that lists the plugin, its next plugin sync removes the cache copy, and a new marketplace commit replaces the clone folder. `--no-sideload` or `GROK_BOT_SIDELOAD=0` turns it off. | +| `--sideload-repo ` | `scriptedalchemy/plugins` | `grokbot` only. GitHub marketplace whose Grok Bot clone receives the sideload; `GROK_BOT_SIDELOAD_REPO` sets it from the environment (`GROK_BOT_SIDELOAD_MARKETPLACE`, the earlier name, is still read and must name one repository). Install and uninstall retire a record the first sideload release left at `~/.grokbot/agent-bundle/sideload/.json` (under `$GROK_BOT_HOME` when set), removing only the folders and entry it names. | +| `--sideload-slug ` | `-` | `grokbot` only. Grok Bot's plugin-cache partition for that marketplace (`scriptedalchemy-plugins` by default); `GROK_BOT_SIDELOAD_SLUG` sets it from the environment. | The emitted standalone `install.mjs` accepts the same `--replace`. Cursor copies carry an install receipt (`.agent-bundle-install.json`), replacement @@ -222,7 +225,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `amp`, `claude`, `codex`, `cursor`, or `grokbot` (removes the staged repository and receipt, and names the Grok Bot plugin id to uninstall in Grok Bot). | +| `` | **required** | `amp`, `claude`, `codex`, `cursor`, or `grokbot` (removes the staged repository and receipt, plus the receipted sideload folders, cache copy, and manifest entry that still carry this plugin's marker, and names the Grok Bot plugin id to uninstall in Grok Bot). | | `--from ` | `process.cwd()` | The composite root whose `agent-bundle.manifest.json` identifies the plugin (name, version, marketplace), read exactly as `install` reads it (`AB7001` on the same conditions). | | `--scope ` | `user` | The scope the plugin was installed at (Claude or Amp). | | `--mode ` | `local` | Cursor only: uninstall the `local` copy or the staged `marketplace` repository. | @@ -267,7 +270,7 @@ environment or home a source of purge authority: Doctor and uninstall report the | Option | Default | Meaning | | --- | --- | --- | -| `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, `cursor`, or the opt-in `grokbot` (reports receipts plus the plugin id, version, and installed commit from the Grok Bot computer's plugin cache, `AB7334`). | +| `--host ` | all supported | Host to inspect. Repeatable; `claude`, `codex`, `cursor`, or the opt-in `grokbot` (reports receipts plus the plugin id, version, and installed commit from the Grok Bot computer's plugin cache, `AB7334`, and which parts of a receipted sideload remain and whether Grok Bot loaded it, `AB7335`). | | `--from ` | none | The composite root to compare against, read through its `agent-bundle.manifest.json` as `install` reads it: identity from `application`, each inspected host from its `projections[]` row (`AB7001` when the manifest does not describe the host). | | `--json` | off | Write one machine-readable JSON document. | diff --git a/website/docs/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 140472ec6..4541db69c 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -32,7 +32,7 @@ npx agent-bundle install amp --from artifact --scope user | Claude Code | 委托给 `claude plugin marketplace add` 与 `claude plugin install`。Claude Code 会自行加载插件根目录下的 `hooks/hooks.json` 与 `.mcp.json`,因此 `.claude-plugin/plugin.json` 不带 `hooks` 指针(Claude Code 会把指向该文件的指针报告为重复的 hooks 文件);同一根目录中紧挨着 Codex 与 Cursor 各自清单的文档从不会被 Claude Code 读取。对于 `directory` 类型的市场,Claude Code 会把 `${CLAUDE_PLUGIN_ROOT}` 解析为该目录本身而不是其插件缓存,因此重新构建该目录会改变正在运行的会话所执行的钩子。 | `user`、`project`、`local` | | Codex | 委托给 `codex plugin marketplace add` 与 `codex plugin add`。`.codex-plugin/plugin.json` 指向自己的 `.codex-plugin/hooks.json` 与 `.codex-plugin/mcp.json`,因此 Codex 绝不会回退到发现根目录中另一宿主的文档。 | `user` | | Cursor | 把根目录复制到 `~/.cursor/plugins/local/`(默认的 `--mode local`),因为 Cursor 未发布非交互式安装动词;`--mode marketplace` 则在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步。无论哪种方式,Cursor 都加载 `.cursor-plugin/plugin.json` 所指定的钩子与 MCP 文档,紧挨着清单的 `.cursor-plugin/hooks.json` 与 `.cursor-plugin/mcp.json`,因此插件 hook 会在替换 `${CURSOR_PLUGIN_ROOT}` 后运行,无需 `~/.cursor/hooks.json` 条目。 | `user` | -| Grok Bot | 把 Cursor 投影(bundle 的 `targets` 必须包含 `cursor`)暂存为 `~/.grokbot/agent-bundle/marketplaces/` 下一个已提交的市场仓库(`$GROK_BOT_HOME` 可覆盖 `~/.grokbot`),并写入存储回执。Grok Bot 只从托管的 Git 市场把 Cursor 格式插件安装到用户账户,途径是它的 Marketplace 界面、对话内的插件安装工具或 `grokbot://app/v1/plugin/add?id=`,均以服务端分配的插件 id 为键;没有本地文件夹导入或 CLI 动词,因此 `install grokbot` 打印剩余步骤(托管仓库、添加为市场、在 Grok Bot 中安装),而不是声称已注册。`doctor --host grokbot` 从 Grok Bot 计算机的插件缓存报告插件 id 与已安装的提交(`AB7334`);`uninstall grokbot` 删除暂存与回执,并指出需在 Grok Bot 中卸载的插件 id。 | `user` | +| Grok Bot | 把 Cursor 投影(bundle 的 `targets` 必须包含 `cursor`)暂存为 `~/.grokbot/agent-bundle/marketplaces/` 下一个已提交的市场仓库(`$GROK_BOT_HOME` 可覆盖 `~/.grokbot`),并写入存储回执。Grok Bot 只从托管的 Git 市场把 Cursor 格式插件安装到用户账户,途径是它的 Marketplace 界面、对话内的插件安装工具或 `grokbot://app/v1/plugin/add?id=`,均以服务端分配的插件 id 为键;没有本地文件夹导入或 CLI 动词,因此 `install grokbot` 打印剩余步骤(托管仓库、添加为市场、在 Grok Bot 中安装),而不是声称已注册。在存在 Grok Bot agent-data 的计算机上,它还会以 Grok Bot 已在使用的克隆提交,把插件旁加载到 Grok Bot 自己的 `scriptedalchemy/plugins` 市场克隆(包括其清单)与插件缓存中;只有当账户插件列表包含该插件时 Grok Bot 才会加载这份副本,否则插件同步会删除缓存副本(`--no-sideload`、`--sideload-repo` 与 `--sideload-slug` 见 [CLI 参考](../../reference/cli.mdx#install))。`doctor --host grokbot` 从 Grok Bot 计算机的插件缓存报告插件 id 与已安装的提交(`AB7334`)以及旁加载状态(`AB7335`);`uninstall grokbot` 删除暂存、回执记录的旁加载与回执,并指出需在 Grok Bot 中卸载的插件 id。 | `user` | 由于 Claude 与 Codex 投影始终随行本地市场清单,它们的公开 CLI 可以直接安装该根目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index f8b1bd62e..60cf5ba3d 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -175,7 +175,7 @@ JSON 文档在 `hostValidation` 下携带这些报告,每个被校验的投影 ```sh agent-bundle install [--from ] [--scope ] [--mode ] \ - [--replace] [--json] + [--replace] [--no-sideload] [--sideload-repo ] [--sideload-slug ] [--json] ``` | 选项 | 默认值 | 含义 | @@ -185,6 +185,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Amp 接受 `project` 与 `user`(系统根;项目安装应从目标项目根运行);Codex、Cursor 与 Grok Bot 是 user 作用域。 | | `--mode ` | `local` | 仅限 Cursor。`local` 把捆绑包复制到 `~/.cursor/plugins/local/`;`marketplace` 在 `~/.cursor/agent-bundle/marketplaces/` 下暂存一个已提交的本地市场仓库,并打印 Customize → Plugins →“Add Plugins from Local Repository”这一步,让 Cursor 把该插件当作市场安装来管理。 | | `--replace` | 关闭 | 即使版本不同,也替换该插件已有的 agent-bundle 安装。不带它时,内容完全相同的副本是 `already-installed` 空操作,版本相同但内容哈希不同的副本会被自动替换,版本不同则为 `AB7005`。外来目录总是被拒绝(`AB7005`)。 | +| `--no-sideload` | 默认开启旁加载 | 仅限 `grokbot`。在存在 Grok Bot agent-data 的计算机上(依次查找 `GROK_BOT_AGENT_DATA_DIR`、`/home/box/agent-data`、`~/.grokbot/agent-data`、macOS 应用支持目录),`install grokbot` 还会把插件写入 Grok Bot 自己的市场克隆 `/plugins/marketplaces/github.com////`,在该克隆的 `.cursor-plugin/marketplace.json`(或 `.claude-plugin/marketplace.json`)中添加条目,并写入带 `.cache-complete` 的缓存副本 `/plugins/cache///`。`` 是 Grok Bot 已有的克隆目录。每个目录都带有 `.agent-bundle-sideload.json`,回执还记录写入的确切清单条目:没有该标记的目录、与记录不一致的条目,以及经由符号链接到达的路径都绝不会被触碰;遇到这类冲突、缺少克隆或缺少 agent-data 时跳过旁加载而不是失败。目录通过重命名一个已完整写入的同级目录落地;清单会在原子重命名前立即重新读取,期间 Grok Bot 新增的条目都会保留。若清单或回执无法写入,旁加载会被回滚,不会留下未记录的内容。回执记录每个路径,重复运行是幂等的,`uninstall grokbot` 只删除这些内容。Grok Bot 只加载其账户插件列表返回的插件:在列表包含该插件之前,下一次插件同步会删除缓存副本,市场移到新提交时会替换克隆目录。`--no-sideload` 或 `GROK_BOT_SIDELOAD=0` 关闭旁加载。 | +| `--sideload-repo ` | `scriptedalchemy/plugins` | 仅限 `grokbot`。接收旁加载的 Grok Bot 克隆所对应的 GitHub 市场;也可用环境变量 `GROK_BOT_SIDELOAD_REPO` 设置(旧名称 `GROK_BOT_SIDELOAD_MARKETPLACE` 仍会读取,但只能指定一个仓库)。安装和卸载会清理首个旁加载版本在 `~/.grokbot/agent-bundle/sideload/.json`(设置 `$GROK_BOT_HOME` 时位于其下)留下的记录,只删除其中列出的目录和条目。 | +| `--sideload-slug ` | `-` | 仅限 `grokbot`。该市场在 Grok Bot 插件缓存中的分区(默认 `scriptedalchemy-plugins`);也可用环境变量 `GROK_BOT_SIDELOAD_SLUG` 设置。 | 输出的独立 `install.mjs` 接受同样的 `--replace`。Cursor 副本携带安装回执 (`.agent-bundle-install.json`),替换只触碰归属文件;没有指向本插件回执的目录(包括回执出现之前放置的副本) @@ -206,7 +209,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `amp`、`claude`、`codex`、`cursor` 或 `grokbot`(删除暂存仓库与回执,并给出需要在 Grok Bot 中卸载的插件 id)。 | +| `` | **必填** | `amp`、`claude`、`codex`、`cursor` 或 `grokbot`(删除暂存仓库与回执,以及回执记录且仍带本插件标记的旁加载目录、缓存副本与清单条目,并给出需要在 Grok Bot 中卸载的插件 id)。 | | `--from ` | `process.cwd()` | 其 `agent-bundle.manifest.json` 用于识别插件(名称、版本、市场)的组合根目录,读取方式与 `install` 完全相同(相同条件下为 `AB7001`)。 | | `--scope ` | `user` | 安装时使用的作用域(Claude 或 Amp)。 | | `--mode ` | `local` | 仅限 Cursor:卸载 `local` 副本或已暂存的 `marketplace` 仓库。 | @@ -242,7 +245,7 @@ Doctor 与 uninstall 会把观察到的根报告为 `unrecorded` / 无法证明 | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex`、`cursor`,或需显式指定的 `grokbot`(报告回执,以及 Grok Bot 计算机插件缓存中的插件 id、版本与已安装提交,`AB7334`)。 | +| `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex`、`cursor`,或需显式指定的 `grokbot`(报告回执,以及 Grok Bot 计算机插件缓存中的插件 id、版本与已安装提交,`AB7334`;并报告回执记录的旁加载还剩哪些部分、Grok Bot 是否已加载它,`AB7335`)。 | | `--from ` | 无 | 用于比对的组合根目录,通过其 `agent-bundle.manifest.json` 按 `install` 的方式读取:身份来自 `application`,每个被检查的宿主来自它的 `projections[]` 行(清单未描述该宿主时为 `AB7001`)。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 |