From 6b7aaccc0700dcd1c02756e9ebe3bb741fd79041 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:07:11 -0700 Subject: [PATCH 1/6] feat(install): add a grokbot host that stages a Grok Bot marketplace install grokbot stages the Cursor projection as a committed marketplace repository under ~/.grokbot/agent-bundle/marketplaces/ with a store receipt and prints the remaining Grok Bot steps (host the repository, add it as a marketplace, install from Grok Bot, which assigns the plugin id). doctor --host grokbot reports the plugin id and installed commit from the Grok Bot plugin cache and skill index (AB7334); uninstall grokbot removes the staging and receipt and names the plugin id to remove in Grok Bot. --- .changeset/grokbot-install-host.md | 5 + docs/diagnostics.md | 20 +- packages/agent-bundle/README.md | 10 + .../dev/playground/host-discovery-service.ts | 11 +- packages/agent-bundle/src/install/commands.ts | 23 +- packages/agent-bundle/src/install/doctor.ts | 111 ++++++- packages/agent-bundle/src/install/format.ts | 10 +- packages/agent-bundle/src/install/grokbot.ts | 258 ++++++++++++++++ packages/agent-bundle/src/install/identity.ts | 2 +- packages/agent-bundle/src/install/install.ts | 14 +- packages/agent-bundle/src/install/receipt.ts | 6 +- .../agent-bundle/src/install/uninstall.ts | 139 ++++++--- packages/agent-bundle/src/test/installed.ts | 2 +- packages/agent-bundle/tests/cli.test.ts | 2 +- packages/agent-bundle/tests/doctor.test.ts | 2 +- .../tests/grokbot-install.test.ts | 282 ++++++++++++++++++ .../agent-bundle/tests/install-cli.test.ts | 2 +- .../tests/support/install-fixture.ts | 2 +- .../en/guide/distribution/installation.mdx | 1 + .../zh/guide/distribution/installation.mdx | 1 + 20 files changed, 830 insertions(+), 73 deletions(-) create mode 100644 .changeset/grokbot-install-host.md create mode 100644 packages/agent-bundle/src/install/grokbot.ts create mode 100644 packages/agent-bundle/tests/grokbot-install.test.ts diff --git a/.changeset/grokbot-install-host.md b/.changeset/grokbot-install-host.md new file mode 100644 index 000000000..dfebc50e8 --- /dev/null +++ b/.changeset/grokbot-install-host.md @@ -0,0 +1,5 @@ +--- +'agent-bundle': minor +--- + +Add a `grokbot` install host. `agent-bundle install grokbot` (and `-install install grokbot` from package-bound installer bins) stages the bundle's Cursor projection as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` with a store receipt, and prints the remaining Grok Bot steps: host the repository, add it as a plugin marketplace, and install the plugin from Grok Bot's Marketplace, which assigns the plugin id server-side. `agent-bundle doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`), and `agent-bundle uninstall grokbot` removes the staging and receipt and names the plugin id to uninstall in Grok Bot. diff --git a/docs/diagnostics.md b/docs/diagnostics.md index c4035bf66..7d525b121 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`–`AB7333` | 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), and dangling receipt-owned marketplaces (`AB7333`; see below). `AB7311` and `AB7325` are also emitted by `build` and `validate --artifact` from the Claude load check (see "Claude Code host validation"). | +| `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"). | | `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). | @@ -1261,6 +1261,22 @@ state, never purged, and is listed by `uninstall` as retained. | --- | --- | --- | | `AB7333` | error | A Claude or Codex marketplace recorded as Agent Bundle-owned by an install receipt points at a source directory that no longer exists. Run `agent-bundle uninstall --from --force`, or remove the named marketplace with the host CLI. | +## Read-only Doctor Grok Bot report (`AB7334`) + +`agent-bundle doctor --host grokbot` (never part of the default host set) reads +the receipts `install grokbot` wrote under `~/.grokbot/agent-bundle` (or +`$GROK_BOT_HOME/agent-bundle`) and, on the Grok Bot computer, every completed +copy of the plugin in `/plugins/cache////` +together with the server-assigned plugin id from `/plugin-skills/cache.json` +(`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. + +| 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. | + ## Read-only runtime identity introspection (`AB7317`–`AB7318`) | Code | Severity | Trigger | @@ -1835,7 +1851,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .`, the exhaustive host switch received a host that is not `amp`, `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.`, evaluating the configuration module or discovering source threw before validation. | Install: pass `--host amp`, `claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json`, no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 5` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.`, the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: `, loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.`, the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | -| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor`; `--scope` other than `user` for Codex or Cursor; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.`, the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or, as the message says, stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | +| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor` (including `grokbot`, which always stages a marketplace); `--scope` other than `user` for Codex, Cursor, or Grok Bot; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.`, the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or, as the message says, stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (the native plugin CLI has no qualified settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/README.md b/packages/agent-bundle/README.md index 14cdf8788..0dc891e73 100644 --- a/packages/agent-bundle/README.md +++ b/packages/agent-bundle/README.md @@ -221,6 +221,16 @@ Cursor manage the plugin as a marketplace install; `agent-bundle doctor --host cursor` reports hook registration (`AB7322`), duplicate user-level delivery (`AB7323`), and marketplace import state (`AB7324`). +`agent-bundle install grokbot` (or ` install grokbot` from a package-bound +installer) stages the same Cursor projection as a committed marketplace +repository at `~/.grokbot/agent-bundle/marketplaces/` and prints the +steps Grok Bot needs: host the repository on GitHub, add it as a plugin +marketplace, and install the plugin from Grok Bot's Marketplace. Grok Bot +assigns the plugin id server-side, so nothing is registered locally; +`agent-bundle doctor --host grokbot` reports that id and the installed commit +from the Grok Bot computer's plugin cache (`AB7334`), and `uninstall grokbot` +removes the staging and names the id to uninstall in Grok Bot. + For a root whose only Cursor-loadable format is the `portable` projection, `install.mjs` copies the Agent Plugins package to the same `~/.cursor/plugins/local/` location and, because Cursor 3.18.25 diff --git a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts index cf433990b..5f9cc2997 100644 --- a/packages/agent-bundle/src/dev/playground/host-discovery-service.ts +++ b/packages/agent-bundle/src/dev/playground/host-discovery-service.ts @@ -117,8 +117,11 @@ const discoveryMcpServer = (value: ModernMcpServerEntry): DiscoveryMcpServer => transport: value.server.kind, }); +/** Workbench discovery covers the hosts with a full Doctor report; the opt-in `grokbot` report is not one of them. */ +type DiscoverableHostReport = DoctorHostReport & { readonly host: Exclude }; + const enumerateMcpServers = async ( - value: DoctorHostReport, + value: DiscoverableHostReport, registry: TargetRegistry, run: PlatformRun, ): Promise => { @@ -142,7 +145,7 @@ const enumerateMcpServers = async ( }; const hostReport = async ( - value: DoctorHostReport, + value: DiscoverableHostReport, registry: TargetRegistry, run: PlatformRun, ): Promise => Object.freeze({ @@ -208,7 +211,9 @@ export class HostDiscoveryService implements HostDiscoveryRouteService { ...(bundleSource ? { from: bundleSource } : {}), }); const hosts: readonly DiscoveryHostReport[] = Object.freeze( - await Promise.all(report.hosts.map((value) => hostReport(value, this.#registry, this.#run))), + await Promise.all(report.hosts + .filter((value): value is DiscoverableHostReport => value.host !== 'grokbot') + .map((value) => hostReport(value, this.#registry, this.#run))), ); const endpoints: DiscoveryEndpointReport = endpointReport(report.endpoints); const diagnostics: readonly DiscoveryDiagnostic[] = Object.freeze( diff --git a/packages/agent-bundle/src/install/commands.ts b/packages/agent-bundle/src/install/commands.ts index 0a07b3a55..3634d5c06 100644 --- a/packages/agent-bundle/src/install/commands.ts +++ b/packages/agent-bundle/src/install/commands.ts @@ -52,13 +52,13 @@ interface UninstallCommandOptions { interface DoctorCommandOptions { readonly from?: string; - readonly host: readonly DoctorHost[]; + readonly host: readonly (DoctorHost | 'grokbot')[]; readonly json?: boolean; } export const installHost = (value: string): InstallHost => { - if (value === 'amp' || value === 'claude' || value === 'codex' || value === 'cursor') return value; - throw new InvalidArgumentError('Install host must be amp, claude, codex, or cursor.'); + if (value === 'amp' || value === 'claude' || value === 'codex' || value === 'cursor' || value === 'grokbot') return value; + throw new InvalidArgumentError('Install host must be amp, claude, codex, cursor, or grokbot.'); }; const devInstallHost = (value: string): DevInstallHost => { @@ -79,12 +79,15 @@ const installScope = (value: string): InstallScope => { throw new InvalidArgumentError('Install scope must be user, project, or local.'); }; -const doctorHost = (value: string): DoctorHost => { - if (value === 'claude' || value === 'codex' || value === 'cursor') return value; - throw new InvalidArgumentError('Doctor host must be claude, codex, or cursor.'); +const doctorHost = (value: string): DoctorHost | 'grokbot' => { + if (value === 'claude' || value === 'codex' || value === 'cursor' || value === 'grokbot') return value; + throw new InvalidArgumentError('Doctor host must be claude, codex, cursor, or grokbot.'); }; -const collectDoctorHost = (value: string, previous: readonly DoctorHost[]): readonly DoctorHost[] => +const collectDoctorHost = ( + value: string, + previous: readonly (DoctorHost | 'grokbot')[], +): readonly (DoctorHost | 'grokbot')[] => [...previous, doctorHost(value)]; export const registerLifecycleCommands = (program: Command, options: LifecycleCommandOptions): void => { @@ -96,7 +99,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const installCommand = fromOption( program.command('install') .description('Install a built bundle into a supported host') - .argument('', 'Destination host: amp, claude, codex, or cursor', installHost), + .argument('', 'Destination host: amp, claude, codex, cursor, or grokbot', installHost), 'Target bundle directory or artifact root', true, ) @@ -123,7 +126,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const uninstallCommand = fromOption( program.command('uninstall') .description('Remove a receipt-owned host install of a built bundle, and nothing else') - .argument('', 'Host to uninstall from: amp, claude, codex, or cursor', installHost), + .argument('', 'Host to uninstall from: amp, claude, codex, cursor, or grokbot', installHost), 'Target bundle directory or artifact root that identifies the plugin', true, ) @@ -158,7 +161,7 @@ export const registerLifecycleCommands = (program: Command, options: LifecycleCo const doctorCommand = fromOption( program.command('doctor') .description('Inspect host installs and runtime endpoints without changing them') - .option('--host ', 'Host to inspect (repeatable)', collectDoctorHost, []), + .option('--host ', 'Host to inspect (repeatable; grokbot only when named)', collectDoctorHost, []), 'Target bundle directory or artifact root', ) .option('--json', 'Write one machine-readable JSON document'); diff --git a/packages/agent-bundle/src/install/doctor.ts b/packages/agent-bundle/src/install/doctor.ts index c539195d5..9594e2c21 100644 --- a/packages/agent-bundle/src/install/doctor.ts +++ b/packages/agent-bundle/src/install/doctor.ts @@ -73,11 +73,13 @@ import { inspectCursorMarketplaceStaging, inspectCursorPluginHooks, } from './cursor-hooks-registration.ts'; -import { cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { grokBotMarketplaceRoot, grokBotRoot, readGrokBotInventory, type GrokBotInventory } from './grokbot.ts'; import { bundleInventory, installedBundleInventory, readBundleIdentity, type PluginIdentity } from './identity.ts'; import { inspectInstalledStateOwnership, resolveInstalledStateRoots } from './state-root.ts'; -export type DoctorHost = Exclude; +/** Hosts with a full Doctor report; `grokbot` has its own read-only report (install/grokbot.ts). */ +export type DoctorHost = Exclude; export type DoctorHostProbeStatus = 'available' | 'failed' | 'unavailable'; export type DoctorInventoryStatus = 'known' | 'skipped' | 'unknown'; export type DoctorFindingState = @@ -110,7 +112,7 @@ export interface DoctorOptions { readonly environment?: Readonly; readonly from?: string; readonly home?: string; - readonly hosts?: readonly DoctorHost[]; + readonly hosts?: readonly (DoctorHost | 'grokbot')[]; readonly platform?: NodeJS.Platform; } @@ -159,6 +161,8 @@ export interface DoctorFinding { readonly manifest?: string; readonly name?: string; readonly path?: string; + /** Grok Bot only: the server-assigned plugin id from Grok Bot's skill index. */ + readonly pluginId?: string; /** The in-tree install receipt of a Cursor local copy, when it carries one. */ readonly receipt?: DoctorReceiptSummary; readonly runtime?: DoctorRuntimeStatus; @@ -322,7 +326,7 @@ export interface DoctorHostReport { readonly marketplace?: string; }; readonly diagnostics: readonly Diagnostic[]; - readonly host: DoctorHost; + readonly host: DoctorHost | 'grokbot'; readonly inventory: DoctorInventory; readonly probe: DoctorHostProbe; /** Agent Bundle store receipts under the host root, cross-checked against the host's inventory. */ @@ -385,7 +389,7 @@ const diagnostic = ( message: string, recovery: string, severity: DiagnosticSeverity, - target?: DoctorHost, + target?: DoctorHost | 'grokbot', ): Diagnostic => Object.freeze({ code, message, @@ -1584,7 +1588,7 @@ const receiptFinding = (path: string, receipt: InstallReceipt, state: DoctorRece * thrown. */ const inspectStoreReceipts = async ( - host: DoctorHost, + host: DoctorHost | 'grokbot', hostRoot: string, stateOf: (receipt: InstallReceipt) => Promise, ): Promise<{ readonly diagnostics: readonly Diagnostic[]; readonly receipts: readonly DoctorReceiptFinding[] }> => { @@ -1639,7 +1643,7 @@ const inspectStoreReceipts = async ( 'AB7328', `Agent Bundle receipt ${JSON.stringify(path)} records ${receipt.plugin}@${receipt.version} (${receipt.mode}, scope ${receipt.scope}) ` + `but ${host} no longer holds the registration it describes.`, - `Run \`agent-bundle uninstall ${host} --from ${receipt.mode === 'marketplace' ? ' --mode marketplace' : ''}\` to consume the ` + + `Run \`agent-bundle uninstall ${host} --from ${receipt.mode === 'marketplace' && host === 'cursor' ? ' --mode marketplace' : ''}\` to consume the ` + 'orphaned receipt, or reinstall the plugin.', 'warning', host, @@ -2765,13 +2769,104 @@ const doctorHost = async ( }); }; +/** + * 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 + * the commit Grok Bot installed (`AB7334`). Nothing here can see or change the account-level install itself. + */ +const doctorGrokBot = async (options: DoctorOptions, home: string): Promise => { + const environment = options.environment ?? process.env; + const root = grokBotRoot({ environment, home }); + const diagnostics: Diagnostic[] = []; + const receipts = await inspectStoreReceipts('grokbot', root, async (receipt) => + receipt.mode === 'marketplace' && await exists(join(grokBotMarketplaceRoot(root), receipt.plugin)) ? 'consistent' : 'orphaned'); + diagnostics.push(...receipts.diagnostics); + let identity: PluginIdentity | undefined; + let bundleError: unknown; + if (options.from !== undefined) { + try { + identity = await readBundleIdentity(options.from, 'cursor'); + } catch (error) { + bundleError = error; + } + } + const plugins = [...new Set([ + ...(identity === undefined ? [] : [identity.plugin]), + ...receipts.receipts.map((receipt) => receipt.plugin), + ])].sort(); + const inventories = new Map(); + for (const plugin of plugins) inventories.set(plugin, await readGrokBotInventory(plugin, { environment, home })); + const firstInventory = [...inventories.values()][0] ?? await readGrokBotInventory('', { environment, home }); + const available = firstInventory.status === 'available'; + const findings: DoctorFinding[] = [...inventories.values()].flatMap((inventory, index) => inventory.status !== 'available' + ? [] + : inventory.entries.map((entry) => Object.freeze({ + commit: entry.pluginVersion, + marketplace: entry.marketplace, + name: plugins[index] ?? '', + path: entry.installPath, + ...(entry.pluginId === undefined ? {} : { pluginId: entry.pluginId }), + state: entry.pluginId === undefined ? 'installed' as const : 'registered' as const, + ...(entry.manifestVersion === undefined ? {} : { version: entry.manifestVersion }), + }))); + let bundle: DoctorHostReport['bundle']; + if (identity !== undefined) { + const copies = findings.filter((finding) => finding.name === identity.plugin); + const staged = await exists(join(grokBotMarketplaceRoot(root), identity.plugin)); + const registered = copies.find((finding) => finding.pluginId !== undefined); + const current = copies.find((finding) => finding.version === identity.version); + bundle = Object.freeze({ + bundleRoot: identity.bundleRoot, + marketplace: cursorMarketplaceName(identity.plugin), + name: identity.plugin, + ...(registered?.pluginId === undefined ? {} : { pluginId: registered.pluginId }), + state: registered !== undefined ? 'registered' : copies.length > 0 ? 'installed' : staged ? 'unregistered' : 'missing', + version: identity.version, + }); + const described = copies.map((finding) => + `${finding.pluginId === undefined ? 'no plugin id yet' : `plugin id ${finding.pluginId}`}, version ${finding.version ?? 'unknown'}, ` + + `commit ${finding.commit ?? 'unknown'} from ${finding.marketplace ?? 'unknown marketplace'}`); + diagnostics.push(diagnostic( + 'AB7334', + !available + ? `${identity.plugin}@${identity.version} on grokbot: ${firstInventory.status === 'unavailable' ? firstInventory.reason : ''}` + + `${staged ? ` Staged marketplace: ${join(grokBotMarketplaceRoot(root), identity.plugin)}.` : ''}` + : copies.length === 0 + ? `${identity.plugin}@${identity.version} is not installed in Grok Bot` + + `${staged ? ` (staged at ${join(grokBotMarketplaceRoot(root), identity.plugin)}, not yet imported)` : ''}.` + : `${identity.plugin}@${identity.version} on grokbot: installed (${described.join('; ')}).`, + copies.length > 0 && current === undefined + ? 'Grok Bot installed a different version: push the rebuilt marketplace and update the plugin in Grok Bot, then rerun Doctor.' + : copies.length > 0 + ? 'No action needed.' + : 'Run `agent-bundle install grokbot`, host the staged marketplace, and install the plugin from Grok Bot\'s Marketplace.', + copies.length > 0 && current === undefined ? 'warning' : 'info', + 'grokbot', + )); + } else if (bundleError !== undefined) { + const malformed = malformedBundle('cursor', bundleError); + diagnostics.push(...malformed.diagnostics.map((entry) => Object.freeze({ ...entry, target: 'grokbot' }))); + bundle = malformed.finding; + } + return Object.freeze({ + diagnostics: freezeDiagnostics(diagnostics), + host: 'grokbot', + inventory: freezeInventory(available ? 'known' : 'unknown', findings), + probe: Object.freeze(available ? { evidence: 'directory' as const, status: 'available' as const } : { status: 'unavailable' as const }), + receipts: receipts.receipts, + ...(bundle === undefined ? {} : { bundle }), + }); +}; + export const runDoctor = async (options: DoctorOptions = {}): Promise => { const home = options.home ?? homedir(); const run = options.commandRunner ?? defaultCommandRunner; const hosts = options.hosts ?? Object.freeze(['claude', 'codex', 'cursor'] as const); const uniqueHosts = [...new Set(hosts)]; const hostReports: DoctorHostReport[] = []; - for (const host of uniqueHosts) hostReports.push(await doctorHost(host, options, home, run)); + for (const host of uniqueHosts) { + hostReports.push(host === 'grokbot' ? await doctorGrokBot(options, home) : await doctorHost(host, options, home, run)); + } const endpoints = await scanEndpoints( options.endpointDirectory ?? doctorEndpointDirectory(), options.platform ?? process.platform, diff --git a/packages/agent-bundle/src/install/format.ts b/packages/agent-bundle/src/install/format.ts index 80bab8062..de6d16607 100644 --- a/packages/agent-bundle/src/install/format.ts +++ b/packages/agent-bundle/src/install/format.ts @@ -39,7 +39,7 @@ export const formatInstallResult = (result: InstallResult): string => { const lines = [ `${installVerb(result.state, result.mode)} ${result.plugin}@${result.version} for ${result.host}${mode} at ${destination}${content}`, ]; - if (result.marketplace !== undefined && result.host === 'cursor') { + if (result.marketplace !== undefined && (result.host === 'cursor' || result.host === 'grokbot')) { lines.push(`Marketplace: ${result.marketplace}${result.commit === undefined ? '' : ` @ ${result.commit}`}`); } if (result.nextSteps !== undefined && result.nextSteps.length > 0) { @@ -163,6 +163,7 @@ export const formatDoctorReport = (result: DoctorReport): string => { ? '' : ` ${host.bundle.name}${host.bundle.version === undefined ? '' : `@${host.bundle.version}`}`; out.push(` bundle:${identity} ${host.bundle.state}\n`); + if (host.bundle.pluginId !== undefined) out.push(` plugin id: ${host.bundle.pluginId}\n`); if (host.bundle.comparison !== undefined) { out.push(` installed copy: ${describeInstallComparison(host.bundle.comparison)}\n`); } @@ -176,6 +177,13 @@ export const formatDoctorReport = (result: DoctorReport): string => { out.push(` lifecycle: ${describeLifecycle(host.bundle.lifecycle)}\n`); } } + for (const finding of host.host === 'grokbot' ? host.inventory.findings : []) { + out.push( + ` installed: ${finding.name ?? 'unknown'}@${finding.version ?? 'unknown'} ` + + `(plugin id ${finding.pluginId ?? 'not assigned yet'}, commit ${finding.commit ?? 'unknown'}, ` + + `marketplace ${finding.marketplace ?? 'unknown'}) at ${finding.path ?? 'unknown path'}\n`, + ); + } if (host.receipts.length > 0) { out.push(` receipts: ${host.receipts.length} store receipt(s)\n`); for (const receipt of host.receipts) { diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts new file mode 100644 index 000000000..9e55e7d9c --- /dev/null +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -0,0 +1,258 @@ +import { readdir, readFile, stat } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; + +import { Predicate } from 'effect'; + +import { DiagnosticError } from '../core/diagnostics.ts'; +import { errorMessage, isErrno } from '../core/errors.ts'; +import { exists } from '../core/paths.ts'; +import { cursorMarketplaceRoot, stageCursorMarketplace } from './cursor-marketplace.ts'; +import { bundleInventory, readBundleIdentity } from './identity.ts'; +import { + createInstallReceipt, + installReceiptStorePath, + readInstallReceiptFile, + writeStoredInstallReceipt, +} from './receipt.ts'; +import type { InstallBundleOptions, InstallCommandRunner, InstallResult } from './install.ts'; + +/** + * Grok Bot host (`install grokbot`). + * + * Grok Bot loads Cursor-format plugins (`.cursor-plugin/plugin.json` with + * skills, rules, agents, commands, and `mcpServers`) that are installed into + * the user's Cursor account from a Git marketplace (`.cursor-plugin/ + * marketplace.json`). Its computer clones each installed plugin into + * `/plugins/cache////` (with an empty + * `.cache-complete` marker) and records the server-assigned plugin id per + * skill in `/plugin-skills/cache.json`. Observed 2026-09-29 on the + * Grok Bot computer: registration is an account-level install through Grok + * Bot's Marketplace UI, the in-chat InstallPlugin tool, or a + * `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 only stages a committed marketplace repository built from the + * Cursor projection, records a receipt, and prints the exact remaining steps. + * Doctor reads the cache and skill index read-only to report the plugin id + * and the commit Grok Bot installed. + */ + +export const grokBotHost = 'grokbot'; + +/** Agent Bundle's own Grok Bot root: the marketplace staging repository and receipt store live under it. */ +export const grokBotRoot = (options: { readonly environment?: Readonly; readonly home?: string }): string => + options.environment?.['GROK_BOT_HOME'] ?? join(options.home ?? homedir(), '.grokbot'); + +export const grokBotMarketplaceRoot = (root: string): string => cursorMarketplaceRoot(root); + +export const grokBotReceiptPath = (root: string, plugin: string): string => + installReceiptStorePath(root, plugin, 'marketplace'); + +export const grokBotNextSteps = (repoRoot: string, plugin: string, bin = 'agent-bundle'): readonly string[] => Object.freeze([ + `Push ${repoRoot} to a GitHub repository (or copy plugins/${plugin} and its .cursor-plugin/marketplace.json entry into a ` + + 'marketplace repository you already use): Grok Bot installs plugins only from hosted Git marketplaces, not local folders.', + 'Add that repository as a plugin marketplace on your Cursor account (Grok Bot reads the same account plugin catalog).', + `In Grok Bot, open Marketplace -> Plugins and install "${plugin}" (or ask a Grok Bot agent to install it; ` + + 'grokbot://app/v1/plugin/add?id= opens the install card once the marketplace assigned the id).', + `Verify on the Grok Bot computer with \`${bin} doctor --host grokbot\`: it reports the plugin id and installed commit ` + + 'once Grok Bot has cloned the plugin.', +]); + +const failure = (code: string, message: string): DiagnosticError => + new DiagnosticError([{ code, message, severity: 'error', target: grokBotHost }]); + +/** + * Stages the Cursor projection as a committed marketplace repository under + * `/agent-bundle/marketplaces/` and writes a store + * receipt beside it. Nothing is registered with Grok Bot: the plugin id is + * assigned by the marketplace once the repository is hosted and installed. + */ +export const installGrokBot = async ( + options: InstallBundleOptions, + runner: InstallCommandRunner, + treeHash: (root: string) => Promise, +): Promise => { + if ((options.scope ?? 'user') !== 'user') { + throw failure('AB7003', `Grok Bot plugin installation supports only user scope, not ${options.scope ?? 'user'}.`); + } + if (options.mode !== undefined) { + throw failure('AB7003', `Install mode ${JSON.stringify(options.mode)} applies to the cursor host only.`); + } + const identity = await readBundleIdentity(options.from, 'cursor').catch((error: unknown) => { + if (error instanceof DiagnosticError) { + throw new DiagnosticError(error.diagnostics.map((entry) => ({ + ...entry, + message: `${entry.message} The grokbot host installs the Cursor projection.`, + target: grokBotHost, + }))); + } + throw error; + }); + const root = grokBotRoot(options); + try { + const artifact = await bundleInventory(identity, { restoreModes: true }); + const staged = await stageCursorMarketplace({ artifact, cursorRoot: root, identity, runner, treeHash }); + const receiptPath = grokBotReceiptPath(root, identity.plugin); + const previousReceipt = await readInstallReceiptFile(receiptPath); + if ( + staged.state === 'staged' || + previousReceipt === undefined || + previousReceipt.contentHash !== artifact.hash || + previousReceipt.registrations[0]?.commit !== staged.commit + ) { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + 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, + }], + scope: 'user', + updatedAt: new Date().toISOString(), + version: identity.version, + })); + } + return { + bundleRoot: identity.bundleRoot, + ...(staged.commit === undefined ? {} : { commit: staged.commit }), + contentHash: artifact.hash, + destination: staged.destination, + host: grokBotHost, + marketplace: staged.marketplace, + mode: 'marketplace', + nextSteps: grokBotNextSteps(staged.destination, identity.plugin), + plugin: identity.plugin, + receipt: receiptPath, + state: staged.state, + version: identity.version, + }; + } catch (error) { + if (error instanceof DiagnosticError) { + throw new DiagnosticError(error.diagnostics.map((entry) => ({ ...entry, target: grokBotHost }))); + } + throw failure('AB7004', errorMessage(error)); + } +}; + +/** 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///`. */ + readonly installPath: string; + /** Cache partition: Grok Bot's slug for the marketplace repository (`owner-repo`). */ + readonly marketplace: string; + /** `version` from the cached `.cursor-plugin/plugin.json`. */ + readonly manifestVersion?: string; + /** Server-assigned plugin id from `plugin-skills/cache.json`; absent when the plugin contributes no skill yet. */ + readonly pluginId?: string; + /** The version Grok Bot installed: the marketplace commit for Git marketplaces. */ + readonly pluginVersion: string; +} + +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(); + if (!Predicate.isObject(document) || !Array.isArray(document['skills'])) return ids; + for (const skill of document['skills'] as unknown[]) { + 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); + } + 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. + */ +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; + } + } + 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).', + status: 'unavailable', + }); + } + const ids = skillIndexIds(await readJsonFile(join(agentData, 'plugin-skills', 'cache.json'))); + const cacheRoot = join(agentData, 'plugins', 'cache'); + const entries: GrokBotInstalledPlugin[] = []; + for (const marketplace of await listDirectory(cacheRoot)) { + const pluginRoot = join(cacheRoot, marketplace, plugin); + for (const version of await listDirectory(pluginRoot)) { + const installPath = join(pluginRoot, version); + if (!(await exists(join(installPath, '.cache-complete')))) continue; + 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}`); + entries.push(Object.freeze({ + installPath, + marketplace, + ...(typeof manifest['version'] === 'string' ? { manifestVersion: manifest['version'] } : {}), + ...(pluginId === undefined ? {} : { pluginId }), + pluginVersion: version, + })); + } + } + return Object.freeze({ agentData, entries: Object.freeze(entries), status: 'available' }); +}; diff --git a/packages/agent-bundle/src/install/identity.ts b/packages/agent-bundle/src/install/identity.ts index 7e9eba7d1..f509a5c5a 100644 --- a/packages/agent-bundle/src/install/identity.ts +++ b/packages/agent-bundle/src/install/identity.ts @@ -26,7 +26,7 @@ export interface PluginIdentity { export const failure = ( code: string, message: string, - target: BundleIdentityHost, + target: BundleIdentityHost | 'grokbot', ): DiagnosticError => new DiagnosticError([{ code, message, diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 7dd4056e5..6bf68cd50 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,6 +12,7 @@ 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 { installGrokBot } from './grokbot.ts'; import { bundleInventory, failure, @@ -45,9 +46,10 @@ import { } from './receipt.ts'; import { recordInstalledState } from './state-root.ts'; -export type InstallHost = BundleIdentityHost; -export type DevInstallHost = Exclude; -export type PublicInstallHost = Exclude; +/** `grokbot` installs the Cursor projection through a Grok Bot marketplace; see install/grokbot.ts. */ +export type InstallHost = BundleIdentityHost | 'grokbot'; +export type DevInstallHost = Exclude; +export type PublicInstallHost = Exclude; export type InstallScope = 'local' | 'project' | 'user'; export type InstallResultState = 'already-installed' | 'installed' | 'replaced' | 'staged'; @@ -1255,6 +1257,9 @@ const installProgram = Effect.fnUntraced(function*( options: InstallBundleOptions, ): Effect.fn.Return { const scope = options.scope ?? 'user'; + if (options.host === 'grokbot') { + return yield* liftPromise(() => installGrokBot(options, options.commandRunner ?? defaultCommandRunner, treeHash)); + } if (options.mode !== undefined && options.host !== 'cursor') { return yield* Effect.fail(failure( 'AB7003', @@ -1262,7 +1267,8 @@ const installProgram = Effect.fnUntraced(function*( options.host, )); } - const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); + const host = options.host; + const identity = yield* liftPromise(() => readBundleIdentity(options.from, host)); switch (options.host) { case 'amp': return yield* liftPromise(() => installAmp(options, identity, scope)); diff --git a/packages/agent-bundle/src/install/receipt.ts b/packages/agent-bundle/src/install/receipt.ts index 0e7c6435c..308613d64 100644 --- a/packages/agent-bundle/src/install/receipt.ts +++ b/packages/agent-bundle/src/install/receipt.ts @@ -69,7 +69,8 @@ export type InstallRegistrationKind = | 'codex-marketplace' | 'codex-plugin' | 'cursor-local-plugin' - | 'cursor-marketplace-staging'; + | 'cursor-marketplace-staging' + | 'grokbot-marketplace-staging'; export const installRegistrationKinds: readonly InstallRegistrationKind[] = Object.freeze([ 'amp-project-plugin', @@ -80,10 +81,11 @@ export const installRegistrationKinds: readonly InstallRegistrationKind[] = Obje 'codex-plugin', 'cursor-local-plugin', 'cursor-marketplace-staging', + 'grokbot-marketplace-staging', ]); export interface InstallRegistration { - /** Staged marketplace HEAD commit (`cursor-marketplace-staging`). */ + /** Staged marketplace HEAD commit (`cursor-marketplace-staging`, `grokbot-marketplace-staging`). */ readonly commit?: string; /** `@` for plugin registrations. */ readonly id?: string; diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index af7984dc0..1d8223929 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -12,6 +12,7 @@ import { runPromise } from '../effect/boundary.ts'; import { liftPromise } from '../effect/lift.ts'; import { cacheHasPlugin, readHeadCommit } from './cursor-hooks-registration.ts'; import { cursorMarketplaceName, cursorMarketplacePluginPath, cursorMarketplaceRoot } from './cursor-marketplace.ts'; +import { grokBotRoot, readGrokBotInventory } from './grokbot.ts'; import { ampInstallLocation, cursorMarketplaceReceiptPath, @@ -745,36 +746,96 @@ const stagedWorkingTreeDirt = async (runner: InstallCommandRunner, repoRoot: str `${entries.length === 1 ? 'entry' : 'entries'}: ${shown}${entries.length > 5 ? ', …' : ''}) that the receipt does not own.`; }; -const uninstallCursorMarketplace = async ( +/** + * The host a staged marketplace repository was built for: Cursor's Customize import (`install cursor --mode + * marketplace`) or a hosted Grok Bot marketplace (`install grokbot`). Both stage the same committed repository + * under `/agent-bundle/marketplaces/`; they differ in the root, the receipt's registration kind, + * and how an imported copy is detected and removed. + */ +interface MarketplaceStagingHost { + readonly host: 'cursor' | 'grokbot'; + readonly imported: (marketplace: string, receipt: InstallReceipt | undefined, commit: string | undefined) => + Promise<{ readonly detail: string; readonly nextStep: string } | undefined>; + readonly installCommand: string; + readonly kind: 'cursor-marketplace-staging' | 'grokbot-marketplace-staging'; + readonly label: string; + readonly root: string; +} + +const cursorStagingHost = (options: UninstallBundleOptions, identity: PluginIdentity): MarketplaceStagingHost => ({ + host: 'cursor', + // A completed copy Cursor imported from the recorded staging commit is Cursor-owned, whether or not the + // staged repository itself still exists: the receipt's commit — and the version the receipt recorded, not the + // version the bundle has been rebuilt to since — is what identifies it. + imported: async (marketplace, receipt, commit) => + commit !== undefined && + await cacheHasPlugin(options.home ?? homedir(), marketplace, identity.plugin, receipt?.version ?? identity.version, commit) + ? { + detail: `Cursor imported this marketplace (a completed copy exists under ~/.cursor/plugins/cache/${marketplace}); ` + + 'its installed-plugin registry is server-assigned and exposes no non-interactive removal verb.', + nextStep: `Open Cursor, then Customize -> Plugins, and uninstall "${identity.plugin}" (marketplace ${marketplace}) there.`, + } + : undefined, + installCommand: '`agent-bundle install cursor --mode marketplace`', + kind: 'cursor-marketplace-staging', + label: 'Cursor', + root: join(options.home ?? homedir(), '.cursor'), +}); + +const grokBotStagingHost = (options: UninstallBundleOptions, identity: PluginIdentity): MarketplaceStagingHost => ({ + host: 'grokbot', + // Grok Bot clones installed plugins from the hosted marketplace, never from the staged repository, so any + // completed copy in its cache is the account install the user made; it is reported, never removed. + imported: async () => { + const inventory = await readGrokBotInventory(identity.plugin, options); + if (inventory.status !== 'available' || inventory.entries.length === 0) return undefined; + const ids = [...new Set(inventory.entries.map((entry) => entry.pluginId).filter((id) => id !== undefined))]; + const copies = inventory.entries.map((entry) => + `${entry.marketplace}@${entry.pluginVersion}${entry.pluginId === undefined ? '' : ` (plugin id ${entry.pluginId})`}`); + return { + detail: `Grok Bot has ${identity.plugin} installed on this account (${copies.join(', ')}); ` + + 'the account install is server-side and exposes no non-interactive removal verb.', + nextStep: `In Grok Bot, open Settings -> Plugins and uninstall "${identity.plugin}"` + + `${ids.length === 0 ? '' : ` (plugin id ${ids.join(', ')})`}, or ask a Grok Bot agent to uninstall that plugin id.`, + }; + }, + installCommand: '`agent-bundle install grokbot`', + kind: 'grokbot-marketplace-staging', + label: 'Grok Bot', + root: grokBotRoot(options), +}); + +const uninstallStagedMarketplace = async ( options: UninstallBundleOptions, identity: PluginIdentity, policy: UninstallDataPolicy, + target: MarketplaceStagingHost, ): Promise => { const force = options.force === true; const marketplace = cursorMarketplaceName(identity.plugin); const base = { bundleRoot: identity.bundleRoot, forced: force, - host: 'cursor', + host: target.host, marketplace, mode: 'marketplace', plugin: identity.plugin, scope: 'user', version: identity.version, } as const; - const cursorRoot = join(options.home ?? homedir(), '.cursor'); - const marketplacesRoot = cursorMarketplaceRoot(cursorRoot); + const hostRoot = target.root; + const marketplacesRoot = cursorMarketplaceRoot(hostRoot); const repoRoot = join(marketplacesRoot, identity.plugin); - const receiptPath = cursorMarketplaceReceiptPath(cursorRoot, identity.plugin); + const receiptPath = cursorMarketplaceReceiptPath(hostRoot, identity.plugin); const data: UninstallDataReport = Object.freeze({ - detail: 'A staged marketplace repository holds no runtime state; a copy Cursor imported from it is Cursor-owned and is not touched.', + detail: `A staged marketplace repository holds no runtime state; a copy ${target.label} imported from it is ${target.label}-owned and is not touched.`, outcome: 'unavailable', paths: Object.freeze([]), policy, }); - const cursorHome = await realDirectory(cursorRoot, 'cursor'); - const repo = cursorHome === undefined ? undefined : await realDirectory(repoRoot, 'cursor'); - const receipt = cursorHome === undefined ? undefined : await readInstallReceiptFile(receiptPath); + const home = await realDirectory(hostRoot, target.host); + const repo = home === undefined ? undefined : await realDirectory(repoRoot, target.host); + const receipt = home === undefined ? undefined : await readInstallReceiptFile(receiptPath); if (repo === undefined && receipt === undefined) { return Object.freeze({ ...base, @@ -783,7 +844,7 @@ const uninstallCursorMarketplace = async ( receipt: receiptReport(receiptPath, undefined, 'missing'), registrations: Object.freeze([Object.freeze({ action: 'already-absent' as const, - kind: 'cursor-marketplace-staging' as const, + kind: target.kind, name: marketplace, })]), removed: Object.freeze({ directories: Object.freeze([]), files: Object.freeze([]) }), @@ -792,16 +853,16 @@ const uninstallCursorMarketplace = async ( }); } let status: UninstallReceiptStatus = receipt === undefined ? 'forced-missing' : 'consumed'; - const recorded = receipt?.registrations.find((registration) => registration.kind === 'cursor-marketplace-staging'); + const recorded = receipt?.registrations.find((registration) => registration.kind === target.kind); if (repo !== undefined) { if (receipt === undefined) { if (!force) { throw failure( 'AB7009', - `Refusing to remove staged Cursor marketplace ${repoRoot} without an install receipt at ${receiptPath}. ` + + `Refusing to remove staged ${target.label} marketplace ${repoRoot} without an install receipt at ${receiptPath}. ` + 'Re-run with --force to remove it after verifying it is this plugin\'s staging, or rerun ' + - '`agent-bundle install cursor --mode marketplace` to record a receipt first.', - 'cursor', + `${target.installCommand} to record a receipt first.`, + target.host, ); } if (!await readMarketplaceStagingIdentity(repoRoot, identity.plugin)) { @@ -809,14 +870,14 @@ const uninstallCursorMarketplace = async ( 'AB7007', `Refusing to remove ${repoRoot}: it is not a staged Agent Bundle marketplace for ${identity.plugin} ` + `(expected .cursor-plugin/marketplace.json naming ${marketplace} and plugins/${identity.plugin}). --force does not apply.`, - 'cursor', + target.host, ); } } else if (receipt.plugin !== identity.plugin) { throw failure( 'AB7007', `Refusing to remove ${repoRoot}: the receipt at ${receiptPath} names plugin ${JSON.stringify(receipt.plugin)}.`, - 'cursor', + target.host, ); } else { const head = await readHeadCommit(repoRoot); @@ -826,7 +887,7 @@ const uninstallCursorMarketplace = async ( 'AB7007', `Refusing to remove ${repoRoot}: its HEAD is ${head ?? 'unresolvable'} but the receipt recorded commit ` + `${recorded.commit}, so the staged repository changed after staging. Re-run with --force to remove it anyway.`, - 'cursor', + target.host, ); } status = 'forced-mismatch'; @@ -839,8 +900,8 @@ const uninstallCursorMarketplace = async ( throw failure( 'AB7007', `Refusing to remove ${repoRoot}: ${dirty} Move those entries out (or commit them and rerun ` + - '`agent-bundle install cursor --mode marketplace`), or re-run with --force to remove them anyway.', - 'cursor', + `${target.installCommand}), or re-run with --force to remove them anyway.`, + target.host, ); } status = 'forced-mismatch'; @@ -848,29 +909,23 @@ const uninstallCursorMarketplace = async ( } } } - // A completed copy Cursor imported from the recorded staging commit is Cursor-owned, whether or not the - // staged repository itself still exists: the receipt's commit — and the version the receipt recorded, not the - // version the bundle has been rebuilt to since — is what identifies it. - const imported = recorded?.commit !== undefined - ? await cacheHasPlugin(options.home ?? homedir(), marketplace, identity.plugin, receipt?.version ?? identity.version, recorded.commit) - : false; + const imported = await target.imported(marketplace, receipt, recorded?.commit); const planned = options.plan === true; const registrations: UninstallRegistrationReport[] = [Object.freeze({ action: repo === undefined ? 'already-absent' as const : planned ? 'planned' as const : 'removed' as const, ...(recorded?.commit === undefined ? {} : { commit: recorded.commit }), - kind: 'cursor-marketplace-staging' as const, + kind: target.kind, name: marketplace, })]; const nextSteps: string[] = []; - if (imported) { + if (imported !== undefined) { registrations.push(Object.freeze({ action: 'manual', - detail: `Cursor imported this marketplace (a completed copy exists under ~/.cursor/plugins/cache/${marketplace}); ` + - 'its installed-plugin registry is server-assigned and exposes no non-interactive removal verb.', - kind: 'cursor-marketplace-staging', + detail: imported.detail, + kind: target.kind, name: marketplace, })); - nextSteps.push(`Open Cursor, then Customize -> Plugins, and uninstall "${identity.plugin}" (marketplace ${marketplace}) there.`); + nextSteps.push(imported.nextStep); } const files = receipt === undefined && !await exists(receiptPath) ? [] : [receiptPath]; const directories = repo === undefined ? [] : [repoRoot]; @@ -878,8 +933,8 @@ const uninstallCursorMarketplace = async ( // Exactly the directories the run below would prune, in its order: the receipt store, the staging root, then // the Agent Bundle namespace once both are gone. const gone = new Set([...files, ...directories]); - const namespace = join(cursorRoot, 'agent-bundle'); - const wouldPruneStore = await wouldPrune(installReceiptStoreDirectory(cursorRoot), gone) ? [installReceiptStoreDirectory(cursorRoot)] : []; + const namespace = join(hostRoot, 'agent-bundle'); + const wouldPruneStore = await wouldPrune(installReceiptStoreDirectory(hostRoot), gone) ? [installReceiptStoreDirectory(hostRoot)] : []; const wouldPruneMarketplaces = await wouldPrune(marketplacesRoot, gone) ? [marketplacesRoot] : []; const wouldPruneNamespace = await wouldPrune(namespace, gone) ? [namespace] : []; return Object.freeze({ @@ -900,9 +955,9 @@ const uninstallCursorMarketplace = async ( // The whole repository is installer-created (mkdtemp + rename), and the receipt just proved HEAD is // the commit that staging wrote (or --force accepted the difference): removing it wholesale is bounded. if (repo !== undefined) await rm(repoRoot, { force: true, recursive: true }); - const removedReceipt = await removeStoredInstallReceipt(receiptPath, cursorRoot); + const removedReceipt = await removeStoredInstallReceipt(receiptPath, hostRoot); const prunedMarketplaces = await pruneEmptyDirectory(marketplacesRoot) ? [marketplacesRoot] : []; - const prunedNamespace = await pruneEmptyDirectory(join(cursorRoot, 'agent-bundle')) ? [join(cursorRoot, 'agent-bundle')] : []; + const prunedNamespace = await pruneEmptyDirectory(join(hostRoot, 'agent-bundle')) ? [join(hostRoot, 'agent-bundle')] : []; return Object.freeze({ ...base, data, @@ -1621,7 +1676,17 @@ const uninstallProgram = Effect.fnUntraced(function*( )); } const policy = resolveDataPolicy(options); - const identity = yield* liftPromise(() => readBundleIdentity(options.from, options.host)); + if (options.host === 'grokbot') { + if (scope !== 'user') { + return yield* Effect.fail(failure('AB7003', `Grok Bot plugin uninstallation supports only user scope, not ${scope}.`, 'grokbot')); + } + // The grokbot host stages the Cursor projection, so the Cursor projection identifies the plugin. + const cursorIdentity = yield* liftPromise(() => readBundleIdentity(options.from, 'cursor')); + return yield* liftPromise(() => + uninstallStagedMarketplace(options, cursorIdentity, policy, grokBotStagingHost(options, cursorIdentity))); + } + const host = options.host; + const identity = yield* liftPromise(() => readBundleIdentity(options.from, host)); switch (options.host) { case 'amp': return yield* liftPromise(() => uninstallAmp(options, identity, scope, policy)); @@ -1635,7 +1700,7 @@ const uninstallProgram = Effect.fnUntraced(function*( } // Cursor requires an existing home for install; uninstalling from a missing home is simply nothing to do. return yield* liftPromise(() => options.mode === 'marketplace' - ? uninstallCursorMarketplace(options, identity, policy) + ? uninstallStagedMarketplace(options, identity, policy, cursorStagingHost(options, identity)) : uninstallCursorLocal(options, identity, policy)); } default: { diff --git a/packages/agent-bundle/src/test/installed.ts b/packages/agent-bundle/src/test/installed.ts index ce884e097..7e6c077cd 100644 --- a/packages/agent-bundle/src/test/installed.ts +++ b/packages/agent-bundle/src/test/installed.ts @@ -19,7 +19,7 @@ import { type AgentTestProofLevel, } from './manifest.ts'; -type InstalledMcpHost = Exclude; +type InstalledMcpHost = Exclude; export type InstalledHostCheckName = | 'component-paths' diff --git a/packages/agent-bundle/tests/cli.test.ts b/packages/agent-bundle/tests/cli.test.ts index ad4146a09..be346b3fa 100644 --- a/packages/agent-bundle/tests/cli.test.ts +++ b/packages/agent-bundle/tests/cli.test.ts @@ -1298,7 +1298,7 @@ it('reports invalid CLI arguments as Commander usage errors', async () => { const invalidInstallHost = await runSourceCliWithOutput(['install', 'windsurf']); expect(invalidInstallHost.code).toBe(2); expect(invalidInstallHost.stderr).toContain( - "error: command-argument value 'windsurf' is invalid for argument 'host'. Install host must be amp, claude, codex, or cursor.", + "error: command-argument value 'windsurf' is invalid for argument 'host'. Install host must be amp, claude, codex, cursor, or grokbot.", ); expect(invalidInstallHost.stderr).not.toContain('AB5000'); }); diff --git a/packages/agent-bundle/tests/doctor.test.ts b/packages/agent-bundle/tests/doctor.test.ts index 05bbacb0e..31bd07e03 100644 --- a/packages/agent-bundle/tests/doctor.test.ts +++ b/packages/agent-bundle/tests/doctor.test.ts @@ -3023,7 +3023,7 @@ it('rejects an invalid Doctor host as a usage error', async () => { const terminal = captureCliTerminal(); const code = await runCli(['doctor', '--host', 'portable'], terminal.output); expect(code).toBe(2); - expect(terminal.stderr()).toContain('Doctor host must be claude, codex, or cursor.'); + expect(terminal.stderr()).toContain('Doctor host must be claude, codex, cursor, or grokbot.'); }); const writeHookedCursorPlugin = async (pluginRoot: string, version = '1.2.3'): Promise => { diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts new file mode 100644 index 000000000..3ef463d58 --- /dev/null +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -0,0 +1,282 @@ +import { access, mkdir, mkdtemp, readFile, realpath, 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 { formatDoctorReport, formatInstallResult, formatUninstallResult } from '../src/install/format.ts'; +import { readGrokBotInventory } from '../src/install/grokbot.ts'; +import { installBundle } from '../src/install/install.ts'; +import { 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 writeJson = async (path: string, value: unknown): Promise => { + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, `${JSON.stringify(value, null, 2)}\n`); +}; + +interface Sandbox { + readonly agentData: string; + readonly cleanup: () => Promise; + readonly environment: Readonly; + readonly from: string; + readonly home: string; +} + +/** A built Cursor projection named `grok-fixture`, an isolated home, and an empty Grok Bot agent-data root. */ +const sandbox = async (): Promise => { + const root = await realpath(await mkdtemp(join(tmpdir(), 'agent-bundle-grokbot-'))); + const from = join(root, 'bundle'); + await mkdir(from, { recursive: true }); + await writeFile(join(from, 'payload.txt'), 'payload\n'); + await writeJson(join(from, '.cursor-plugin/plugin.json'), { 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, + }; +}; + +/** What Grok Bot leaves after installing the plugin from a hosted marketplace: a completed cache copy plus its skill index row. */ +const simulateGrokBotInstall = async (box: Sandbox, version = '1.2.3'): Promise => { + const commit = '0401b288f8d5b444bc445ed4c3ac2b94fb24e02c'; + const installPath = join(box.agentData, 'plugins', 'cache', 'scriptedalchemy-plugins', 'grok-fixture', commit); + await writeJson(join(installPath, '.cursor-plugin', 'plugin.json'), { name: 'grok-fixture', version }); + await writeFile(join(installPath, '.cache-complete'), ''); + await writeJson(join(box.agentData, 'plugin-skills', 'cache.json'), { + skills: [ + { + // The index may still spell the legacy sand-data root; only the cache-relative tail identifies the copy. + installPath: `/home/box/sand-data/plugins/cache/scriptedalchemy-plugins/grok-fixture/${commit}`, + name: 'talk', + pluginId: '69001364', + pluginName: 'grok-fixture', + pluginVersion: commit, + }, + { installPath: '/elsewhere', pluginId: '1', pluginName: 'other' }, + ], + }); + return commit; +}; + +it('stages the Cursor projection as a Grok Bot marketplace repository with a receipt and honest next steps', async () => { + const box = await sandbox(); + try { + const result = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + const repo = join(box.home, '.grokbot', 'agent-bundle', 'marketplaces', 'grok-fixture'); + expect(result).toMatchObject({ + destination: repo, + host: 'grokbot', + marketplace: 'grok-fixture-marketplace', + mode: 'marketplace', + plugin: 'grok-fixture', + state: 'staged', + version: '1.2.3', + }); + expect(result.commit).toMatch(/^[0-9a-f]{40}$/u); + expect(JSON.parse(await readFile(join(repo, '.cursor-plugin', 'marketplace.json'), 'utf8'))).toEqual({ + metadata: { description: 'Agent Bundle local marketplace for grok-fixture@1.2.3.' }, + name: 'grok-fixture-marketplace', + owner: { name: 'grok-fixture' }, + plugins: [{ name: 'grok-fixture', source: 'plugins/grok-fixture' }], + }); + expect(await readFile(join(repo, 'plugins', 'grok-fixture', 'payload.txt'), 'utf8')).toBe('payload\n'); + expect(result.nextSteps?.join('\n')).toContain('hosted Git marketplaces'); + expect(result.nextSteps?.join('\n')).toContain('grokbot://app/v1/plugin/add?id='); + expect(formatInstallResult(result)).toContain(`Marketplace: grok-fixture-marketplace @ ${result.commit}`); + + const receipt = await readInstallReceiptFile(join(box.home, '.grokbot', 'agent-bundle', 'receipts', 'grok-fixture.marketplace.json')); + expect(receipt).toMatchObject({ + host: 'grokbot', + mode: 'marketplace', + plugin: 'grok-fixture', + registrations: [{ commit: result.commit, kind: 'grokbot-marketplace-staging', name: 'grok-fixture-marketplace' }], + scope: 'user', + version: '1.2.3', + }); + // Nothing is written into Grok Bot's own plugin cache: registration is server-side. + await expect(access(join(box.agentData, 'plugins', 'cache', 'grok-fixture-marketplace'))).rejects.toMatchObject({ code: 'ENOENT' }); + + const again = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(again).toMatchObject({ commit: result.commit, state: 'already-installed' }); + } finally { + await box.cleanup(); + } +}); + +it('refuses a non-user scope and an explicit mode for grokbot', async () => { + const box = await sandbox(); + try { + const scoped = await installBundle({ from: box.from, home: box.home, host: 'grokbot', scope: 'project' }) + .catch((error: unknown) => error); + expect((scoped as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7003', target: 'grokbot' }]); + const moded = await installBundle({ from: box.from, home: box.home, host: 'grokbot', mode: 'marketplace' }) + .catch((error: unknown) => error); + expect((moded as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7003', target: 'grokbot' }]); + } finally { + await box.cleanup(); + } +}); + +it('reads the plugin id and installed commit from the Grok Bot cache and skill index', async () => { + const box = await sandbox(); + try { + expect(await readGrokBotInventory('grok-fixture', { environment: box.environment, home: box.home })) + .toEqual({ agentData: box.agentData, entries: [], status: 'available' }); + const commit = await simulateGrokBotInstall(box); + expect(await readGrokBotInventory('grok-fixture', { environment: box.environment, home: box.home })).toEqual({ + agentData: box.agentData, + entries: [{ + installPath: join(box.agentData, 'plugins', 'cache', 'scriptedalchemy-plugins', 'grok-fixture', commit), + manifestVersion: '1.2.3', + marketplace: 'scriptedalchemy-plugins', + pluginId: '69001364', + pluginVersion: commit, + }], + status: 'available', + }); + const missing = await readGrokBotInventory('grok-fixture', { environment: {}, home: box.home }); + // The default candidates include /home/box/agent-data, which exists only on a Grok Bot computer. + if (missing.status === 'available') expect(missing.agentData).toBe('/home/box/agent-data'); + else expect(missing.reason).toContain('GROK_BOT_AGENT_DATA_DIR'); + } finally { + await box.cleanup(); + } +}); + +it('doctor --host grokbot reports the plugin id and version once Grok Bot installed it', async () => { + const box = await sandbox(); + try { + const before = await runDoctor({ + endpointDirectory: join(box.home, 'endpoints'), + environment: box.environment, + from: box.from, + home: box.home, + hosts: ['grokbot'], + }); + expect(before.hosts.map((host) => host.host)).toEqual(['grokbot']); + expect(before.hosts[0]?.bundle).toMatchObject({ name: 'grok-fixture', state: 'missing', version: '1.2.3' }); + expect(before.diagnostics.find((entry) => entry.code === 'AB7334')?.message).toContain('is not installed in Grok Bot'); + + await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + const staged = await runDoctor({ + endpointDirectory: join(box.home, 'endpoints'), + environment: box.environment, + from: box.from, + home: box.home, + hosts: ['grokbot'], + }); + expect(staged.hosts[0]?.bundle?.state).toBe('unregistered'); + expect(staged.hosts[0]?.receipts).toMatchObject([{ plugin: 'grok-fixture', state: 'consistent' }]); + + const commit = await simulateGrokBotInstall(box); + const installed = await runDoctor({ + endpointDirectory: join(box.home, 'endpoints'), + environment: box.environment, + from: box.from, + home: box.home, + hosts: ['grokbot'], + }); + const report = installed.hosts[0]; + expect(report?.probe).toEqual({ evidence: 'directory', status: 'available' }); + expect(report?.bundle).toMatchObject({ pluginId: '69001364', state: 'registered', version: '1.2.3' }); + expect(report?.inventory.findings).toMatchObject([{ + commit, + marketplace: 'scriptedalchemy-plugins', + name: 'grok-fixture', + pluginId: '69001364', + state: 'registered', + version: '1.2.3', + }]); + const diagnostic = installed.diagnostics.find((entry) => entry.code === 'AB7334'); + expect(diagnostic).toMatchObject({ severity: 'info', target: 'grokbot' }); + expect(diagnostic?.message).toContain(`plugin id 69001364, version 1.2.3, commit ${commit}`); + const text = formatDoctorReport(installed); + expect(text).toContain('plugin id: 69001364'); + expect(text).toContain(`installed: grok-fixture@1.2.3 (plugin id 69001364, commit ${commit}, marketplace scriptedalchemy-plugins)`); + } finally { + await box.cleanup(); + } +}); + +it('warns when Grok Bot installed a different version than the bundle', async () => { + const box = await sandbox(); + try { + await simulateGrokBotInstall(box, '1.0.0'); + const report = await runDoctor({ + endpointDirectory: join(box.home, 'endpoints'), + environment: box.environment, + from: box.from, + home: box.home, + hosts: ['grokbot'], + }); + expect(report.diagnostics.find((entry) => entry.code === 'AB7334')).toMatchObject({ severity: 'warning' }); + } finally { + await box.cleanup(); + } +}); + +it('uninstall grokbot removes the receipted staging and names the server-side plugin id to remove by hand', async () => { + const box = await sandbox(); + try { + const installed = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + await simulateGrokBotInstall(box); + const planned = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot', plan: true }); + expect(planned.state).toBe('planned'); + await access(installed.destination ?? ''); + + const result = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(result).toMatchObject({ host: 'grokbot', mode: 'marketplace', state: 'uninstalled' }); + expect(result.registrations).toMatchObject([ + { action: 'removed', commit: installed.commit, kind: 'grokbot-marketplace-staging' }, + { action: 'manual', kind: 'grokbot-marketplace-staging' }, + ]); + expect(result.nextSteps?.[0]).toContain('plugin id 69001364'); + expect(formatUninstallResult(result)).toContain('Uninstalled grok-fixture@1.2.3 for grokbot (marketplace mode)'); + await expect(access(installed.destination ?? '')).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(access(join(box.home, '.grokbot', 'agent-bundle'))).rejects.toMatchObject({ code: 'ENOENT' }); + + const again = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(again.state).toBe('not-installed'); + } finally { + await box.cleanup(); + } +}); + +it('package-bound installer bins accept grokbot for install, uninstall, and doctor', async () => { + const box = await sandbox(); + const previous = { data: process.env['GROK_BOT_AGENT_DATA_DIR'], home: process.env['GROK_BOT_HOME'] }; + process.env['GROK_BOT_AGENT_DATA_DIR'] = box.agentData; + process.env['GROK_BOT_HOME'] = join(box.home, '.grokbot'); + try { + let stdout = ''; + 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'])).toBe(0); + expect(stdout).toContain('Staged grok-fixture@1.2.3 for grokbot (marketplace mode)'); + stdout = ''; + expect(await run(['doctor', '--host', 'grokbot'])).toBe(0); + expect(stdout).toContain('grokbot: available (directory)'); + stdout = ''; + expect(await run(['uninstall', 'grokbot'])).toBe(0); + expect(stdout).toContain('Uninstalled grok-fixture@1.2.3 for grokbot'); + } finally { + for (const [key, value] of [['GROK_BOT_AGENT_DATA_DIR', previous.data], ['GROK_BOT_HOME', previous.home]] as const) { + if (value === undefined) delete process.env[key]; + else process.env[key] = value; + } + await box.cleanup(); + } +}); diff --git a/packages/agent-bundle/tests/install-cli.test.ts b/packages/agent-bundle/tests/install-cli.test.ts index 9ea8be5b3..da94562c3 100644 --- a/packages/agent-bundle/tests/install-cli.test.ts +++ b/packages/agent-bundle/tests/install-cli.test.ts @@ -128,7 +128,7 @@ describe('runInstallCli', () => { it('exits 2 on a usage error without touching the lifecycle', async () => { const out = capture(); expect(await runInstallCli(['install', 'windsurf'], { from: '/pkg', ...out.sinks })).toBe(2); - expect(out.stderr()).toContain('Install host must be amp, claude, codex, or cursor.'); + expect(out.stderr()).toContain('Install host must be amp, claude, codex, cursor, or grokbot.'); expect(await runInstallCli([], { from: '/pkg', ...out.sinks })).toBe(2); }); diff --git a/packages/agent-bundle/tests/support/install-fixture.ts b/packages/agent-bundle/tests/support/install-fixture.ts index 7f73ecc82..cb1db112a 100644 --- a/packages/agent-bundle/tests/support/install-fixture.ts +++ b/packages/agent-bundle/tests/support/install-fixture.ts @@ -13,7 +13,7 @@ import { } from '../../src/build/manifest.ts'; import type { WebManifest } from '../../src/web-host/manifest.ts'; import { digest, sha256Hex } from '../../src/core/digest.ts'; -import type { InstallHost } from '../../src/install/install.ts'; +import type { BundleIdentityHost as InstallHost } from '../../src/install/identity.ts'; const pluginDocuments: Readonly, string>> = Object.freeze({ claude: '.claude-plugin/plugin.json', diff --git a/website/docs/en/guide/distribution/installation.mdx b/website/docs/en/guide/distribution/installation.mdx index a10ad5472..462e3cbdb 100644 --- a/website/docs/en/guide/distribution/installation.mdx +++ b/website/docs/en/guide/distribution/installation.mdx @@ -39,6 +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` | 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/zh/guide/distribution/installation.mdx b/website/docs/zh/guide/distribution/installation.mdx index 337582e1d..140472ec6 100644 --- a/website/docs/zh/guide/distribution/installation.mdx +++ b/website/docs/zh/guide/distribution/installation.mdx @@ -32,6 +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` | 由于 Claude 与 Codex 投影始终随行本地市场清单,它们的公开 CLI 可以直接安装该根目录。当所选宿主 二进制文件不可用时,安装器会以一条带类型的诊断失败,而不是报告一次它并未完成的成功。宿主安装诊断属于 From 1cc591e9c7235be84a290fdffc913db64fa15332 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:14:39 -0700 Subject: [PATCH 2/6] fix(install): honor GROK_BOT_HOME from process.env and reword staging diagnostics for grokbot --- .changeset/grokbot-install-host.md | 2 +- docs/diagnostics.md | 2 +- packages/agent-bundle/src/install/grokbot.ts | 28 +++++++++++++++++-- .../tests/grokbot-install.test.ts | 28 ++++++++++++++++++- 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/.changeset/grokbot-install-host.md b/.changeset/grokbot-install-host.md index dfebc50e8..4681f919e 100644 --- a/.changeset/grokbot-install-host.md +++ b/.changeset/grokbot-install-host.md @@ -2,4 +2,4 @@ 'agent-bundle': minor --- -Add a `grokbot` install host. `agent-bundle install grokbot` (and `-install install grokbot` from package-bound installer bins) stages the bundle's Cursor projection as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` with a store receipt, and prints the remaining Grok Bot steps: host the repository, add it as a plugin marketplace, and install the plugin from Grok Bot's Marketplace, which assigns the plugin id server-side. `agent-bundle doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`), and `agent-bundle uninstall grokbot` removes the staging and receipt and names the plugin id to uninstall in Grok Bot. +Add a `grokbot` install host. `agent-bundle install grokbot` (and `-install install grokbot` from package-bound installer bins) stages the bundle's Cursor projection as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` with a store receipt, and prints the remaining Grok Bot steps: host the repository, add it as a plugin marketplace, and install the plugin from Grok Bot's Marketplace, which assigns the plugin id server-side. `agent-bundle doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`), and `agent-bundle uninstall grokbot` removes the staging and receipt and names the plugin id to uninstall in Grok Bot. (#864) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index 7d525b121..e5cd220fc 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1851,7 +1851,7 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .`, the exhaustive host switch received a host that is not `amp`, `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.`, evaluating the configuration module or discovering source threw before validation. | Install: pass `--host amp`, `claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json`, no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 5` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.`, the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: `, loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | | `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.`, the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | -| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor` (including `grokbot`, which always stages a marketplace); `--scope` other than `user` for Codex, Cursor, or Grok Bot; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.`, the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or, as the message says, stage a Cursor Plugin bundle without `.git`, or use `--mode local`. Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | +| `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor` (including `grokbot`, which always stages a marketplace); `--scope` other than `user` for Codex, Cursor, or Grok Bot; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` (or `install grokbot`) without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.`, the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or, as the message says, stage a Cursor Plugin bundle without `.git`, or use `--mode local` (for `grokbot`, list `cursor` in the bundle targets instead). Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | | `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (the native plugin CLI has no qualified settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts index 9e55e7d9c..56022c72f 100644 --- a/packages/agent-bundle/src/install/grokbot.ts +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -41,7 +41,7 @@ export const grokBotHost = 'grokbot'; /** Agent Bundle's own Grok Bot root: the marketplace staging repository and receipt store live under it. */ export const grokBotRoot = (options: { readonly environment?: Readonly; readonly home?: string }): string => - options.environment?.['GROK_BOT_HOME'] ?? join(options.home ?? homedir(), '.grokbot'); + (options.environment ?? process.env)['GROK_BOT_HOME'] ?? join(options.home ?? homedir(), '.grokbot'); export const grokBotMarketplaceRoot = (root: string): string => cursorMarketplaceRoot(root); @@ -58,6 +58,26 @@ export const grokBotNextSteps = (repoRoot: string, plugin: string, bin = 'agent- 'once Grok Bot has cloned the plugin.', ]); +/** + * The shared stager words its diagnostics for Cursor's `--mode marketplace`; the grokbot host has no `--mode`, so + * recovery advice pointing at `--mode local` or Cursor would be wrong here. + */ +const grokBotStagingRewrites: readonly (readonly [string, string])[] = [ + ['; install git or use `--mode local`.', '; install git.'], + [', or use `--mode local`.', '.'], + [' Agent Plugins (root `plugin.json`) packs install with `--mode local`.', ' List `cursor` in the bundle targets.'], + ['git is required for `--mode marketplace` (Cursor imports marketplaces from Git repositories)', + 'git is required for the grokbot host (Grok Bot installs plugins from Git marketplaces)'], + ['`--mode marketplace` requires', 'The grokbot host requires'], + ['`--mode marketplace` refuses', 'The grokbot host refuses'], + ['Cursor marketplace staging', 'Grok Bot marketplace staging'], + ['Cursor marketplaces resolve', 'Grok Bot marketplaces resolve'], + ['Cursor would import', 'Grok Bot would import'], +]; + +export const grokBotStagingMessage = (message: string): string => + grokBotStagingRewrites.reduce((text, [from, to]) => text.split(from).join(to), message); + const failure = (code: string, message: string): DiagnosticError => new DiagnosticError([{ code, message, severity: 'error', target: grokBotHost }]); @@ -132,7 +152,11 @@ export const installGrokBot = async ( }; } catch (error) { if (error instanceof DiagnosticError) { - throw new DiagnosticError(error.diagnostics.map((entry) => ({ ...entry, target: grokBotHost }))); + throw new DiagnosticError(error.diagnostics.map((entry) => ({ + ...entry, + message: grokBotStagingMessage(entry.message), + target: grokBotHost, + }))); } throw failure('AB7004', errorMessage(error)); } diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index 3ef463d58..b2f6ac5a8 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runDoctor } from '../src/install/doctor.ts'; import { formatDoctorReport, formatInstallResult, formatUninstallResult } from '../src/install/format.ts'; -import { readGrokBotInventory } from '../src/install/grokbot.ts'; +import { grokBotRoot, grokBotStagingMessage, readGrokBotInventory } from '../src/install/grokbot.ts'; import { installBundle } from '../src/install/install.ts'; import { readInstallReceiptFile } from '../src/install/receipt.ts'; import { uninstallBundle } from '../src/install/uninstall.ts'; @@ -280,3 +280,29 @@ it('package-bound installer bins accept grokbot for install, uninstall, and doct await box.cleanup(); } }); + +it('resolves GROK_BOT_HOME from the process environment when no environment is passed', () => { + const previous = process.env['GROK_BOT_HOME']; + process.env['GROK_BOT_HOME'] = '/tmp/grokbot-home-from-env'; + try { + expect(grokBotRoot({ home: '/home/someone' })).toBe('/tmp/grokbot-home-from-env'); + expect(grokBotRoot({ environment: {}, home: '/home/someone' })).toBe(join('/home/someone', '.grokbot')); + } finally { + if (previous === undefined) delete process.env['GROK_BOT_HOME']; + else process.env['GROK_BOT_HOME'] = previous; + } +}); + +it('rewrites shared Cursor staging diagnostics without --mode recovery advice', () => { + const git = grokBotStagingMessage( + 'git is required for `--mode marketplace` (Cursor imports marketplaces from Git repositories); install git or use `--mode local`.', + ); + expect(git).toBe('git is required for the grokbot host (Grok Bot installs plugins from Git marketplaces); install git.'); + const nested = grokBotStagingMessage( + '`--mode marketplace` refuses bundle-internal Git metadata at "a/.git": git would record it as an empty gitlink and ' + + 'Cursor would import a plugin without files. Stage from a built bundle directory without `.git`, or use `--mode local`.', + ); + expect(nested).not.toContain('--mode'); + expect(nested).toContain('Grok Bot would import'); + expect(grokBotStagingMessage('Cursor marketplace staging failed: git commit: boom')).toBe('Grok Bot marketplace staging failed: git commit: boom'); +}); From c5efd9e3320047c21d800763354776312c83ca17 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:18:08 -0700 Subject: [PATCH 3/6] docs(install): list grokbot in the CLI reference and diagnostics recovery rows --- docs/diagnostics.md | 6 +++--- packages/agent-bundle/tests/grokbot-install.test.ts | 10 ++++++++-- website/docs/en/reference/cli.mdx | 8 ++++---- website/docs/zh/reference/cli.mdx | 8 ++++---- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/docs/diagnostics.md b/docs/diagnostics.md index e5cd220fc..c00abc9d5 100644 --- a/docs/diagnostics.md +++ b/docs/diagnostics.md @@ -1848,11 +1848,11 @@ the uninstall refusals `AB7007`–`AB7009`, have their own sections above. | Code | Severity | Meaning | Recovery | | --- | --- | --- | --- | -| `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .`, the exhaustive host switch received a host that is not `amp`, `claude`, `codex`, or `cursor`. Project preparation: `Unable to load project source.`, evaluating the configuration module or discovering source threw before validation. | Install: pass `--host amp`, `claude`, `codex`, or `cursor`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | +| `AB7000` | error | Install/uninstall: `Unsupported install host .` / `Unsupported uninstall host .`, the exhaustive host switch received a host that is not `amp`, `claude`, `codex`, `cursor`, or `grokbot`. Project preparation: `Unable to load project source.`, evaluating the configuration module or discovering source threw before validation. | Install: pass `amp`, `claude`, `codex`, `cursor`, or `grokbot`. Preparation: fix the Agent Bundle configuration and source files, then inspect again. | | `AB7001` | error | Install/uninstall/doctor: the bundle identity or authoritative file inventory is unreadable from `agent-bundle.manifest.json`, no manifest directly under the `--from` directory (the composite root is every selected host's bundle root, so `/` is never probed and host documents are never read for identity); a manifest that is not the canonical `manifestVersion: 5` document (the message carries the parser's reason); a manifest with no projection whose `builtInHost` is the requested host (identity is the shipped adapter, never the selected name), whose projection has neither its required `documents.plugin` nor Amp `documents.entry`, or whose `documents.entry` / `documents.plugin` / `documents.marketplace` pointer names a file the root does not contain; a `files[]` row whose path is missing or whose size, digest, bytes, or executable state is invalid after installation (a declared package bin must remain executable; a file the manifest does not declare executable must remain non-executable; another manifest executable may have lost its bit while being packed from a filesystem without executable modes); a Cursor or Amp `application.name` that is not a safe local plugin name; a Claude or Codex projection with no `marketplace.name`. `install` restores manifest modes before copying an npm-installed artifact into a host, while Doctor only compares. Project preparation: `Unable to validate project source.`, `Unable to normalize project source.`, `Unable to validate normalized project.`, or `Unable to create project context.`, the source validator, normalizer, adapter planner, or project-context factory threw; `inspectProject` adds `Unable to prepare inspection plans.` and, for `inspect --bundler`, `Unable to compose the bundler inspection: `, loading entries, generating the declaration tsconfig, or lowering and asserting the build's own Rslib/Rsbuild configuration failed. The reason carries the underlying source, project-tsconfig, toolchain, or invariant error, including a `tools` value the build would refuse. | Install: point `--from` at the unchanged composite root `agent-bundle build` wrote, rebuilt with the host among `targets`; if a listed file is missing or changed, rebuild or restore that file from the matching artifact. Preparation: fix normalized project configuration and source references, then inspect again. Bundler inspection: fix the source, project tsconfig, toolchain, or refused `tools` value named by the reason. | -| `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace`. Project preparation: `Unable to prepare project paths.`, the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git or use `--mode local`. Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | +| `AB7002` | error | Install/uninstall: ` is not installed or is not available on PATH.`, `Cursor is not installed in "".` / `Cursor home "" is not a directory.`, or `git` is missing for `--mode marketplace` or `install grokbot`. Project preparation: `Unable to prepare project paths.`, the project root or a configured output root could not be resolved inside the project. | Install: install the host CLI the message names; for the `git` refusal, install git (Cursor can also use `--mode local`; `grokbot` has no local mode). Preparation: ensure the project root and configured output roots are readable and remain inside the project root, then inspect again. | | `AB7003` | error | Install/uninstall scope and mode refusals: `--mode` on a host other than `cursor` (including `grokbot`, which always stages a marketplace); `--scope` other than `user` for Codex, Cursor, or Grok Bot; Amp `--scope local` instead of `project` or `user`; `--mode marketplace` (or `install grokbot`) without `.cursor-plugin/plugin.json` or with bundle-internal Git metadata. Project preparation: `Unable to snapshot project source.`, the source snapshot could not be taken, including when a discovered identity is not a relocatable POSIX path (a POSIX filename containing `\`, or another segment the manifest cannot carry). | Install: use a documented host scope, drop `--mode` for non-Cursor hosts, or, as the message says, stage a Cursor Plugin bundle without `.git`, or use `--mode local` (for `grokbot`, list `cursor` in the bundle targets instead). Preparation: ensure project source files and ignore rules are readable, remain inside the project root, and use relocatable POSIX path segments, then inspect again. | -| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (the native plugin CLI has no qualified settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | +| `AB7004` | error | Install/uninstall command and safety failures: ` plugin failed: ` (a host CLI verb exited nonzero); ` plugin list --json` was unusable when `--replace` or an uninstall needed it; an installed copy could not be compared and `--replace` was not given; a Codex replacement whose plugin list row is `enabled: false` or omits `enabled` (the native plugin CLI has no qualified settings-preserving update API, and native `plugin add` would set enabled to true); a rollback after a failed install also failed (the message lists the host verbs to run by hand); a Cursor or Grok Bot marketplace `git` step failed or the committed tree differs from the staged bytes; or any non-diagnostic error thrown by a Cursor installer. `inspectProject`: `Requested inspection target "" is not selected for this project.` | Install: read the host's detail in the message, then rerun (with `--replace` where the message says so). For a Codex disabled/unknown-enablement refusal, enable the plugin in Codex first. Inspection: choose a target selected by the project configuration, then inspect again. | ## Development server (`AB80xx`) diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index b2f6ac5a8..ef15b94b5 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -7,7 +7,7 @@ import { expect, it } from '@rstest/core'; import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runDoctor } from '../src/install/doctor.ts'; import { formatDoctorReport, formatInstallResult, formatUninstallResult } from '../src/install/format.ts'; -import { grokBotRoot, grokBotStagingMessage, readGrokBotInventory } from '../src/install/grokbot.ts'; +import { grokBotReceiptPath, grokBotRoot, grokBotStagingMessage, readGrokBotInventory } from '../src/install/grokbot.ts'; import { installBundle } from '../src/install/install.ts'; import { readInstallReceiptFile } from '../src/install/receipt.ts'; import { uninstallBundle } from '../src/install/uninstall.ts'; @@ -259,19 +259,25 @@ it('package-bound installer bins accept grokbot for install, uninstall, and doct const box = await sandbox(); const previous = { data: process.env['GROK_BOT_AGENT_DATA_DIR'], home: process.env['GROK_BOT_HOME'] }; process.env['GROK_BOT_AGENT_DATA_DIR'] = box.agentData; - process.env['GROK_BOT_HOME'] = join(box.home, '.grokbot'); + const grokHome = join(box.home, 'custom-grokbot'); + process.env['GROK_BOT_HOME'] = grokHome; try { let stdout = ''; 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'])).toBe(0); expect(stdout).toContain('Staged grok-fixture@1.2.3 for grokbot (marketplace mode)'); + // GROK_BOT_HOME from the process environment decides where install stages and receipts, and uninstall removes them there. + const receipt = grokBotReceiptPath(grokHome, 'grok-fixture'); + await access(receipt); + await access(join(grokHome, 'agent-bundle', 'marketplaces', 'grok-fixture')); stdout = ''; expect(await run(['doctor', '--host', 'grokbot'])).toBe(0); expect(stdout).toContain('grokbot: available (directory)'); stdout = ''; expect(await run(['uninstall', 'grokbot'])).toBe(0); expect(stdout).toContain('Uninstalled grok-fixture@1.2.3 for grokbot'); + await expect(access(receipt)).rejects.toThrow(); } finally { for (const [key, value] of [['GROK_BOT_AGENT_DATA_DIR', previous.data], ['GROK_BOT_HOME', previous.home]] as const) { if (value === undefined) delete process.env[key]; diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 3814b8552..9cfe56904 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -191,9 +191,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `amp`, `claude`, `codex`, or `cursor`. | +| `` | **required** | `amp`, `claude`, `codex`, `cursor`, or `grokbot`. `grokbot` installs the Cursor projection: it stages a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` (`$GROK_BOT_HOME` overrides `~/.grokbot`), writes a receipt, and prints the remaining Grok Bot steps. Grok Bot registers plugins server-side from hosted Git marketplaces, so nothing is registered locally. | | `--from ` | `process.cwd()` | The composite root holding `agent-bundle.manifest.json`. Identity comes from `application` and `projections[host]`; other hosts use `documents.plugin`, while Amp copies only the directory containing `documents.entry`. `AB7001` covers a missing/invalid manifest, projection, required host file, unsafe local name, or missing Claude/Codex marketplace identity. | -| `--scope ` | `user` | `user`, `project`, or `local`. Claude accepts all three; Amp accepts `project` and `user` (its system root); Codex and Cursor are user-scoped. Run Amp project-scope installation from the destination project root. | +| `--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`). | @@ -222,7 +222,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `amp`, `claude`, `codex`, or `cursor`. | +| `` | **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). | | `--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 +267,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`, or `cursor`. | +| `--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`). | | `--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/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 650ea0f53..74c2265f8 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -180,9 +180,9 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `amp`、`claude`、`codex` 或 `cursor`。 | +| `` | **必填** | `amp`、`claude`、`codex`、`cursor` 或 `grokbot`。`grokbot` 安装 Cursor 投影:在 `~/.grokbot/agent-bundle/marketplaces/` 下暂存一个已提交的市场仓库(`$GROK_BOT_HOME` 可覆盖 `~/.grokbot`),写入回执,并打印其余 Grok Bot 步骤。Grok Bot 在服务端从托管的 Git 市场注册插件,因此本地不会注册任何内容。 | | `--from ` | `process.cwd()` | 存放 `agent-bundle.manifest.json` 的组合根。身份来自 `application` 与 `projections[host]`;其他宿主使用 `documents.plugin`,Amp 只复制 `documents.entry` 所在目录。缺少清单、投影、所需宿主文件、不安全本地名称或 Claude/Codex 市场身份时为 `AB7001`。 | -| `--scope ` | `user` | `user`、`project` 或 `local`。Claude 接受全部三种;Amp 接受 `project` 与 `user`(系统根;项目安装应从目标项目根运行);Codex 与 Cursor 是 user 作用域。 | +| `--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`)。 | @@ -206,7 +206,7 @@ agent-bundle uninstall [--from ] [--scope ] [--mode ] | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `amp`、`claude`、`codex` 或 `cursor`。 | +| `` | **必填** | `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 +242,7 @@ Doctor 与 uninstall 会把观察到的根报告为 `unrecorded` / 无法证明 | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex` 或 `cursor`。 | +| `--host ` | 全部受支持的宿主 | 要检查的宿主。可重复;取值为 `claude`、`codex`、`cursor`,或需显式指定的 `grokbot`(报告回执,以及 Grok Bot 计算机插件缓存中的插件 id、版本与已安装提交,`AB7334`)。 | | `--from ` | 无 | 用于比对的组合根目录,通过其 `agent-bundle.manifest.json` 按 `install` 的方式读取:身份来自 `application`,每个被检查的宿主来自它的 `projections[]` 行(清单未描述该宿主时为 `AB7001`)。 | | `--json` | 关闭 | 写出一份机器可读的 JSON 文档。 | From 9f23ef3feb0f3ceb7546cd0221b1cb77064145be Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:23:00 -0700 Subject: [PATCH 4/6] chore(changeset): additive grokbot host is a pre-1.0 patch --- .changeset/grokbot-install-host.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/grokbot-install-host.md b/.changeset/grokbot-install-host.md index 4681f919e..98db64b17 100644 --- a/.changeset/grokbot-install-host.md +++ b/.changeset/grokbot-install-host.md @@ -1,5 +1,5 @@ --- -'agent-bundle': minor +'agent-bundle': patch --- Add a `grokbot` install host. `agent-bundle install grokbot` (and `-install install grokbot` from package-bound installer bins) stages the bundle's Cursor projection as a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` with a store receipt, and prints the remaining Grok Bot steps: host the repository, add it as a plugin marketplace, and install the plugin from Grok Bot's Marketplace, which assigns the plugin id server-side. `agent-bundle doctor --host grokbot` reports the plugin id and installed commit from the Grok Bot computer's plugin cache (`AB7334`), and `agent-bundle uninstall grokbot` removes the staging and receipt and names the plugin id to uninstall in Grok Bot. (#864) From 638fc8853ba621d5ebf125529d5bef1f02bc47a7 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:30:15 -0700 Subject: [PATCH 5/6] fix(install): restage a receipt-owned grokbot marketplace on drift or --replace --- packages/agent-bundle/src/install/grokbot.ts | 59 ++++++++++++++++++- .../tests/grokbot-install.test.ts | 54 +++++++++++++++++ website/docs/en/reference/cli.mdx | 2 +- website/docs/zh/reference/cli.mdx | 2 +- 4 files changed, 112 insertions(+), 5 deletions(-) diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts index 56022c72f..e0e3824e3 100644 --- a/packages/agent-bundle/src/install/grokbot.ts +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -1,4 +1,4 @@ -import { readdir, readFile, stat } from 'node:fs/promises'; +import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'; import { homedir } from 'node:os'; import { join } from 'node:path'; @@ -10,6 +10,7 @@ import { exists } from '../core/paths.ts'; import { cursorMarketplaceRoot, stageCursorMarketplace } from './cursor-marketplace.ts'; import { bundleInventory, readBundleIdentity } from './identity.ts'; import { + type InstallReceipt, createInstallReceipt, installReceiptStorePath, readInstallReceiptFile, @@ -81,6 +82,48 @@ export const grokBotStagingMessage = (message: string): string => const failure = (code: string, message: string): DiagnosticError => new DiagnosticError([{ code, message, severity: 'error', target: grokBotHost }]); +const gitOutput = async (runner: InstallCommandRunner, cwd: string, args: readonly string[]): Promise => { + const result = await runner.run('git', args, { cwd }).catch(() => undefined); + return result === undefined || result.code !== 0 ? undefined : result.stdout.trim(); +}; + +/** + * Moves a receipt-owned staging repository aside when this install supersedes it: same-version content drift is + * restaged automatically, a different version only with `--replace`. Ownership means the receipt names this plugin + * and host and its commit is the clean HEAD; anything else is left for the stager to refuse (`AB7005`). + * Returns the aside path so a failed restage can be rolled back. + */ +const supersedeOwnedStaging = async (options: { + readonly artifact: { readonly hash: string }; + readonly identity: { readonly plugin: string; readonly version: string }; + readonly options: InstallBundleOptions; + readonly previousReceipt: InstallReceipt | undefined; + readonly repoRoot: string; + readonly runner: InstallCommandRunner; +}): Promise => { + const { identity, previousReceipt, repoRoot, runner } = options; + if (previousReceipt === undefined || !(await exists(repoRoot))) return undefined; + const sameVersion = previousReceipt.version === identity.version; + if (sameVersion && previousReceipt.contentHash === options.artifact.hash) return undefined; + const commit = previousReceipt.registrations[0]?.commit; + const owned = previousReceipt.host === grokBotHost && + previousReceipt.plugin === identity.plugin && + commit !== undefined && + await gitOutput(runner, repoRoot, ['rev-parse', 'HEAD']) === commit && + await gitOutput(runner, repoRoot, ['status', '--porcelain', '--untracked-files=all', '--ignored=matching']) === ''; + if (!owned) return undefined; + if (!sameVersion && options.options.replace !== true) { + throw failure( + 'AB7005', + `Refusing version collision at ${repoRoot}: found ${previousReceipt.version}, requested ${identity.version}. ` + + 'Re-run with --replace to restage it.', + ); + } + const aside = join(repoRoot, '..', `.${identity.plugin}.superseded-${process.pid}-${Date.now()}`); + await rename(repoRoot, aside); + return aside; +}; + /** * Stages the Cursor projection as a committed marketplace repository under * `/agent-bundle/marketplaces/` and writes a store @@ -111,9 +154,18 @@ export const installGrokBot = async ( const root = grokBotRoot(options); try { const artifact = await bundleInventory(identity, { restoreModes: true }); - const staged = await stageCursorMarketplace({ artifact, cursorRoot: root, identity, runner, treeHash }); const receiptPath = grokBotReceiptPath(root, identity.plugin); const previousReceipt = await readInstallReceiptFile(receiptPath); + const repoRoot = join(grokBotMarketplaceRoot(root), identity.plugin); + const superseded = await supersedeOwnedStaging({ artifact, identity, options, previousReceipt, repoRoot, runner }); + let staged: Awaited>; + try { + staged = await stageCursorMarketplace({ artifact, cursorRoot: root, identity, runner, treeHash }); + } catch (error) { + if (superseded !== undefined) await rename(superseded, repoRoot); + throw error; + } + if (superseded !== undefined) await rm(superseded, { force: true, recursive: true }); if ( staged.state === 'staged' || previousReceipt === undefined || @@ -146,8 +198,9 @@ export const installGrokBot = async ( mode: 'marketplace', nextSteps: grokBotNextSteps(staged.destination, identity.plugin), plugin: identity.plugin, + ...(superseded === undefined || previousReceipt === undefined ? {} : { previousContentHash: previousReceipt.contentHash }), receipt: receiptPath, - state: staged.state, + state: superseded === undefined ? staged.state : 'replaced', version: identity.version, }; } catch (error) { diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index ef15b94b5..152478080 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -312,3 +312,57 @@ it('rewrites shared Cursor staging diagnostics without --mode recovery advice', expect(nested).toContain('Grok Bot would import'); expect(grokBotStagingMessage('Cursor marketplace staging failed: git commit: boom')).toBe('Grok Bot marketplace staging failed: git commit: boom'); }); + +/** Rewrites the fixture bundle in place, as a rebuild would. */ +const rebuild = async (box: Sandbox, version: string, payload: string): Promise => { + await writeFile(join(box.from, 'payload.txt'), payload); + await writeJson(join(box.from, '.cursor-plugin/plugin.json'), { name: 'grok-fixture', version }); + await writeInstallFixtureManifest(box.from, { name: 'grok-fixture', version }, [{ host: 'cursor' }]); +}; + +it('restages a receipt-owned Grok Bot marketplace on same-version drift and on --replace for a new version', async () => { + const box = await sandbox(); + try { + const install = (replace?: boolean) => installBundle({ + environment: box.environment, from: box.from, home: box.home, host: 'grokbot', ...(replace === undefined ? {} : { replace }), + }); + const repo = join(box.home, '.grokbot', 'agent-bundle', 'marketplaces', 'grok-fixture'); + const first = await install(); + + await rebuild(box, '1.2.3', 'drifted\n'); + const drift = await install(); + expect(drift).toMatchObject({ previousContentHash: first.contentHash, state: 'replaced', version: '1.2.3' }); + expect(drift.commit).not.toBe(first.commit); + expect(await readFile(join(repo, 'plugins', 'grok-fixture', 'payload.txt'), 'utf8')).toBe('drifted\n'); + + await rebuild(box, '1.3.0', 'next\n'); + const refused = await install().catch((error: unknown) => error); + expect((refused as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005', target: 'grokbot' }]); + expect((refused as DiagnosticError).diagnostics[0]?.message).toContain('--replace'); + expect(await readFile(join(repo, 'plugins', 'grok-fixture', 'payload.txt'), 'utf8')).toBe('drifted\n'); + + const replaced = await install(true); + expect(replaced).toMatchObject({ previousContentHash: drift.contentHash, state: 'replaced', version: '1.3.0' }); + expect(await readFile(join(repo, 'plugins', 'grok-fixture', 'payload.txt'), 'utf8')).toBe('next\n'); + const receipt = await readInstallReceiptFile(grokBotReceiptPath(join(box.home, '.grokbot'), 'grok-fixture')); + expect(receipt).toMatchObject({ registrations: [{ commit: replaced.commit }], version: '1.3.0' }); + } finally { + await box.cleanup(); + } +}); + +it('leaves a dirty staged repository for the stager to refuse instead of replacing it', async () => { + const box = await sandbox(); + try { + await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + const repo = join(box.home, '.grokbot', 'agent-bundle', 'marketplaces', 'grok-fixture'); + await writeFile(join(repo, 'local-edit.txt'), 'mine\n'); + await rebuild(box, '1.2.3', 'drifted\n'); + const refused = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot', replace: true }) + .catch((error: unknown) => error); + expect((refused as DiagnosticError).diagnostics).toMatchObject([{ code: 'AB7005', target: 'grokbot' }]); + expect(await readFile(join(repo, 'local-edit.txt'), 'utf8')).toBe('mine\n'); + } finally { + await box.cleanup(); + } +}); diff --git a/website/docs/en/reference/cli.mdx b/website/docs/en/reference/cli.mdx index 9cfe56904..3170f58be 100644 --- a/website/docs/en/reference/cli.mdx +++ b/website/docs/en/reference/cli.mdx @@ -191,7 +191,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | Option | Default | Meaning | | --- | --- | --- | -| `` | **required** | `amp`, `claude`, `codex`, `cursor`, or `grokbot`. `grokbot` installs the Cursor projection: it stages a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` (`$GROK_BOT_HOME` overrides `~/.grokbot`), writes a receipt, and prints the remaining Grok Bot steps. Grok Bot registers plugins server-side from hosted Git marketplaces, so nothing is registered locally. | +| `` | **required** | `amp`, `claude`, `codex`, `cursor`, or `grokbot`. `grokbot` installs the Cursor projection: it stages a committed marketplace repository under `~/.grokbot/agent-bundle/marketplaces/` (`$GROK_BOT_HOME` overrides `~/.grokbot`), writes a receipt, and prints the remaining Grok Bot steps. A clean, receipt-owned staging repository is restaged on same-version content drift, or for a new version with `--replace`. Grok Bot registers plugins server-side from hosted Git marketplaces, so nothing is registered locally. | | `--from ` | `process.cwd()` | The composite root holding `agent-bundle.manifest.json`. Identity comes from `application` and `projections[host]`; other hosts use `documents.plugin`, while Amp copies only the directory containing `documents.entry`. `AB7001` covers a missing/invalid manifest, projection, required host file, unsafe local name, or missing Claude/Codex marketplace identity. | | `--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. | diff --git a/website/docs/zh/reference/cli.mdx b/website/docs/zh/reference/cli.mdx index 74c2265f8..f8b1bd62e 100644 --- a/website/docs/zh/reference/cli.mdx +++ b/website/docs/zh/reference/cli.mdx @@ -180,7 +180,7 @@ agent-bundle install [--from ] [--scope ] [--mode ] \ | 选项 | 默认值 | 含义 | | --- | --- | --- | -| `` | **必填** | `amp`、`claude`、`codex`、`cursor` 或 `grokbot`。`grokbot` 安装 Cursor 投影:在 `~/.grokbot/agent-bundle/marketplaces/` 下暂存一个已提交的市场仓库(`$GROK_BOT_HOME` 可覆盖 `~/.grokbot`),写入回执,并打印其余 Grok Bot 步骤。Grok Bot 在服务端从托管的 Git 市场注册插件,因此本地不会注册任何内容。 | +| `` | **必填** | `amp`、`claude`、`codex`、`cursor` 或 `grokbot`。`grokbot` 安装 Cursor 投影:在 `~/.grokbot/agent-bundle/marketplaces/` 下暂存一个已提交的市场仓库(`$GROK_BOT_HOME` 可覆盖 `~/.grokbot`),写入回执,并打印其余 Grok Bot 步骤。干净且由回执归属的暂存仓库在同版本内容漂移时会被重新暂存,版本变化时需加 `--replace`。Grok Bot 在服务端从托管的 Git 市场注册插件,因此本地不会注册任何内容。 | | `--from ` | `process.cwd()` | 存放 `agent-bundle.manifest.json` 的组合根。身份来自 `application` 与 `projections[host]`;其他宿主使用 `documents.plugin`,Amp 只复制 `documents.entry` 所在目录。缺少清单、投影、所需宿主文件、不安全本地名称或 Claude/Codex 市场身份时为 `AB7001`。 | | `--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 把该插件当作市场安装来管理。 | From 4382169eff26d3a62a81fcb2b9ba1a16d513df87 Mon Sep 17 00:00:00 2001 From: Zack Jackson <25274700+ScriptedAlchemy@users.noreply.github.com> Date: Mon, 28 Sep 2026 20:35:24 -0700 Subject: [PATCH 6/6] fix(install): keep the superseded grokbot staging until the receipt is written --- packages/agent-bundle/src/install/grokbot.ts | 53 +++++++++++-------- .../tests/grokbot-install.test.ts | 38 ++++++++++++- 2 files changed, 67 insertions(+), 24 deletions(-) diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts index e0e3824e3..23ed88048 100644 --- a/packages/agent-bundle/src/install/grokbot.ts +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -165,29 +165,38 @@ export const installGrokBot = async ( if (superseded !== undefined) await rename(superseded, repoRoot); throw error; } - if (superseded !== undefined) await rm(superseded, { force: true, recursive: true }); - if ( - staged.state === 'staged' || - previousReceipt === undefined || - previousReceipt.contentHash !== artifact.hash || - previousReceipt.registrations[0]?.commit !== staged.commit - ) { - await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ - 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, - }], - scope: 'user', - updatedAt: new Date().toISOString(), - version: identity.version, - })); + try { + if ( + staged.state === 'staged' || + previousReceipt === undefined || + previousReceipt.contentHash !== artifact.hash || + previousReceipt.registrations[0]?.commit !== staged.commit + ) { + await writeStoredInstallReceipt(receiptPath, createInstallReceipt({ + 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, + }], + scope: 'user', + updatedAt: new Date().toISOString(), + version: identity.version, + })); + } + } catch (error) { + // 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 }); + await rename(superseded, repoRoot); + } + throw error; } + if (superseded !== undefined) await rm(superseded, { force: true, recursive: true }); return { bundleRoot: identity.bundleRoot, ...(staged.commit === undefined ? {} : { commit: staged.commit }), diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index 152478080..cd6aadba6 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -1,4 +1,4 @@ -import { access, mkdir, mkdtemp, readFile, realpath, writeFile } from 'node:fs/promises'; +import { access, mkdir, mkdtemp, readFile, realpath, rename, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; @@ -8,7 +8,7 @@ import { DiagnosticError } from '../src/core/diagnostics.ts'; import { runDoctor } from '../src/install/doctor.ts'; import { formatDoctorReport, formatInstallResult, formatUninstallResult } from '../src/install/format.ts'; import { grokBotReceiptPath, grokBotRoot, grokBotStagingMessage, readGrokBotInventory } from '../src/install/grokbot.ts'; -import { installBundle } from '../src/install/install.ts'; +import { defaultCommandRunner, installBundle } from '../src/install/install.ts'; import { readInstallReceiptFile } from '../src/install/receipt.ts'; import { uninstallBundle } from '../src/install/uninstall.ts'; import { runInstallCli } from '../src/install/index.ts'; @@ -366,3 +366,37 @@ it('leaves a dirty staged repository for the stager to refuse instead of replaci await box.cleanup(); } }); + +it('rolls a restage back to the superseded repository when the receipt cannot be written', async () => { + const box = await sandbox(); + try { + const grokHome = join(box.home, '.grokbot'); + const repo = join(grokHome, 'agent-bundle', 'marketplaces', 'grok-fixture'); + const receipts = join(grokHome, 'agent-bundle', 'receipts'); + const first = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + await rebuild(box, '1.2.3', 'drifted\n'); + // Once the replacement commit exists, turn the receipt store into a file so the receipt write fails. + const commandRunner = { + run: async (command: string, args: readonly string[], options: { readonly cwd: string }) => { + const result = await defaultCommandRunner.run(command, args, options); + if (args.includes('commit')) { + await rename(receipts, `${receipts}.aside`); + await writeFile(receipts, 'not a directory\n'); + } + return result; + }, + }; + const failed = await installBundle({ commandRunner, environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }) + .catch((error: unknown) => error); + expect(failed).toBeInstanceOf(Error); + await removeTree(receipts); + await rename(`${receipts}.aside`, receipts); + expect(await readFile(join(repo, 'plugins', 'grok-fixture', 'payload.txt'), 'utf8')).toBe('payload\n'); + const head = await defaultCommandRunner.run('git', ['rev-parse', 'HEAD'], { cwd: repo }); + expect(head.stdout.trim()).toBe(first.commit); + const receipt = await readInstallReceiptFile(grokBotReceiptPath(grokHome, 'grok-fixture')); + expect(receipt).toMatchObject({ contentHash: first.contentHash, registrations: [{ commit: first.commit }] }); + } finally { + await box.cleanup(); + } +});