Skip to content

Commit fd8186c

Browse files
committed
Wire Desktop Extensions through agentlock install
1 parent bffdde1 commit fd8186c

2 files changed

Lines changed: 75 additions & 0 deletions

File tree

cli/src/commands/install.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ import {
3636
checkSafeTarget,
3737
executeFileOps,
3838
executeUninstallOps,
39+
listExtensionBundleManifests,
40+
listJsonFiles,
3941
readExistingFiles,
4042
} from "../util/install-fs.ts";
4143
import { claudeDesktopConfigPath } from "../detect/claude-desktop.ts";
@@ -351,6 +353,11 @@ export async function runInstall(argv: string[] = []): Promise<void> {
351353
uninstallPaths.push(resolve(join(dir, "settings.json")));
352354
} else if (id === "claude-desktop") {
353355
uninstallPaths.push(resolve(join(dir, "claude_desktop_config.json")));
356+
// Bundle manifests live one dir over and are the actual launch
357+
// source for Desktop Extensions — the daemon needs each to
358+
// unwind the wrap on uninstall.
359+
const bundlesDir = resolve(join(dir, "Claude Extensions"));
360+
uninstallPaths.push(...(await listExtensionBundleManifests(bundlesDir)));
354361
} else if (id === "codex" || id === "cursor") {
355362
uninstallPaths.push(resolve(join(dir, "hooks.json")));
356363
} else if (id === "gemini") {
@@ -411,13 +418,31 @@ export async function runInstall(argv: string[] = []): Promise<void> {
411418
const geminiSettings = resolve(
412419
join(hostConfigDirs["gemini"], "settings.json"),
413420
);
421+
// Per-extension bundle manifests are THE launch source for Desktop
422+
// Extensions installed via Settings → Extensions UI — claudeDesktopPlan
423+
// wraps each one in place using the schema-blessed _meta.agentlock
424+
// slot (MCPB v0.3+). The Claude Extensions Settings sidecar JSONs
425+
// tell us which extensions are isEnabled so disabled ones get
426+
// unwound rather than re-wrapped.
427+
const claudeDesktopBundlesDir = resolve(
428+
join(hostConfigDirs["claude-desktop"], "Claude Extensions"),
429+
);
430+
const claudeDesktopExtSettingsDir = resolve(
431+
join(hostConfigDirs["claude-desktop"], "Claude Extensions Settings"),
432+
);
433+
const bundleManifests = await listExtensionBundleManifests(
434+
claudeDesktopBundlesDir,
435+
);
436+
const extSettingsFiles = await listJsonFiles(claudeDesktopExtSettingsDir);
414437
const existingFiles = await readExistingFiles([
415438
claudeSettings,
416439
claudeDesktopConfig,
417440
codexHooks,
418441
codexConfig,
419442
cursorHooks,
420443
geminiSettings,
444+
...bundleManifests,
445+
...extSettingsFiles,
421446
]);
422447

423448
// Write the status-line script alongside the binary wrapper. Daemon

cli/src/util/install-fs.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,56 @@ export function checkSafeTarget(
5555
);
5656
}
5757

58+
// listJsonFiles returns the absolute path of every *.json file
59+
// directly under `dir`. Returns an empty array if `dir` doesn't exist
60+
// or isn't a directory — matches readExistingFiles's "missing is
61+
// fine" posture so callers can chain them safely.
62+
export async function listJsonFiles(dir: string): Promise<string[]> {
63+
let entries: import("node:fs").Dirent[];
64+
try {
65+
entries = await fs.readdir(dir, { withFileTypes: true });
66+
} catch (err: unknown) {
67+
const code = (err as { code?: string }).code;
68+
if (code === "ENOENT" || code === "ENOTDIR") return [];
69+
throw err;
70+
}
71+
const out: string[] = [];
72+
for (const e of entries) {
73+
if (!e.isFile()) continue;
74+
if (!e.name.endsWith(".json")) continue;
75+
out.push(resolve(dir, e.name));
76+
}
77+
return out;
78+
}
79+
80+
// listExtensionBundleManifests scans the "Claude Extensions" dir for
81+
// each <ext-id>/manifest.json — the on-disk bundle manifest Claude
82+
// Desktop launches from. Returns absolute paths. Missing dir is fine.
83+
export async function listExtensionBundleManifests(
84+
bundlesDir: string,
85+
): Promise<string[]> {
86+
let entries: import("node:fs").Dirent[];
87+
try {
88+
entries = await fs.readdir(bundlesDir, { withFileTypes: true });
89+
} catch (err: unknown) {
90+
const code = (err as { code?: string }).code;
91+
if (code === "ENOENT" || code === "ENOTDIR") return [];
92+
throw err;
93+
}
94+
const out: string[] = [];
95+
for (const e of entries) {
96+
if (!e.isDirectory()) continue;
97+
const manifestPath = resolve(bundlesDir, e.name, "manifest.json");
98+
try {
99+
const stat = await fs.stat(manifestPath);
100+
if (stat.isFile()) out.push(manifestPath);
101+
} catch {
102+
// No manifest.json in this bundle dir — skip silently.
103+
}
104+
}
105+
return out;
106+
}
107+
58108
// readExistingFiles loads utf8 contents for each absolute path that
59109
// exists; silently skips ENOENT so the caller can pass a list of
60110
// "maybe present" paths and the daemon merges against whatever it

0 commit comments

Comments
 (0)