diff --git a/.changeset/grokbot-sideload.md b/.changeset/grokbot-sideload.md new file mode 100644 index 000000000..6e15a49ce --- /dev/null +++ b/.changeset/grokbot-sideload.md @@ -0,0 +1,5 @@ +--- +"agent-bundle": minor +--- + +`install grokbot` now also sideloads the plugin into a marketplace snapshot Grok Bot already syncs: on a Grok Bot computer it mirrors the Cursor projection into `/plugins/marketplaces/github.com/////`, lists it in that snapshot's `.cursor-plugin/marketplace.json`, and writes `/plugins/cache/-///` with `.cache-complete`, the same layout as official plugins. The target is the bundle repository owner's marketplace (or `GROK_BOT_SIDELOAD_MARKETPLACE=owner/repo`); plugins the hosted marketplace already ships are left alone, `GROK_BOT_SIDELOAD=0` disables it, and `uninstall grokbot` removes exactly what was sideloaded. diff --git a/packages/agent-bundle/src/install/grokbot.ts b/packages/agent-bundle/src/install/grokbot.ts index 23ed88048..232a137f9 100644 --- a/packages/agent-bundle/src/install/grokbot.ts +++ b/packages/agent-bundle/src/install/grokbot.ts @@ -1,6 +1,6 @@ -import { readdir, readFile, rename, rm, stat } from 'node:fs/promises'; +import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises'; import { homedir } from 'node:os'; -import { join } from 'node:path'; +import { dirname, join } from 'node:path'; import { Predicate } from 'effect'; @@ -11,6 +11,8 @@ import { cursorMarketplaceRoot, stageCursorMarketplace } from './cursor-marketpl import { bundleInventory, readBundleIdentity } from './identity.ts'; import { type InstallReceipt, + type TreeInventory, + copyInventoryFiles, createInstallReceipt, installReceiptStorePath, readInstallReceiptFile, @@ -32,8 +34,10 @@ import type { InstallBundleOptions, InstallCommandRunner, InstallResult } from ' * 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. + * installer stages a committed marketplace repository built from the Cursor + * projection, records a receipt, prints the exact remaining steps, and (on a + * Grok Bot computer) sideloads the plugin into a marketplace snapshot Grok Bot + * already syncs; see `sideloadGrokBot` below. * Doctor reads the cache and skill index read-only to report the plugin id * and the commit Grok Bot installed. */ @@ -197,6 +201,7 @@ export const installGrokBot = async ( throw error; } if (superseded !== undefined) await rm(superseded, { force: true, recursive: true }); + const sideload = await sideloadGrokBot({ artifact, identity, options, root }); return { bundleRoot: identity.bundleRoot, ...(staged.commit === undefined ? {} : { commit: staged.commit }), @@ -205,7 +210,10 @@ export const installGrokBot = async ( host: grokBotHost, marketplace: staged.marketplace, mode: 'marketplace', - nextSteps: grokBotNextSteps(staged.destination, identity.plugin), + nextSteps: sideload.length === 0 + ? grokBotNextSteps(staged.destination, identity.plugin) + : [...sideload.map(sideloadStep), ...grokBotNextSteps(staged.destination, identity.plugin)], + ...(sideload.length === 0 ? {} : { sideload }), plugin: identity.plugin, ...(superseded === undefined || previousReceipt === undefined ? {} : { previousContentHash: previousReceipt.contentHash }), receipt: receiptPath, @@ -342,3 +350,187 @@ export const readGrokBotInventory = async ( } return Object.freeze({ agentData, entries: Object.freeze(entries), status: 'available' }); }; + +/** + * Unofficial sideload into a marketplace Grok Bot already syncs. + * + * Grok Bot keeps a sparse snapshot of each account marketplace under + * `/plugins/marketplaces/github.com////` and a + * completed copy of every installed plugin under + * `/plugins/cache/-///`. When the bundle's + * repository owner (or `GROK_BOT_SIDELOAD_MARKETPLACE=owner/repo[,owner/repo]`) + * names a marketplace that snapshot exists for, the installer mirrors the + * plugin into it exactly as Grok Bot lays out its official plugins: the plugin + * folder beside its siblings, an entry in that snapshot's + * `.cursor-plugin/marketplace.json`, and a cache copy with `.cache-complete`. + * The plugin id stays server-assigned: until the hosted marketplace lists the + * plugin and the account installs it, Grok Bot's sync may prune these files, + * and re-running the installer restores them. `GROK_BOT_SIDELOAD=0` disables it. + * A record under `/agent-bundle/sideload/.json` lets + * uninstall remove exactly what was written. + */ +export interface GrokBotSideloadEntry { + /** `/` of the Grok Bot marketplace snapshot. */ + readonly marketplace: string; + /** Snapshot commit the plugin was mirrored under. */ + readonly commit: string; + /** `/`. */ + readonly pluginPath: string; + /** `/plugins/cache/-//`. */ + readonly cachePath: string; + /** Whether this install added the plugin to the snapshot's marketplace.json (false when already listed). */ + readonly addedEntry: boolean; +} + +interface GrokBotSideloadRecord { + readonly entries: readonly GrokBotSideloadEntry[]; + readonly plugin: string; + readonly version: string; +} + +export const grokBotSideloadRecordPath = (root: string, plugin: string): string => + join(root, 'agent-bundle', 'sideload', `${plugin}.json`); + +const sideloadStep = (entry: GrokBotSideloadEntry): string => + `Sideloaded into Grok Bot's ${entry.marketplace} marketplace snapshot @ ${entry.commit}: ${entry.pluginPath} ` + + `and ${entry.cachePath} (unofficial; Grok Bot assigns the plugin id once the hosted marketplace lists the plugin).`; + +const repositoryOwner = (repository: unknown): string | undefined => { + const url = typeof repository === 'string' + ? repository + : Predicate.isObject(repository) && typeof repository['url'] === 'string' ? repository['url'] : undefined; + return url?.match(/github\.com[/:]([^/]+)\//iu)?.[1]?.toLowerCase(); +}; + +const readSideloadRecord = async (path: string): Promise => { + const document = await readJsonFile(path); + if (!Predicate.isObject(document) || !Array.isArray(document['entries'])) return undefined; + return document as unknown as GrokBotSideloadRecord; +}; + +const grokBotAgentData = async ( + environment: Readonly, + home: string, +): Promise => { + for (const candidate of grokBotAgentDataCandidates(environment, home)) { + if (await directoryExists(join(candidate, 'plugins'))) return candidate; + } + return undefined; +}; + +/** `[owner, repo]` marketplace snapshots to sideload into, from the override or the bundle's repository owner. */ +const sideloadTargets = async ( + marketplacesRoot: string, + environment: Readonly, + bundleRoot: string, +): Promise => { + const override = environment['GROK_BOT_SIDELOAD_MARKETPLACE']; + if (override !== undefined && override.trim() !== '') { + return override.split(',').map((value) => value.trim().toLowerCase().split('/')) + .filter((parts): parts is [string, string] => parts.length === 2 && parts[0] !== '' && parts[1] !== '') + .map(([owner, repo]) => [owner, repo] as const); + } + const manifest = await readJsonFile(join(bundleRoot, '.cursor-plugin', 'plugin.json')); + const owner = Predicate.isObject(manifest) ? repositoryOwner(manifest['repository']) : undefined; + if (owner === undefined) return []; + return (await listDirectory(join(marketplacesRoot, owner))).map((repo) => [owner, repo] as const); +}; + +const marketplaceManifestFile = (snapshot: string): string => join(snapshot, '.cursor-plugin', 'marketplace.json'); + +const writeTree = async (bundleRoot: string, destination: string, artifact: TreeInventory): Promise => { + const stage = `${destination}.agent-bundle-stage-${process.pid}`; + await rm(stage, { force: true, recursive: true }); + await copyInventoryFiles(bundleRoot, stage, artifact); + await rm(destination, { force: true, recursive: true }); + await mkdir(dirname(destination), { recursive: true }); + await rename(stage, destination); +}; + +const sideloadGrokBot = async (options: { + readonly artifact: TreeInventory; + readonly identity: { readonly bundleRoot: string; readonly plugin: string; readonly version: string }; + readonly options: InstallBundleOptions; + readonly root: string; +}): Promise => { + const environment = options.options.environment ?? process.env; + if (environment['GROK_BOT_SIDELOAD'] === '0') return []; + const agentData = await grokBotAgentData(environment, options.options.home ?? homedir()); + if (agentData === undefined) return []; + const { artifact, identity } = options; + const marketplacesRoot = join(agentData, 'plugins', 'marketplaces', 'github.com'); + const recordPath = grokBotSideloadRecordPath(options.root, identity.plugin); + const previous = await readSideloadRecord(recordPath); + const owned = new Set(previous?.entries.flatMap((entry) => [entry.pluginPath, entry.cachePath]) ?? []); + const previouslyAdded = new Set(previous?.entries.filter((entry) => entry.addedEntry).map((entry) => entry.pluginPath) ?? []); + const description = await readManifestDescription(identity.bundleRoot); + const entries: GrokBotSideloadEntry[] = []; + for (const [owner, repo] of await sideloadTargets(marketplacesRoot, environment, identity.bundleRoot)) { + for (const commit of await listDirectory(join(marketplacesRoot, owner, repo))) { + const snapshot = join(marketplacesRoot, owner, repo, commit); + const manifestPath = marketplaceManifestFile(snapshot); + const manifest = await readJsonFile(manifestPath); + if (!Predicate.isObject(manifest) || !Array.isArray(manifest['plugins'])) continue; + const plugins = manifest['plugins'] as unknown[]; + const pluginPath = join(snapshot, identity.plugin); + const listed = plugins.some((entry) => Predicate.isObject(entry) && entry['name'] === identity.plugin); + // The hosted marketplace already ships this plugin at this commit: Grok Bot owns it, leave it alone. + if (listed && !owned.has(pluginPath) && await exists(pluginPath)) continue; + const cachePath = join(agentData, 'plugins', 'cache', `${owner}-${repo}`, identity.plugin, commit); + if (!owned.has(cachePath) && await exists(join(cachePath, '.cache-complete'))) continue; + await writeTree(identity.bundleRoot, pluginPath, artifact); + if (!listed) { + plugins.push({ name: identity.plugin, source: identity.plugin, ...(description === undefined ? {} : { description }) }); + await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`); + } + await writeTree(identity.bundleRoot, cachePath, artifact); + await writeFile(join(cachePath, '.cache-complete'), ''); + entries.push(Object.freeze({ + addedEntry: !listed || previouslyAdded.has(pluginPath), + cachePath, + commit, + marketplace: `${owner}/${repo}`, + pluginPath, + })); + } + } + if (entries.length > 0 || previous !== undefined) { + await mkdir(dirname(recordPath), { recursive: true }); + await writeFile(recordPath, `${JSON.stringify({ entries, plugin: identity.plugin, version: identity.version }, null, 2)}\n`); + } + return Object.freeze(entries); +}; + +const readManifestDescription = async (bundleRoot: string): Promise => { + const manifest = await readJsonFile(join(bundleRoot, '.cursor-plugin', 'plugin.json')); + return Predicate.isObject(manifest) && typeof manifest['description'] === 'string' ? manifest['description'] : undefined; +}; + +/** Removes what `install grokbot` sideloaded (plugin folders, marketplace entries it added, cache copies) and its record. */ +export const removeGrokBotSideload = async (root: string, plugin: string): Promise => { + const recordPath = grokBotSideloadRecordPath(root, plugin); + const record = await readSideloadRecord(recordPath); + if (record === undefined) return []; + const removed: string[] = []; + for (const entry of record.entries) { + if (entry.addedEntry) { + const manifestPath = marketplaceManifestFile(dirname(entry.pluginPath)); + const manifest = await readJsonFile(manifestPath); + if (Predicate.isObject(manifest) && Array.isArray(manifest['plugins'])) { + const plugins = manifest['plugins'] as unknown[]; + const kept = plugins.filter((item) => !(Predicate.isObject(item) && item['name'] === plugin)); + if (kept.length !== plugins.length) { + await writeFile(manifestPath, `${JSON.stringify({ ...manifest, plugins: kept }, null, 2)}\n`); + } + } + } + for (const path of [entry.pluginPath, entry.cachePath]) { + if (await exists(path)) { + await rm(path, { force: true, recursive: true }); + removed.push(path); + } + } + } + await rm(recordPath, { force: true }); + return Object.freeze(removed); +}; diff --git a/packages/agent-bundle/src/install/install.ts b/packages/agent-bundle/src/install/install.ts index 6bf68cd50..831344e4b 100644 --- a/packages/agent-bundle/src/install/install.ts +++ b/packages/agent-bundle/src/install/install.ts @@ -12,7 +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 { type GrokBotSideloadEntry, installGrokBot } from './grokbot.ts'; import { bundleInventory, failure, @@ -115,6 +115,8 @@ export interface InstallResult { * in the host root's `agent-bundle/receipts` store for host-CLI and marketplace deliveries (#101). */ readonly receipt?: string; + /** grokbot only: copies mirrored into Grok Bot's own marketplace snapshot and plugin cache (see install/grokbot.ts). */ + readonly sideload?: readonly GrokBotSideloadEntry[]; /** * `staged` means the marketplace repository is ready and Cursor's import step is still pending * (`mode: 'marketplace'`). diff --git a/packages/agent-bundle/src/install/uninstall.ts b/packages/agent-bundle/src/install/uninstall.ts index 1d8223929..1d272ef4c 100644 --- a/packages/agent-bundle/src/install/uninstall.ts +++ b/packages/agent-bundle/src/install/uninstall.ts @@ -12,7 +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 { grokBotRoot, readGrokBotInventory, removeGrokBotSideload } from './grokbot.ts'; import { ampInstallLocation, cursorMarketplaceReceiptPath, @@ -1682,8 +1682,13 @@ const uninstallProgram = Effect.fnUntraced(function*( } // The grokbot host stages the Cursor projection, so the Cursor projection identifies the plugin. const cursorIdentity = yield* liftPromise(() => readBundleIdentity(options.from, 'cursor')); - return yield* liftPromise(() => + const staged = yield* liftPromise(() => uninstallStagedMarketplace(options, cursorIdentity, policy, grokBotStagingHost(options, cursorIdentity))); + if (staged.state !== 'uninstalled') return staged; + const sideloaded = yield* liftPromise(() => removeGrokBotSideload(grokBotRoot(options), cursorIdentity.plugin)); + return sideloaded.length === 0 + ? staged + : { ...staged, removed: { ...staged.removed, directories: [...staged.removed.directories, ...sideloaded] } }; } const host = options.host; const identity = yield* liftPromise(() => readBundleIdentity(options.from, host)); diff --git a/packages/agent-bundle/tests/grokbot-install.test.ts b/packages/agent-bundle/tests/grokbot-install.test.ts index cd6aadba6..4aa19f666 100644 --- a/packages/agent-bundle/tests/grokbot-install.test.ts +++ b/packages/agent-bundle/tests/grokbot-install.test.ts @@ -400,3 +400,93 @@ it('rolls a restage back to the superseded repository when the receipt cannot be await box.cleanup(); } }); + +it('sideloads into the Grok Bot marketplace snapshot named by the bundle repository owner, and uninstall reverses it', async () => { + const box = await sandbox(); + try { + await writeJson(join(box.from, '.cursor-plugin/plugin.json'), { + description: 'Fixture for Grok Bot.', + name: 'grok-fixture', + repository: 'https://github.com/ScriptedAlchemy/grok-fixture', + version: '1.2.3', + }); + await writeInstallFixtureManifest(box.from, { name: 'grok-fixture', version: '1.2.3' }, [{ host: 'cursor' }]); + const commit = '0401b288f8d5b444bc445ed4c3ac2b94fb24e02c'; + const snapshot = join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'scriptedalchemy', 'plugins', commit); + await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { + name: 'cursor-plugins', + plugins: [{ description: 'p', name: 'pstack', source: 'pstack' }], + }); + await writeJson(join(snapshot, 'pstack', '.cursor-plugin', 'plugin.json'), { name: 'pstack' }); + + const result = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + const cachePath = join(box.agentData, 'plugins', 'cache', 'scriptedalchemy-plugins', 'grok-fixture', commit); + expect(result.sideload).toEqual([{ + addedEntry: true, + cachePath, + commit, + marketplace: 'scriptedalchemy/plugins', + pluginPath: join(snapshot, 'grok-fixture'), + }]); + expect(await readFile(join(snapshot, 'grok-fixture', 'payload.txt'), 'utf8')).toBe('payload\n'); + expect(await readFile(join(cachePath, 'payload.txt'), 'utf8')).toBe('payload\n'); + await access(join(cachePath, '.cache-complete')); + expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins).toEqual([ + { description: 'p', name: 'pstack', source: 'pstack' }, + { description: 'Fixture for Grok Bot.', name: 'grok-fixture', source: 'grok-fixture' }, + ]); + expect(formatInstallResult(result)).toContain(`Sideloaded into Grok Bot's scriptedalchemy/plugins marketplace snapshot @ ${commit}`); + const inventory = await readGrokBotInventory('grok-fixture', { environment: box.environment, home: box.home }); + expect(inventory).toMatchObject({ entries: [{ installPath: cachePath, marketplace: 'scriptedalchemy-plugins' }], status: 'available' }); + + // Re-running restores a pruned sideload and keeps a single marketplace entry. + await removeTree(cachePath); + const again = await installBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(again.sideload?.[0]).toMatchObject({ addedEntry: true, cachePath }); + await access(join(cachePath, '.cache-complete')); + expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins).toHaveLength(2); + + const removed = await uninstallBundle({ environment: box.environment, from: box.from, home: box.home, host: 'grokbot' }); + expect(removed.removed.directories).toEqual(expect.arrayContaining([join(snapshot, 'grok-fixture'), cachePath])); + await expect(access(cachePath)).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(access(join(snapshot, 'grok-fixture'))).rejects.toMatchObject({ code: 'ENOENT' }); + expect(JSON.parse(await readFile(join(snapshot, '.cursor-plugin', 'marketplace.json'), 'utf8')).plugins) + .toEqual([{ description: 'p', name: 'pstack', source: 'pstack' }]); + await access(join(snapshot, 'pstack', '.cursor-plugin', 'plugin.json')); + } finally { + await box.cleanup(); + } +}); + +it('leaves a plugin the hosted marketplace already ships alone and honors GROK_BOT_SIDELOAD=0', async () => { + const box = await sandbox(); + try { + const commit = '691058d3f00529b2f435b13400e75100ada7709e'; + const snapshot = join(box.agentData, 'plugins', 'marketplaces', 'github.com', 'acme', 'plugins', commit); + await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { + plugins: [{ name: 'grok-fixture', source: 'grok-fixture' }], + }); + await writeJson(join(snapshot, 'grok-fixture', '.cursor-plugin', 'plugin.json'), { name: 'grok-fixture', version: '1.0.0' }); + const hosted = await installBundle({ + environment: { ...box.environment, GROK_BOT_SIDELOAD_MARKETPLACE: 'acme/plugins' }, + from: box.from, + home: box.home, + host: 'grokbot', + }); + expect(hosted.sideload).toBeUndefined(); + expect(JSON.parse(await readFile(join(snapshot, 'grok-fixture', '.cursor-plugin', 'plugin.json'), 'utf8')).version).toBe('1.0.0'); + + await removeTree(join(snapshot, 'grok-fixture')); + await writeJson(join(snapshot, '.cursor-plugin', 'marketplace.json'), { plugins: [] }); + const disabled = await installBundle({ + environment: { ...box.environment, GROK_BOT_SIDELOAD: '0', GROK_BOT_SIDELOAD_MARKETPLACE: 'acme/plugins' }, + from: box.from, + home: box.home, + host: 'grokbot', + }); + expect(disabled.sideload).toBeUndefined(); + await expect(access(join(snapshot, 'grok-fixture'))).rejects.toMatchObject({ code: 'ENOENT' }); + } finally { + await box.cleanup(); + } +});