Skip to content
Draft
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
8 changes: 8 additions & 0 deletions packages/installer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,13 +31,21 @@ Restart your AI tools afterward to load the plugin.
npx @sentry/ai install # interactive — pick which agents to set up
npx @sentry/ai install "Setup logging" # copy a custom prompt after installation
npx @sentry/ai install --no-interactive # install into every detected agent
npx @sentry/ai install --develop # install the develop build instead of the release
```

When an instruction follows `install`, the installer offers to copy a prompt such as
`The Sentry plugin has just been installed. Setup logging` after installation.
Without an instruction, it offers the default get-started prompt.
The non-interactive mode is intended for CI and unattended runs and skips this prompt.

`--develop` installs from the `develop` branch of each plugin repository, which is
rebuilt on every merge, rather than the released build.
Because both builds provide the same skills, it first removes any install of the
released plugin — including the one from the assistant’s official marketplace — so only
one copy resolves. Switching back is `install` without the flag, and a plain `remove`
takes out whichever build is present.

## What it installs

For each detected assistant, the installer runs that tool’s native plugin command:
Expand Down
225 changes: 219 additions & 6 deletions packages/installer/src/__tests__/harnesses.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,17 @@ const claudeList = (ids: string[]): ShellResult => ({
stdout: JSON.stringify(ids.map((id) => ({ id }))),
});

const codexList = (pluginIds: string[]): ShellResult => ({
// Entries may carry a version, which is what distinguishes a develop build from
// a release for the harnesses whose plugin id is the same on both channels.
const codexList = (plugins: (string | { pluginId: string; version: string })[]): ShellResult => ({
ok: true,
stdout: JSON.stringify({ installed: pluginIds.map((pluginId) => ({ pluginId })) }),
stdout: JSON.stringify({
installed: plugins.map((p) => (typeof p === "string" ? { pluginId: p } : p)),
}),
});

const grokList = (
plugins: { name: string; source: string; marketplace?: string | null }[],
plugins: { name: string; source: string; marketplace?: string | null; version?: string }[],
): ShellResult => ({
ok: true,
stdout: JSON.stringify(plugins.map((p) => ({ marketplace: null, ...p }))),
Expand Down Expand Up @@ -410,9 +414,15 @@ describe("cursor harness", () => {
expect(await harness.detect()).toBe(false);
});

it("reports installed when the plugin directory exists", async () => {
it("reports installed when the checkout is on the stable branch", async () => {
const target = "/home/user/.cursor/plugins/local/sentry";
const harness = createCursor(fakeSystem({ homedir: "/home/user", existing: [target] }));
const harness = createCursor(
fakeSystem({
homedir: "/home/user",
existing: [target],
run: () => ({ ok: true, stdout: "main\n" }),
}),
);
expect(await harness.isInstalled()).toBe(true);
});

Expand Down Expand Up @@ -442,7 +452,7 @@ describe("cursor harness", () => {

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
'git clone https://github.com/getsentry/plugin-cursor.git "/home/user/.cursor/plugins/local/sentry"',
'git clone --branch main https://github.com/getsentry/plugin-cursor.git "/home/user/.cursor/plugins/local/sentry"',
);
});

Expand Down Expand Up @@ -473,3 +483,206 @@ describe("cursor harness", () => {
expect(system.run).toHaveBeenCalledWith(`rmdir /s /q "${target}"`);
});
});

// The `--develop` channel: every harness installs from the develop ref of our own
// distribution repo, and takes out whatever occupies the same slot on the other
// channel so only one copy of the skills resolves.
describe("develop channel", () => {
const DEVELOP = { ref: "develop" };
const CURSOR_DIR = "/home/user/.cursor/plugins/local/sentry";

const onBranch =
(branch: string) =>
(cmd: string): ShellResult =>
cmd.includes("rev-parse --abbrev-ref") ? { ok: true, stdout: `${branch}\n` } : ok;

it("claude adds our marketplace pinned to the ref and installs from it", async () => {
const system = fakeSystem({ run: () => ok });
const outcome = await createClaude(system, DEVELOP).install();

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
"claude plugin marketplace add getsentry/plugin-claude@develop",
);
expect(system.run).toHaveBeenCalledWith(
"claude plugin install sentry@sentry-plugin-marketplace",
);
});

it("claude removes the official plugin before a develop install", async () => {
const system = fakeSystem({
run: (cmd) => (isList(cmd) ? claudeList(["sentry@claude-plugins-official"]) : ok),
});
const cleaned = await createClaude(system, DEVELOP).cleanup?.();

expect(cleaned).toContain("sentry@claude-plugins-official");
expect(system.run).toHaveBeenCalledWith(
"claude plugin uninstall sentry@claude-plugins-official",
);
});

it("claude removes the develop plugin when going back to stable", async () => {
const system = fakeSystem({
run: (cmd) => (isList(cmd) ? claudeList(["sentry@sentry-plugin-marketplace"]) : ok),
});
const cleaned = await createClaude(system).cleanup?.();

expect(cleaned).toContain("sentry@sentry-plugin-marketplace");
expect(system.run).toHaveBeenCalledWith(
"claude plugin uninstall sentry@sentry-plugin-marketplace",
);
});

it("claude does not treat the stable install as a develop install", async () => {
const system = fakeSystem({
run: (cmd) => (isList(cmd) ? claudeList(["sentry@claude-plugins-official"]) : ok),
});

expect(await createClaude(system, DEVELOP).isInstalled()).toBe(false);
expect(await createClaude(system).isInstalled()).toBe(true);
});

it("claude removes both channels under anyChannel", async () => {
const system = fakeSystem({
run: (cmd) =>
isList(cmd)
? claudeList(["sentry@sentry-plugin-marketplace", "sentry@claude-plugins-official"])
: ok,
});
const outcome = await createClaude(system, { anyChannel: true }).remove();

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
"claude plugin uninstall sentry@sentry-plugin-marketplace",
);
expect(system.run).toHaveBeenCalledWith(
"claude plugin uninstall sentry@claude-plugins-official",
);
});

it("codex re-points the marketplace at the ref", async () => {
const system = fakeSystem({ run: (cmd) => (isList(cmd) ? codexList([]) : ok) });
const outcome = await createCodex(system, DEVELOP).install();

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
"codex plugin marketplace remove sentry-plugin-marketplace",
);
expect(system.run).toHaveBeenCalledWith(
"codex plugin marketplace add getsentry/plugin-codex --ref develop",
);
});

it("codex tells the channels apart by version", async () => {
const develop = fakeSystem({
run: (cmd) =>
isList(cmd)
? codexList([
{ pluginId: "sentry@sentry-plugin-marketplace", version: "1.2.1-dev.4.gabc" },
])
: ok,
});
const release = fakeSystem({
run: (cmd) =>
isList(cmd)
? codexList([{ pluginId: "sentry@sentry-plugin-marketplace", version: "1.3.0" }])
: ok,
});

expect(await createCodex(develop, DEVELOP).isInstalled()).toBe(true);
expect(await createCodex(develop).isInstalled()).toBe(false);
expect(await createCodex(release, DEVELOP).isInstalled()).toBe(false);
expect(await createCodex(release).isInstalled()).toBe(true);
});

it("codex re-points a stable run that finds a develop build", async () => {
const system = fakeSystem({
run: (cmd) =>
isList(cmd)
? codexList([
{ pluginId: "sentry@sentry-plugin-marketplace", version: "1.2.1-dev.4.gabc" },
])
: ok,
});
await createCodex(system).install();

expect(system.run).toHaveBeenCalledWith(
"codex plugin marketplace remove sentry-plugin-marketplace",
);
expect(system.run).toHaveBeenCalledWith("codex plugin marketplace add getsentry/plugin-codex");
});

it("grok installs from the ref-pinned source", async () => {
const system = fakeSystem({ run: (cmd) => (isList(cmd) ? grokList([]) : ok) });
const outcome = await createGrok(system, DEVELOP).install();

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
"grok plugin install getsentry/plugin-grok@develop --trust",
);
});

it("grok clears a release build out of its single sentry slot", async () => {
const system = fakeSystem({
run: (cmd) =>
isList(cmd)
? grokList([
{
name: "sentry",
source: "https://github.com/getsentry/plugin-grok",
version: "1.3.0",
},
])
: ok,
});
const cleaned = await createGrok(system, DEVELOP).cleanup?.();

expect(cleaned).toContain("1.3.0");
expect(system.run).toHaveBeenCalledWith("grok plugin uninstall sentry");
});

it("cursor clones the ref and reports the branch as the channel", async () => {
const system = fakeSystem({ homedir: "/home/user", run: onBranch("develop") });
const outcome = await createCursor(system, DEVELOP).install();

expect(outcome.kind).toBe("done");
expect(system.run).toHaveBeenCalledWith(
`git clone --branch develop https://github.com/getsentry/plugin-cursor.git "${CURSOR_DIR}"`,
);
});

it("cursor discards a stable checkout when the develop ref is asked for", async () => {
const system = fakeSystem({
homedir: "/home/user",
existing: [CURSOR_DIR],
run: onBranch("main"),
});
const harness = createCursor(system, DEVELOP);

expect(await harness.isInstalled()).toBe(false);
expect(await harness.cleanup?.()).toContain("main");
expect(system.run).toHaveBeenCalledWith(`rm -rf "${CURSOR_DIR}"`);
});

it("cursor keeps a checkout already on the requested ref", async () => {
const system = fakeSystem({
homedir: "/home/user",
existing: [CURSOR_DIR],
run: onBranch("develop"),
});
const harness = createCursor(system, DEVELOP);

expect(await harness.isInstalled()).toBe(true);
expect(await harness.cleanup?.()).toBeNull();
});

it("cursor under anyChannel counts any branch as installed", async () => {
const system = fakeSystem({
homedir: "/home/user",
existing: [CURSOR_DIR],
run: onBranch("develop"),
});

expect(await createCursor(system, { anyChannel: true }).isInstalled()).toBe(true);
});
});
67 changes: 67 additions & 0 deletions packages/installer/src/harnesses/channel.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
/**
* Which build of the plugin a harness installs.
*
* `ref` is a git ref in the plugin's distribution repository. Omitting it
* installs the stable channel, which means the repository's default branch — and
* for Claude, the vendor's official marketplace rather than our repository at
* all. The CLI only exposes `--develop`, so in practice `ref` is either
* undefined or `"develop"`; it is modeled as a ref so pinning a release tag
* later needs no new plumbing.
*/
export interface HarnessOptions {
ref?: string;
/**
* Ignore the channel when deciding what counts as installed. `remove` sets it
* so it takes out whatever build is present instead of only the channel it was
* built for — a develop install has to be removable by a plain `remove`.
*/
anyChannel?: boolean;
}

/**
* The branch each distribution repository publishes releases on. The stable
* channel tracks it by passing no ref at all, so this is only needed where a
* harness has to name the branch it expects to already be on.
*/
export const STABLE_BRANCH = "main";

/**
* Whether these options ask for a pre-release build.
*
* Anything other than the release branch counts, so `--develop` reads as
* pre-release while an explicit `main` reads as stable. A future release-tag
* pin would land on the wrong side of this and needs revisiting alongside
* {@link isDevelopVersion}.
*/
function wantsDevelop(options: HarnessOptions): boolean {
return options.ref !== undefined && options.ref !== STABLE_BRANCH;
}

/**
* Whether an installed plugin's version string is a develop build.
*
* `scripts/dev-version.sh` stamps every develop build with a `-dev.` prerelease
* (`1.2.1-dev.14.gdeadbee`) and a release never carries one, so the version is
* the channel marker for the harnesses whose plugin id is identical on both
* channels.
*/
export function isDevelopVersion(version: string | null | undefined): boolean {
return (version ?? "").includes("-dev.");
}

/**
* Whether an installed plugin belongs to the channel that was asked for. Used to
* decide whether an existing install counts as "already installed" or as the
* other channel's copy that has to be replaced. Always true under
* {@link HarnessOptions.anyChannel}.
*/
export function matchesChannel(
version: string | null | undefined,
options: HarnessOptions,
): boolean {
if (options.anyChannel) {
return true;
}

return isDevelopVersion(version) === wantsDevelop(options);
}
Loading
Loading