Skip to content

Commit 649fc7e

Browse files
committed
fix(handoff): resolve the Kiro editor tile through the IDE app bundle on darwin
Bare `kiro` is not a deterministic IDE entry point. Once the opt-in command router is installed (v1.26.0+), `kiro` routes to whatever the user set as their default, so `kiro set-default cli` made the editor tile open the terminal agent instead of the IDE. Add `preferMacOpenBundle` to CatalogueEntry and set it on the kiro entry only: on darwin the app bundle resolves before the $PATH shim, so `open -a Kiro <dir>` reaches /Applications/Kiro.app through LaunchServices in every router state. `command: kiro` stays as the fallback, and resolution order for every other entry is unchanged. Not fixed with `kiro ide <dir>`: with the router absent (the default install) `kiro` is a Code-OSS-style launcher that treats `ide` as a path, which opens a spurious `ide` entry alongside the project. Verified on macOS arm64 with Kiro IDE 1.0.230-insider and no router installed. On win32/linux the ambiguity remains — no deterministic IDE entry point exists there today. Tests now assert the resolved launch arguments, not just platform applicability: bundle-first on darwin, shim fallback when the bundle is missing, `[dir]` only on win32/linux, an unflagged entry keeping shim-first, and a catalogue invariant that only kiro opts in. They mock the fs probe and stub process.platform so they execute on any CI platform instead of self-skipping.
1 parent 6d021bd commit 649fc7e

2 files changed

Lines changed: 162 additions & 29 deletions

File tree

apps/daemon/src/routes/host-tools.ts

Lines changed: 59 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -42,9 +42,16 @@ export interface CatalogueEntry {
4242
// macOS-only fallback: when the CLI shim is missing, look for an app
4343
// bundle by name and launch it via `open -a "<name>"`. Lets us list
4444
// Xcode / Qoder / Antigravity / Warp / IntelliJ without forcing users
45-
// to also install their CLI shim.
45+
// to also install their CLI shim. `preferMacOpenBundle` flips it from
46+
// fallback to first choice.
4647
macOpenBundle?: string | readonly string[];
4748
macOpenArgs?: (bundleName: string, resolvedDir: string) => string[];
49+
// On darwin, try `macOpenBundle` before the `$PATH` shim. For tools whose
50+
// bare shim is not a deterministic entry point, the app bundle is: it goes
51+
// through LaunchServices and lands on exactly one app. `command` stays as
52+
// the fallback so the entry still reports available when the bundle is
53+
// missing, and win32/linux resolution order is untouched.
54+
preferMacOpenBundle?: boolean;
4855
platforms?: RealPlatform[];
4956
excludedPlatforms?: RealPlatform[];
5057
}
@@ -58,7 +65,19 @@ export const CATALOGUE: ReadonlyArray<CatalogueEntry> = [
5865
{ id: 'cursor', label: 'Cursor', icon: 'sparkles', command: 'cursor', macOpenBundle: 'Cursor' },
5966
{ id: 'vscode', label: 'VS Code', icon: 'file-code', command: 'code', macOpenBundle: 'Visual Studio Code' },
6067
{ id: 'windsurf', label: 'Windsurf', icon: 'sparkles', command: 'windsurf', macOpenBundle: 'Windsurf' },
61-
{ id: 'kiro', label: 'Kiro', icon: 'sparkles', command: 'kiro', macOpenBundle: 'Kiro' },
68+
// Bare `kiro` is not a deterministic IDE entry point. Once the opt-in
69+
// command router is installed (v1.26.0+, `kiro-cli integrations install
70+
// kiro-command-router`) `kiro` routes to whatever the user set as their
71+
// default, so `kiro set-default cli` makes this tile open the terminal
72+
// agent instead of the IDE — see "Kiro Command Router" in
73+
// https://kiro.dev/docs/cli/reference/cli-commands/. On darwin we sidestep
74+
// the router by resolving /Applications/Kiro.app first: `open -a Kiro`
75+
// reaches the IDE through LaunchServices in every router state. `kiro ide
76+
// <dir>` is not a fix — with the router absent, `kiro` is a Code-OSS-style
77+
// launcher that treats `ide` as a path and adds a spurious `ide` entry. On
78+
// win32/linux the ambiguity stays unaddressed: no deterministic IDE entry
79+
// point exists there today.
80+
{ id: 'kiro', label: 'Kiro', icon: 'sparkles', command: 'kiro', macOpenBundle: 'Kiro', preferMacOpenBundle: true },
6281
{ id: 'zed', label: 'Zed', icon: 'edit', command: 'zed', macOpenBundle: 'Zed' },
6382
{ id: 'qoder', label: 'Qoder', icon: 'sparkles', command: 'qoder', macOpenBundle: ['Qoder', 'QoderWork'] },
6483
{ id: 'antigravity', label: 'Antigravity', icon: 'orbit', command: 'antigravity', macOpenBundle: ['Antigravity', 'Google Antigravity'] },
@@ -162,35 +181,47 @@ async function probeMacBundle(name: string | readonly string[]): Promise<{ name:
162181
return null;
163182
}
164183

165-
async function resolveEntry(entry: CatalogueEntry): Promise<{
184+
interface ResolvedEntry {
166185
available: boolean;
167186
resolvedPath?: string;
168187
launch?: { command: string; argsForDir: (resolvedDir: string) => string[] };
169-
}> {
170-
if (entry.command) {
171-
const resolved = await probeCommandOnPath(entry.command);
172-
if (resolved) {
173-
return {
174-
available: true,
175-
resolvedPath: resolved,
176-
launch: { command: resolved, argsForDir: entry.commandArgs ?? ((resolvedDir) => [resolvedDir]) },
177-
};
178-
}
179-
}
180-
if (entry.macOpenBundle && process.platform === 'darwin') {
181-
const bundle = await probeMacBundle(entry.macOpenBundle);
182-
if (bundle) {
183-
return {
184-
available: true,
185-
resolvedPath: bundle.path,
186-
launch: {
187-
command: await resolveMacOpenCommand(),
188-
argsForDir: entry.macOpenArgs
189-
? ((resolvedDir) => entry.macOpenArgs?.(bundle.name, resolvedDir) ?? ['-a', bundle.name, resolvedDir])
190-
: ((resolvedDir) => ['-a', bundle.name, resolvedDir]),
191-
},
192-
};
193-
}
188+
}
189+
190+
async function resolveViaPathShim(entry: CatalogueEntry): Promise<ResolvedEntry | null> {
191+
if (!entry.command) return null;
192+
const resolved = await probeCommandOnPath(entry.command);
193+
if (!resolved) return null;
194+
return {
195+
available: true,
196+
resolvedPath: resolved,
197+
launch: { command: resolved, argsForDir: entry.commandArgs ?? ((resolvedDir) => [resolvedDir]) },
198+
};
199+
}
200+
201+
async function resolveViaMacBundle(entry: CatalogueEntry): Promise<ResolvedEntry | null> {
202+
if (!entry.macOpenBundle || process.platform !== 'darwin') return null;
203+
const bundle = await probeMacBundle(entry.macOpenBundle);
204+
if (!bundle) return null;
205+
return {
206+
available: true,
207+
resolvedPath: bundle.path,
208+
launch: {
209+
command: await resolveMacOpenCommand(),
210+
argsForDir: entry.macOpenArgs
211+
? ((resolvedDir) => entry.macOpenArgs?.(bundle.name, resolvedDir) ?? ['-a', bundle.name, resolvedDir])
212+
: ((resolvedDir) => ['-a', bundle.name, resolvedDir]),
213+
},
214+
};
215+
}
216+
217+
async function resolveEntry(entry: CatalogueEntry): Promise<ResolvedEntry> {
218+
const order =
219+
entry.preferMacOpenBundle === true && process.platform === 'darwin'
220+
? [resolveViaMacBundle, resolveViaPathShim]
221+
: [resolveViaPathShim, resolveViaMacBundle];
222+
for (const resolve of order) {
223+
const resolved = await resolve(entry);
224+
if (resolved) return resolved;
194225
}
195226
return { available: false };
196227
}

apps/daemon/tests/host-tools-routes.test.ts

Lines changed: 103 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { describe, expect, it } from 'vitest';
1+
import { afterEach, describe, expect, it, vi } from 'vitest';
22

33
import {
44
CATALOGUE,
@@ -8,6 +8,24 @@ import {
88
} from '../src/routes/host-tools.js';
99
import type { CatalogueEntry, Platform } from '../src/routes/host-tools.js';
1010

11+
// Probe boundary. `installed` stays null by default so the cases that want the
12+
// real filesystem (the darwin Finder/Terminal plans below) keep hitting it;
13+
// the resolution-order cases set an allowlist instead, which lets them assert
14+
// resolved launch arguments on any CI platform rather than self-skipping.
15+
const probe = vi.hoisted(() => ({ installed: null as string[] | null }));
16+
17+
vi.mock('node:fs/promises', async (importOriginal) => {
18+
const actual = await importOriginal<typeof import('node:fs/promises')>();
19+
return {
20+
...actual,
21+
access: async (target: Parameters<typeof actual.access>[0], mode?: number) => {
22+
if (probe.installed === null) return actual.access(target, mode);
23+
if (probe.installed.includes(String(target))) return undefined;
24+
throw Object.assign(new Error(`ENOENT: ${String(target)}`), { code: 'ENOENT' });
25+
},
26+
};
27+
});
28+
1129
describe('host tools open-in launch plans', () => {
1230
it('uses the absolute macOS open command to reveal project folders in Finder', async () => {
1331
if (process.platform !== 'darwin') return;
@@ -30,6 +48,90 @@ describe('host tools open-in launch plans', () => {
3048
});
3149
});
3250

51+
// Resolution-order coverage for the launch *arguments*, not just platform
52+
// applicability. `kiro` carries preferMacOpenBundle so darwin resolves
53+
// /Applications/Kiro.app ahead of the `$PATH` shim — bare `kiro` routes to the
54+
// user's default once the Kiro command router is installed, and `kiro ide
55+
// <dir>` is not a usable substitute (it adds a spurious `ide` entry when the
56+
// router is absent). These cases pin both halves so neither can regress.
57+
describe('host tools resolution order — preferMacOpenBundle', () => {
58+
const DIR = '/tmp/open-design-project';
59+
const ORIGINAL_PLATFORM = process.platform;
60+
61+
function stubPlatform(platform: NodeJS.Platform, installed: string[]) {
62+
Object.defineProperty(process, 'platform', { value: platform, configurable: true });
63+
// Single-entry PATH so probeCommandOnPath resolves to a known absolute path.
64+
vi.stubEnv('PATH', '/fake/bin');
65+
probe.installed = installed;
66+
}
67+
68+
afterEach(() => {
69+
Object.defineProperty(process, 'platform', { value: ORIGINAL_PLATFORM, configurable: true });
70+
vi.unstubAllEnvs();
71+
probe.installed = null;
72+
});
73+
74+
it('darwin: kiro launches the app bundle through open even when the $PATH shim exists', async () => {
75+
stubPlatform('darwin', ['/fake/bin/kiro', '/Applications/Kiro.app', '/usr/bin/open']);
76+
77+
const plan = await resolveHostToolLaunchPlan('kiro', DIR);
78+
79+
expect(plan.available).toBe(true);
80+
expect(plan.resolvedPath).toBe('/Applications/Kiro.app');
81+
expect(plan.command).toBe('/usr/bin/open');
82+
expect(plan.args).toEqual(['-a', 'Kiro', DIR]);
83+
expect(plan.args).not.toContain('ide');
84+
});
85+
86+
it('darwin: kiro still falls back to the $PATH shim when the app bundle is missing', async () => {
87+
stubPlatform('darwin', ['/fake/bin/kiro', '/usr/bin/open']);
88+
89+
const plan = await resolveHostToolLaunchPlan('kiro', DIR);
90+
91+
expect(plan.available).toBe(true);
92+
expect(plan.command).toBe('/fake/bin/kiro');
93+
expect(plan.args).toEqual([DIR]);
94+
});
95+
96+
it('win32: kiro falls back to the $PATH shim with the dir as its only argument', async () => {
97+
stubPlatform('win32', ['/fake/bin/kiro.exe']);
98+
99+
const plan = await resolveHostToolLaunchPlan('kiro', DIR);
100+
101+
expect(plan.available).toBe(true);
102+
expect(plan.command).toBe('/fake/bin/kiro.exe');
103+
expect(plan.args).toEqual([DIR]);
104+
expect(plan.args).not.toContain('ide');
105+
});
106+
107+
it('linux: kiro falls back to the $PATH shim with the dir as its only argument', async () => {
108+
stubPlatform('linux', ['/fake/bin/kiro']);
109+
110+
const plan = await resolveHostToolLaunchPlan('kiro', DIR);
111+
112+
expect(plan.available).toBe(true);
113+
expect(plan.command).toBe('/fake/bin/kiro');
114+
expect(plan.args).toEqual([DIR]);
115+
expect(plan.args).not.toContain('ide');
116+
});
117+
118+
it('darwin: an unflagged entry still prefers the $PATH shim over its app bundle', async () => {
119+
stubPlatform('darwin', ['/fake/bin/cursor', '/Applications/Cursor.app', '/usr/bin/open']);
120+
121+
const plan = await resolveHostToolLaunchPlan('cursor', DIR);
122+
123+
expect(plan.available).toBe(true);
124+
expect(plan.resolvedPath).toBe('/fake/bin/cursor');
125+
expect(plan.command).toBe('/fake/bin/cursor');
126+
expect(plan.args).toEqual([DIR]);
127+
});
128+
129+
it('only kiro opts into bundle-first resolution', () => {
130+
const flagged = CATALOGUE.filter((e: CatalogueEntry) => e.preferMacOpenBundle === true);
131+
expect(flagged.map((e: CatalogueEntry) => e.id)).toEqual(['kiro']);
132+
});
133+
});
134+
33135
describe('platform gate — Warp is darwin-only, cross-platform tools stay available everywhere', () => {
34136
it('CATALOGUE includes a warp entry', () => {
35137
const warp = CATALOGUE.find((e: CatalogueEntry) => e.id === 'warp');

0 commit comments

Comments
 (0)