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
17 changes: 9 additions & 8 deletions docs/ironbee-vscode-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -508,14 +508,15 @@ could disrupt projects the user never intended to touch, and verification settin
`verification.checks` are chosen **per project** via QuickPick (or the folder's existing
`<folder>/.ironbee/config.json` if re-running), written to that folder's committed
`.ironbee/config.json`.
- **AI-client selection — default to `.cursor` when nothing is detected.** Detect existing client
dirs per folder (`.cursor`/`.claude`/`.codex`) and target the detected one(s). **If none of the
three exist, this being a Cursor extension, install into `.cursor`** by passing `--client cursor`
**explicitly**. ⚠ Do NOT rely on the CLI's own no-detection fallback: `REGISTERED_CLIENTS[0]` is
**`claude`** (`ironbee-cli/src/clients/registry.ts:10-14,88`), so an unqualified install would
land in `.claude`, not `.cursor`. `cursor` is a valid `--client` value
(`clients/cursor/index.ts:162`). (If the host is VS Code proper rather than Cursor, still default
to the detected client, or `cursor` if the extension standardizes on it.)
- **AI-client selection — install into `.cursor` ONLY, always.** This being a Cursor extension,
every install targets exactly **`cursor`**: `.cursor` is created if missing and updated
in-place if present (the CLI merges — only IronBee-owned entries in `mcp.json`/`hooks.json`/
`permissions.json` and `ironbee-*` files are written; the user's own entries are preserved).
Existing `.claude`/`.codex` dirs are **never touched** — no IronBee files are written into
them, nothing is removed from them. `cursor` is passed as `--client cursor` **explicitly**.
⚠ Do NOT rely on the CLI's own no-detection fallback: `REGISTERED_CLIENTS[0]` is **`claude`**
(`ironbee-cli/src/clients/registry.ts:10-14,88`), so an unqualified install would land in
`.claude`, not `.cursor`. `cursor` is a valid `--client` value (`clients/cursor/index.ts:162`).
- **LLM-driven platform suggestion (best-effort, reuses IronBee's own mechanism — NOT the
editor's LLM API).** The platform QuickPick offers a "Suggest platforms" affordance that
pre-selects platforms based on the project. The suggestion is produced by running the user's
Expand Down
46 changes: 8 additions & 38 deletions src/runtime/clientDetect.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,15 @@
import { promises as fs } from 'node:fs';
import type { Stats } from 'node:fs';
import * as path from 'node:path';

export type AiClient = 'cursor' | 'claude' | 'codex';

/** Marker dir each AI client uses inside a project. */
const CLIENT_DIRS: Record<AiClient, string> = {
cursor: '.cursor',
claude: '.claude',
codex: '.codex',
};

/** All clients whose marker dir already exists in the folder. */
export async function detectClients(folderDir: string): Promise<AiClient[]> {
const found: AiClient[] = [];
for (const client of Object.keys(CLIENT_DIRS) as AiClient[]) {
if (await dirExists(path.join(folderDir, CLIENT_DIRS[client]))) {
found.push(client);
}
}
return found;
}

/**
* Which client(s) `ironbee install` should target for a folder.
*
* If any client dir exists, target those. If **none** exists, default to
* **`cursor`** (this is a Cursor extension) — passed EXPLICITLY as `--client cursor`.
* We must NOT rely on the CLI's own no-detection fallback: `REGISTERED_CLIENTS[0]`
* is `claude`, so an unqualified install would land in `.claude` (design EXT-6).
* Always exactly **`cursor`** — this is a Cursor extension: it wires up only the editor the user
* is sitting in and NEVER writes into `.claude`/`.codex`, even when those dirs exist (other
* tools' setups are left completely untouched). `cursor` is passed EXPLICITLY as
* `--client cursor`: we must NOT rely on the CLI's own no-detection fallback, whose
* `REGISTERED_CLIENTS[0]` is `claude`, so an unqualified install would land in `.claude`
* (design EXT-6).
*/
export async function resolveInstallClients(folderDir: string): Promise<AiClient[]> {
const detected: AiClient[] = await detectClients(folderDir);
return detected.length > 0 ? detected : ['cursor'];
}

async function dirExists(p: string): Promise<boolean> {
try {
const st: Stats = await fs.stat(p);
return st.isDirectory();
} catch {
return false;
}
export function resolveInstallClients(): AiClient[] {
return ['cursor'];
}
4 changes: 2 additions & 2 deletions src/ui/setupFlow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ export interface FolderOutcome {
/**
* Set up ONE folder with a pre-chosen `mode` (design EXT-6). Platforms are chosen per folder
* (each project's structure differs) via `deps.pickPlatforms`; then install once per resolved
* client (defaults to `.cursor` when none is detected).
* client (always exactly `.cursor`; existing `.claude`/`.codex` setups are never touched).
*/
export async function setUpFolder(
folderDir: string,
Expand All @@ -32,7 +32,7 @@ export async function setUpFolder(
return { folder: folderDir, cancelled: true, installed: [], failed: [] };
}

const clients: AiClient[] = await resolveInstallClients(folderDir);
const clients: AiClient[] = resolveInstallClients();
const installed: AiClient[] = [];
const failed: AiClient[] = [];
for (const client of clients) {
Expand Down
2 changes: 1 addition & 1 deletion test/live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ describe.runIf(LIVE)('LIVE: ironbee install', () => {
// The extension writes this to global config so npx-devtools never downloads browsers.
await writeDevtoolsEnv({ PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '1', BROWSER_DEVTOOLS_INSTALL_CHROMIUM: 'false' });

expect(await resolveInstallClients(proj)).toEqual(['cursor']);
expect(resolveInstallClients()).toEqual(['cursor']);

const res = await runInstall(
{ nodePath: process.execPath, cliEntry },
Expand Down
45 changes: 4 additions & 41 deletions test/runtime/clientDetect.test.ts
Original file line number Diff line number Diff line change
@@ -1,45 +1,8 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { promises as fs } from 'node:fs';
import * as os from 'node:os';
import * as path from 'node:path';
import { detectClients, resolveInstallClients } from '../../src/runtime/clientDetect';

let dir: string;

beforeEach(async () => {
dir = await fs.mkdtemp(path.join(os.tmpdir(), 'ib-detect-'));
});
afterEach(async () => {
await fs.rm(dir, { recursive: true, force: true });
});

describe('detectClients', () => {
it('returns empty when no client dirs exist', async () => {
expect(await detectClients(dir)).toEqual([]);
});

it('detects each present client dir', async () => {
await fs.mkdir(path.join(dir, '.claude'));
await fs.mkdir(path.join(dir, '.codex'));
const found = await detectClients(dir);
expect(found).toContain('claude');
expect(found).toContain('codex');
expect(found).not.toContain('cursor');
});

it('ignores a same-named file (must be a directory)', async () => {
await fs.writeFile(path.join(dir, '.cursor'), 'not a dir');
expect(await detectClients(dir)).toEqual([]);
});
});
import { describe, it, expect } from 'vitest';
import { resolveInstallClients } from '../../src/runtime/clientDetect';

describe('resolveInstallClients', () => {
it('defaults to cursor when nothing is detected', async () => {
expect(await resolveInstallClients(dir)).toEqual(['cursor']);
});

it('targets detected clients when present', async () => {
await fs.mkdir(path.join(dir, '.claude'));
expect(await resolveInstallClients(dir)).toEqual(['claude']);
it('always targets exactly cursor — never claude/codex, regardless of what exists in the folder', () => {
expect(resolveInstallClients()).toEqual(['cursor']);
});
});
4 changes: 2 additions & 2 deletions test/ui/setupFlow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,10 +44,10 @@ describe('setUpFolder', () => {
expect(out.cancelled).toBe(false);
});

it('installs into a detected client instead of cursor', async () => {
it('installs ONLY into cursor even when .claude exists (other clients are never touched)', async () => {
await fs.mkdir(path.join(dir, '.claude'));
const out = await setUpFolder(dir, 'assist', deps(async () => ['node']));
expect(out.installed).toEqual(['claude']);
expect(out.installed).toEqual(['cursor']);
});

it('cancels (no install) when the platform pick is cancelled', async () => {
Expand Down
Loading