Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/effect-install-cursor-program.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"agent-bundle": patch
---

Report every local Cursor install failure as an `AB7004` diagnostic for the `cursor` host: a Cursor home that exists but cannot be inspected (for example an unreadable `~/.cursor`) now surfaces as `AB7004` with `target: 'cursor'` like every other Cursor install failure, instead of a bare error. Successful installs, `AB7002`/`AB7003`/`AB7005` refusals, and the Claude/Codex installers are unchanged. (#522)
9 changes: 8 additions & 1 deletion docs/diagnostics.md
Original file line number Diff line number Diff line change
Expand Up @@ -901,7 +901,14 @@ reads as absent, and a receipt that is not a regular file (a symbolic link, a
FIFO) is refused outright (`AB7004`) before it is read. The same rules apply to
the artifact itself: a file whose path could not round-trip through a receipt
(a backslash in a POSIX name, reserved characters, a trailing dot or space) is
refused (`AB7004`) before anything is staged.
refused (`AB7004`) before anything is staged. Every other failure of a local
Cursor install — a `~/.cursor` that exists but cannot be inspected, an
inventory, staging, or receipt write that fails — is reported the same way, as
`AB7004` with `target: cursor` and the underlying message; a missing
`~/.cursor` is `AB7002`. The staging directory is removed before the failure is
reported, so a refused or failed replacement never leaves a
`plugins/local/.<name>.stage-*` directory behind, and the installed copy is
untouched.

| Code | Severity | Trigger | Recovery |
| --- | --- | --- | --- |
Expand Down
152 changes: 97 additions & 55 deletions packages/agent-bundle/src/install/install.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { DiagnosticError } from '../core/diagnostics.ts';
import { errorMessage, isErrno } from '../core/errors.ts';
import { exists } from '../core/paths.ts';
import { runPromise } from '../effect/boundary.ts';
import { liftPromise } from '../effect/lift.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 {
Expand Down Expand Up @@ -854,16 +854,58 @@ const installCursorMarketplace = async (
}
};

const installCursor = async (
/**
* The Cursor installers' failure contract: a `DiagnosticError` they raised
* passes through, anything else a leaf helper threw becomes `AB7004` for the
* host. The public-CLI installers deliberately do *not* share it — a raw leaf
* failure (an unwritable receipt store, say) re-raises verbatim after their
* host-verb rollback, and `tests/install.test.ts` pins that.
*/
const installFailure = (host: InstallHost) => (error: unknown): DiagnosticError =>
error instanceof DiagnosticError ? error : failure('AB7004', errorMessage(error), host);

/**
* Stage, apply, and always remove the staging parent. The removal must
* *propagate* — a failed cleanup replaces the apply outcome, exactly as the
* former `try`/`finally` did — so this is an explicit exit sequence, not a
* scope finalizer. The FileSystem lane owns turning the staging parent into a
* scoped temporary directory.
*/
const withStagedArtifact = <A>(
stage: () => Promise<Awaited<ReturnType<typeof stageArtifact>>>,
apply: (staged: Awaited<ReturnType<typeof stageArtifact>>) => Promise<A>,
): Effect.Effect<A, LiftedRejection> =>
Effect.gen(function*() {
const staged = yield* liftPromise(stage);
const applied = yield* Effect.exit(liftPromise(() => apply(staged)));
yield* liftPromise(() => rm(staged.parent, { force: true, recursive: true }));
return yield* applied;
});

/**
* The local Cursor install as an Effect program: only the leaf I/O is lifted
* (root resolution, inventories, `exists`, `mkdir`, staging, receipts), the
* decision logic stays synchronous inside the fiber, and every failure leaves
* on the typed channel as a `DiagnosticError`.
*/
const installCursor = Effect.fnUntraced(function*(
options: InstallBundleOptions,
identity: PluginIdentity,
scope: InstallScope,
): Promise<InstallResult> => {
): Effect.fn.Return<InstallResult, DiagnosticError> {
if (scope !== 'user') {
throw failure('AB7003', `Cursor plugin installation supports only user scope, not ${scope}.`, 'cursor');
return yield* Effect.fail(
failure('AB7003', `Cursor plugin installation supports only user scope, not ${scope}.`, 'cursor'),
);
}
if (options.mode === 'marketplace') return installCursorMarketplace(options, identity);
const cursorRoot = await resolveCursorRoot(options);
if (options.mode === 'marketplace') {
return yield* liftPromise(() => installCursorMarketplace(options, identity)).pipe(
Effect.mapError(installFailure('cursor')),
);
}
const cursorRoot = yield* liftPromise(() => resolveCursorRoot(options)).pipe(
Effect.mapError(installFailure('cursor')),
);
Comment on lines +906 to +908

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Document the new Cursor AB7004 contract in both locales

When inspecting ~/.cursor fails for a reason other than ENOENT, this mapping changes the public CLI/JSON result from a raw error to AB7004 with target: "cursor". Neither website/docs/en/** nor website/docs/zh/** documents the changed failure contract; update the matching installation documentation or its generated source so both locales reflect it.

AGENTS.md reference: AGENTS.md:L71-L77

Useful? React with 👍 / 👎.

const installRoot = join(cursorRoot, 'plugins', 'local');
const destination = join(installRoot, identity.plugin);
const base = {
Expand All @@ -875,15 +917,15 @@ const installCursor = async (
receipt: join(destination, installReceiptFile),
version: identity.version,
} as const;
try {
const artifact = await treeInventory(identity.bundleRoot);
const program = Effect.gen(function*() {
const artifact = yield* liftPromise(() => treeInventory(identity.bundleRoot));
// The receipt records which host directories this installer created on the way to the plugin root
// (a fresh Cursor home has no `plugins/local`), so uninstall can prune exactly those and no more.
const hostDirectories: string[] = [];
for (const relativePath of ['plugins', 'plugins/local']) {
if (!await exists(join(cursorRoot, relativePath))) hostDirectories.push(relativePath);
if (!(yield* liftPromise(() => exists(join(cursorRoot, relativePath))))) hostDirectories.push(relativePath);
}
await mkdir(installRoot, { recursive: true });
yield* liftPromise(() => mkdir(installRoot, { recursive: true }));
const receipt: InstallReceiptIdentity = {
host: 'cursor',
hostDirectories,
Expand All @@ -893,94 +935,93 @@ const installCursor = async (
scope: 'user',
version: identity.version,
};
if (!await exists(destination)) {
const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot });
try {
await rename(staged.root, destination);
} finally {
await rm(staged.parent, { force: true, recursive: true });
}
return { ...base, contentHash: artifact.hash, state: 'installed' };
if (!(yield* liftPromise(() => exists(destination)))) {
yield* withStagedArtifact(
() => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt, stageRoot: installRoot }),
(staged) => rename(staged.root, destination),
);
return { ...base, contentHash: artifact.hash, state: 'installed' } as const;
}
if (resolve(identity.bundleRoot) === destination) {
return { ...base, contentHash: artifact.hash, state: 'already-installed' };
return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const;
}
const compared = await compareInstalledTree({
const installedManifest = yield* liftPromise(() => readInstalledManifest(destination));
const compared = yield* liftPromise(() => compareInstalledTree({
artifact,
destination,
installedManifest: await readInstalledManifest(destination),
installedManifest,
plugin: identity.plugin,
version: identity.version,
});
}));
// `uninstall --keep-data` leaves a shell holding only state/ (plus, normally, a remnant receipt that owns no
// files): a reinstall fills it back in around the preserved durable state instead of refusing it as foreign
// (nothing in it is anyone's plugin content) and reports an install, not a replacement.
const remnant = compared.ownership === 'receipt' && compared.receipt !== undefined
? isRemnantReceipt(compared.receipt)
: compared.ownership === 'foreign' && await isRuntimeStateRemnant(destination);
: compared.ownership === 'foreign' && (yield* liftPromise(() => isRuntimeStateRemnant(destination)));
const comparison: InstalledTreeComparison = remnant && compared.ownership === 'foreign'
? { ...compared, ownership: 'legacy', status: 'stale' }
: compared;
if (comparison.status === 'current') {
if (comparison.ownership === 'legacy' && options.replace === true) {
// Adoption created nothing: the legacy copy's directories are not the installer's to prune.
await writeInstallReceipt(destination, createInstallReceipt({
yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({
...receipt,
directories: [],
hostDirectories: [],
inventory: artifact,
}));
return { ...base, contentHash: artifact.hash, state: 'adopted' };
})));
return { ...base, contentHash: artifact.hash, state: 'adopted' } as const;
}
// A receipt-managed identical copy whose receipt predates format/2 is upgraded in place: the
// lifecycle fields are synthesized exactly as the reader migrates them, and nothing else changes.
if (comparison.ownership === 'receipt' && comparison.receipt?.migratedFrom !== undefined) {
await writeInstallReceipt(destination, createInstallReceipt({
const previous = comparison.receipt;
yield* liftPromise(() => writeInstallReceipt(destination, createInstallReceipt({
...receipt,
directories: comparison.receipt.directories,
hostDirectories: comparison.receipt.hostDirectories,
installedAt: comparison.receipt.installedAt,
directories: previous.directories,
hostDirectories: previous.hostDirectories,
installedAt: previous.installedAt,
inventory: artifact,
updatedAt: new Date().toISOString(),
}));
})));
}
return { ...base, contentHash: artifact.hash, state: 'already-installed' };
return { ...base, contentHash: artifact.hash, state: 'already-installed' } as const;
}
const replaceable = (comparison.status === 'stale' && comparison.ownership === 'receipt') || remnant
? true
: comparison.status !== 'foreign' && options.replace === true;
if (!replaceable) {
throw failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor');
return yield* Effect.fail(failure('AB7005', collisionMessage(destination, identity, comparison), 'cursor'));
}
// Replacing an existing copy created no host directories; the previous receipt's carry over.
const replacement: InstallReceiptIdentity = { ...receipt, hostDirectories: comparison.receipt?.hostDirectories ?? [] };
const staged = await stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot });
try {
await replaceInstalledTree({ comparison, destination, receipt: replacement, staged });
} finally {
await rm(staged.parent, { force: true, recursive: true });
}
yield* withStagedArtifact(
() => stageArtifact({ artifactRoot: identity.bundleRoot, destination, receipt: replacement, stageRoot: installRoot }),
(staged) => replaceInstalledTree({ comparison, destination, receipt: replacement, staged }),
);
// Filling a state-only shell is a fresh install of plugin content, not a replacement of any.
if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' };
if (remnant) return { ...base, contentHash: artifact.hash, state: 'installed' } as const;
return {
...base,
contentHash: artifact.hash,
previousContentHash: comparison.installedContentHash,
state: 'replaced',
};
} catch (error) {
if (error instanceof DiagnosticError) throw error;
throw failure(
'AB7004',
errorMessage(error),
'cursor',
);
}
};
} as const;
});
return yield* program.pipe(Effect.mapError(installFailure('cursor')));
});

/**
* The install program. The Cursor branch is Effect-native with a
* `DiagnosticError` channel; `readIdentity` and the public-CLI installers are
* lifted as units whose raw leaf failures cross the boundary verbatim (their
* pinned contract — the CLI entry maps those to `AB7004` itself), so the
* program's channel is the union of both.
*/
const installProgram = Effect.fnUntraced(function*(
options: InstallBundleOptions,
): Effect.fn.Return<InstallResult, unknown> {
): Effect.fn.Return<InstallResult, DiagnosticError | LiftedRejection> {
const scope = options.scope ?? 'user';
if (options.mode !== undefined && options.host !== 'cursor') {
return yield* Effect.fail(failure(
Expand All @@ -992,11 +1033,12 @@ const installProgram = Effect.fnUntraced(function*(
const identity = yield* liftPromise(() => readIdentity(options.from, options.host));
switch (options.host) {
case 'claude':
return yield* liftPromise(() => installPublicCli(options, identity, 'claude', scope));
case 'codex':
return yield* liftPromise(() => installPublicCli(options, identity, 'codex', scope));
case 'codex': {
const host = options.host;
return yield* liftPromise(() => installPublicCli(options, identity, host, scope));
}
case 'cursor':
return yield* liftPromise(() => installCursor(options, identity, scope));
return yield* installCursor(options, identity, scope);
default: {
const exhaustive: never = options.host;
return yield* Effect.fail(failure('AB7000', `Unsupported install host ${String(exhaustive)}.`, options.host));
Expand Down
69 changes: 69 additions & 0 deletions packages/agent-bundle/tests/install.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -843,6 +843,75 @@ it('fails closed when Cursor is not detected in the selected home', async () =>
}
});

it('reports a Cursor home it cannot inspect as AB7004 for the cursor host, like every other Cursor install failure', async () => {
// POSIX directory modes drive the failure: root ignores them, and Windows has no `getuid` and does not
// make a `0o000` directory untraversable, so neither can make the lstat fail here.
if (process.platform === 'win32' || process.getuid?.() === 0) return;
const fixture = await createHostBundle('cursor');
const parent = await mkdtemp(join(tmpdir(), 'agent-bundle-home-'));
const home = join(parent, 'home');
await mkdir(join(home, '.cursor'), { recursive: true });
await chmod(home, 0o000);
try {
const error = await installBundle({
from: fixture.from,
home,
host: 'cursor',
scope: 'user',
}).catch((failure: unknown) => failure);

expect(error).toBeInstanceOf(DiagnosticError);
expect((error as DiagnosticError).diagnostics).toMatchObject([{
code: 'AB7004',
message: expect.stringContaining('EACCES'),
severity: 'error',
target: 'cursor',
}]);
} finally {
await chmod(home, 0o755);
await Promise.all([
rm(fixture.cleanupRoot, { force: true, recursive: true }),
rm(parent, { force: true, recursive: true }),
]);
}
});

it('removes the staging parent after a failed replacement and re-raises the refusal as AB7004', async () => {
const fixture = await createHostBundle('cursor');
const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-'));
const installRoot = join(home, '.cursor', 'plugins', 'local');
await mkdir(installRoot, { recursive: true });
try {
const first = await installBundle({ from: fixture.from, home, host: 'cursor', scope: 'user' });
expect(first.state).toBe('installed');
const destination = first.destination;
if (destination === undefined) throw new Error('Expected a local Cursor install destination.');
// The rebuilt artifact ships a new file exactly where the installed copy holds an unowned one, so
// the swap is staged in full and then refused by replaceInstalledTree.
await mkdir(join(fixture.bundleRoot, 'skills', 'new'), { recursive: true });
await writeFile(join(fixture.bundleRoot, 'skills', 'new', 'SKILL.md'), '# new\n');
await mkdir(join(destination, 'skills', 'new'), { recursive: true });
await writeFile(join(destination, 'skills', 'new', 'SKILL.md'), '# operator-owned\n');
const error = await installBundle({ from: fixture.from, home, host: 'cursor', replace: true, scope: 'user' })
.catch((failure: unknown) => failure);

expect(error).toBeInstanceOf(DiagnosticError);
expect((error as DiagnosticError).diagnostics).toMatchObject([{
code: 'AB7004',
message: expect.stringContaining('Refusing to overwrite unowned files'),
target: 'cursor',
}]);
// The refusal happened after staging: the staging parent is gone and the installed copy is untouched.
expect(await readdir(installRoot)).toEqual([destination.slice(installRoot.length + 1)]);
await expect(readFile(join(destination, 'skills', 'new', 'SKILL.md'), 'utf8')).resolves.toBe('# operator-owned\n');
} finally {
await Promise.all([
rm(fixture.cleanupRoot, { force: true, recursive: true }),
rm(home, { force: true, recursive: true }),
]);
}
});

it('refreshes a receipt whose inventory drifted even when the owned bytes hash equal, and restructures owned paths', async () => {
const fixture = await createHostBundle('cursor');
const home = await mkdtemp(join(tmpdir(), 'agent-bundle-home-'));
Expand Down
Loading