@dex-ai/coding-agent 0.1.92

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/bin/dex.ts +402 -0
  2. package/package.json +45 -0
  3. package/src/__tests__/command-validation.test.ts +205 -0
  4. package/src/__tests__/history.test.ts +183 -0
  5. package/src/cli-extension.ts +153 -0
  6. package/src/commands/extension-loader.ts +399 -0
  7. package/src/commands/extension.ts +924 -0
  8. package/src/commands/update.ts +419 -0
  9. package/src/env.d.ts +5 -0
  10. package/src/extensions/cli-tui-components/ActivityPanel.vue +24 -0
  11. package/src/extensions/cli-tui-components/ActivityPanel.vue.compiled.ts +96 -0
  12. package/src/extensions/cli-tui-components/App.vue +127 -0
  13. package/src/extensions/cli-tui-components/App.vue.compiled.ts +374 -0
  14. package/src/extensions/cli-tui-components/ApprovalPrompt.vue +30 -0
  15. package/src/extensions/cli-tui-components/ApprovalPrompt.vue.compiled.ts +72 -0
  16. package/src/extensions/cli-tui-components/AskPanel.vue +228 -0
  17. package/src/extensions/cli-tui-components/AskPanel.vue.compiled.ts +419 -0
  18. package/src/extensions/cli-tui-components/CommandPalette.vue +19 -0
  19. package/src/extensions/cli-tui-components/CommandPalette.vue.compiled.ts +65 -0
  20. package/src/extensions/cli-tui-components/ConfirmModal.vue +29 -0
  21. package/src/extensions/cli-tui-components/ConfirmModal.vue.compiled.ts +72 -0
  22. package/src/extensions/cli-tui-components/DiffView.vue +139 -0
  23. package/src/extensions/cli-tui-components/DiffView.vue.compiled.ts +274 -0
  24. package/src/extensions/cli-tui-components/FormModal.vue +58 -0
  25. package/src/extensions/cli-tui-components/FormModal.vue.compiled.ts +156 -0
  26. package/src/extensions/cli-tui-components/Header.vue +13 -0
  27. package/src/extensions/cli-tui-components/Header.vue.compiled.ts +42 -0
  28. package/src/extensions/cli-tui-components/InputArea.vue +202 -0
  29. package/src/extensions/cli-tui-components/InputArea.vue.compiled.ts +243 -0
  30. package/src/extensions/cli-tui-components/InteractivePanel.vue +32 -0
  31. package/src/extensions/cli-tui-components/InteractivePanel.vue.compiled.ts +103 -0
  32. package/src/extensions/cli-tui-components/ListModal.vue +58 -0
  33. package/src/extensions/cli-tui-components/ListModal.vue.compiled.ts +130 -0
  34. package/src/extensions/cli-tui-components/MarkdownContent.ts +54 -0
  35. package/src/extensions/cli-tui-components/Messages.vue +68 -0
  36. package/src/extensions/cli-tui-components/Messages.vue.compiled.ts +253 -0
  37. package/src/extensions/cli-tui-components/Modal.vue +56 -0
  38. package/src/extensions/cli-tui-components/Modal.vue.compiled.ts +61 -0
  39. package/src/extensions/cli-tui-components/SettingsPanel.vue +178 -0
  40. package/src/extensions/cli-tui-components/SettingsPanel.vue.compiled.ts +359 -0
  41. package/src/extensions/cli-tui-components/Spinner.vue +19 -0
  42. package/src/extensions/cli-tui-components/Spinner.vue.compiled.ts +42 -0
  43. package/src/extensions/cli-tui-components/StatusBar.vue +45 -0
  44. package/src/extensions/cli-tui-components/StatusBar.vue.compiled.ts +106 -0
  45. package/src/extensions/cli-tui-components/SteeringPreview.vue +11 -0
  46. package/src/extensions/cli-tui-components/SteeringPreview.vue.compiled.ts +38 -0
  47. package/src/extensions/cli-tui-components/ThinkingBlock.vue +40 -0
  48. package/src/extensions/cli-tui-components/ThinkingBlock.vue.compiled.ts +82 -0
  49. package/src/extensions/cli-tui-components/ToolCall.vue +114 -0
  50. package/src/extensions/cli-tui-components/ToolCall.vue.compiled.ts +319 -0
  51. package/src/extensions/cli-tui-components/UserMessage.vue +40 -0
  52. package/src/extensions/cli-tui-components/UserMessage.vue.compiled.ts +148 -0
  53. package/src/extensions/cli-tui-components/ask-panel-controller.ts +573 -0
  54. package/src/extensions/cli-tui-components/settings-panel-controller.ts +958 -0
  55. package/src/extensions/cli-tui.ts +2349 -0
  56. package/src/extensions/debug.ts +46 -0
  57. package/src/extensions/headless.ts +55 -0
  58. package/src/extensions/modal-system.ts +719 -0
  59. package/src/host.ts +505 -0
  60. package/src/index.ts +9 -0
  61. package/src/input/history.ts +233 -0
  62. package/src/input/index.ts +6 -0
  63. package/src/panels/dynamic-panel.ts +5 -0
  64. package/src/panels/index.ts +43 -0
  65. package/src/panels/state.ts +73 -0
  66. package/src/panels/types.ts +79 -0
  67. package/src/panels/widget.ts +25 -0
  68. package/src/provider-registry.ts +44 -0
  69. package/src/stderr-capture.ts +248 -0
  70. package/src/types.ts +20 -0
@@ -0,0 +1,924 @@
1
+ /**
2
+ * `dex extension` CLI subcommand — manage installed extensions.
3
+ *
4
+ * Each extension is installed in its own isolated directory with a dedicated
5
+ * package.json, lockfile, and node_modules/. No shared dependency tree.
6
+ *
7
+ * Commands:
8
+ * dex extension install npm:<package> Install from npm (user scope)
9
+ * dex extension install file:<path> Install from local path (user scope)
10
+ * dex extension install npm:<package> --local Install to project scope
11
+ * dex extension uninstall <name> Remove an extension
12
+ * dex extension uninstall <name> --local Remove from project scope
13
+ * dex extension update <name> Update an extension
14
+ * dex extension update --all Update all npm extensions
15
+ * dex extension list List installed extensions
16
+ */
17
+
18
+ import {
19
+ existsSync,
20
+ readFileSync,
21
+ writeFileSync,
22
+ mkdirSync,
23
+ readdirSync,
24
+ rmSync,
25
+ } from "node:fs";
26
+ import { join, resolve } from "node:path";
27
+ import { homedir } from "node:os";
28
+ import { spawnSync } from "node:child_process";
29
+
30
+ /* ------------------------------------------------------------------ */
31
+ /* Constants */
32
+ /* ------------------------------------------------------------------ */
33
+
34
+ const USER_EXTENSIONS_DIR = join(homedir(), ".dex", "extensions");
35
+ const USER_PACKAGES_DIR = join(USER_EXTENSIONS_DIR, "packages");
36
+ const USER_REGISTRY_PATH = join(USER_EXTENSIONS_DIR, "registry.json");
37
+
38
+ function getProjectExtensionsDir(cwd: string): string {
39
+ return join(cwd, ".dex", "extensions");
40
+ }
41
+
42
+ function getProjectRegistryPath(cwd: string): string {
43
+ return join(getProjectExtensionsDir(cwd), "registry.json");
44
+ }
45
+
46
+ /* ------------------------------------------------------------------ */
47
+ /* Types */
48
+ /* ------------------------------------------------------------------ */
49
+
50
+ interface RegistryEntry {
51
+ source: "npm" | "file";
52
+ package: string;
53
+ version?: string;
54
+ path?: string;
55
+ type?: "native" | "pi-compat";
56
+ installedAt: string;
57
+ }
58
+
59
+ interface Registry {
60
+ version: 1;
61
+ extensions: Record<string, RegistryEntry>;
62
+ }
63
+
64
+ /* ------------------------------------------------------------------ */
65
+ /* Registry helpers */
66
+ /* ------------------------------------------------------------------ */
67
+
68
+ function loadRegistry(registryPath: string): Registry {
69
+ if (!existsSync(registryPath)) {
70
+ return { version: 1, extensions: {} };
71
+ }
72
+ try {
73
+ const data = JSON.parse(readFileSync(registryPath, "utf-8"));
74
+ if (data.version === 1 && data.extensions) return data;
75
+ return { version: 1, extensions: {} };
76
+ } catch {
77
+ return { version: 1, extensions: {} };
78
+ }
79
+ }
80
+
81
+ function saveRegistry(registryPath: string, registry: Registry): void {
82
+ const dir = join(registryPath, "..");
83
+ mkdirSync(dir, { recursive: true });
84
+ writeFileSync(registryPath, JSON.stringify(registry, null, 2) + "\n");
85
+
86
+ // Ensure extensions/config.json exists alongside registry
87
+ const configPath = join(dir, "config.json");
88
+ if (!existsSync(configPath)) {
89
+ writeFileSync(configPath, "{}\n");
90
+ }
91
+ }
92
+
93
+ /* ------------------------------------------------------------------ */
94
+ /* Package manager */
95
+ /* ------------------------------------------------------------------ */
96
+
97
+ type PackageManagerName = "npm" | "bun" | "pnpm" | "yarn";
98
+
99
+ function getConfiguredPackageManager(): PackageManagerName {
100
+ const settingsPath = join(homedir(), ".dex", "settings.json");
101
+ if (existsSync(settingsPath)) {
102
+ try {
103
+ const settings = JSON.parse(readFileSync(settingsPath, "utf-8"));
104
+ const pm = settings.packageManager;
105
+ if (pm === "npm" || pm === "bun" || pm === "pnpm" || pm === "yarn") {
106
+ return pm;
107
+ }
108
+ } catch {
109
+ // Fall through to default
110
+ }
111
+ }
112
+ return "npm";
113
+ }
114
+
115
+ interface PackageManagerCommands {
116
+ install: (cwd: string) => { command: string; args: string[] };
117
+ add: (cwd: string, pkg: string) => { command: string; args: string[] };
118
+ update: (cwd: string, pkg: string) => { command: string; args: string[] };
119
+ }
120
+
121
+ /**
122
+ * Copy .npmrc from ~/.dex/app to an extension directory so that npm resolves
123
+ * packages against the configured registry (e.g. local Verdaccio).
124
+ */
125
+ function ensureNpmrc(extensionDir: string): void {
126
+ const appNpmrc = join(homedir(), ".dex", "app", ".npmrc");
127
+ if (!existsSync(appNpmrc)) return;
128
+ const destNpmrc = join(extensionDir, ".npmrc");
129
+ // Always overwrite to keep in sync with the app config
130
+ try {
131
+ const contents = readFileSync(appNpmrc, "utf-8");
132
+ mkdirSync(extensionDir, { recursive: true });
133
+ writeFileSync(destNpmrc, contents);
134
+ } catch {
135
+ // Non-fatal — npm will fall back to default registry
136
+ }
137
+ }
138
+
139
+ function getPackageManagerCommands(
140
+ pm: PackageManagerName,
141
+ ): PackageManagerCommands {
142
+ switch (pm) {
143
+ case "bun":
144
+ return {
145
+ install: (cwd) => ({ command: "bun", args: ["install"] }),
146
+ add: (cwd, pkg) => ({ command: "bun", args: ["add", pkg] }),
147
+ update: (cwd, pkg) => ({ command: "bun", args: ["update", pkg] }),
148
+ };
149
+ case "pnpm":
150
+ return {
151
+ install: (cwd) => ({ command: "pnpm", args: ["install"] }),
152
+ add: (cwd, pkg) => ({ command: "pnpm", args: ["add", pkg] }),
153
+ update: (cwd, pkg) => ({ command: "pnpm", args: ["update", pkg] }),
154
+ };
155
+ case "yarn":
156
+ return {
157
+ install: (cwd) => ({ command: "yarn", args: ["install"] }),
158
+ add: (cwd, pkg) => ({ command: "yarn", args: ["add", pkg] }),
159
+ update: (cwd, pkg) => ({ command: "yarn", args: ["upgrade", pkg] }),
160
+ };
161
+ case "npm":
162
+ default:
163
+ return {
164
+ install: (cwd) => ({ command: "npm", args: ["install"] }),
165
+ add: (cwd, pkg) => ({ command: "npm", args: ["install", pkg] }),
166
+ // Use `install @latest --prefer-online` instead of `update` —
167
+ // npm update doesn't reliably extract new tarballs from local registries.
168
+ update: (cwd, pkg) => ({
169
+ command: "npm",
170
+ args: ["install", `${pkg}@latest`, "--prefer-online"],
171
+ }),
172
+ };
173
+ }
174
+ }
175
+
176
+ function runPackageManager(
177
+ cmd: { command: string; args: string[] },
178
+ cwd: string,
179
+ ): { success: boolean } {
180
+ const result = spawnSync(cmd.command, cmd.args, {
181
+ cwd,
182
+ stdio: "inherit",
183
+ });
184
+ return { success: result.status === 0 };
185
+ }
186
+
187
+ /* ------------------------------------------------------------------ */
188
+ /* Source parsing */
189
+ /* ------------------------------------------------------------------ */
190
+
191
+ interface ParsedSource {
192
+ type: "npm" | "file";
193
+ ref: string;
194
+ }
195
+
196
+ function parseSource(source: string): ParsedSource | null {
197
+ if (source.startsWith("npm:")) {
198
+ return { type: "npm", ref: source.slice(4) };
199
+ }
200
+ if (source.startsWith("file:")) {
201
+ return { type: "file", ref: source.slice(5) };
202
+ }
203
+ return null;
204
+ }
205
+
206
+ /** Derive extension name from npm package name (strip scope). */
207
+ function deriveNameFromPackage(packageName: string): string {
208
+ return packageName.replace(/^@[^/]+\//, "");
209
+ }
210
+
211
+ /** Derive extension name from tarball filename. */
212
+ function deriveNameFromTarball(tarballPath: string): string {
213
+ const base = tarballPath.split("/").pop() ?? tarballPath;
214
+ // e.g. dex-ai-devtools-extension-0.1.0.tgz -> devtools-extension
215
+ // Strip version suffix and .tgz
216
+ const withoutExt = base.replace(/\.tgz$|\.tar\.gz$/, "");
217
+ // Strip dex-ai- prefix and version suffix (-0.1.0, -1.2.3, etc.)
218
+ const withoutPrefix = withoutExt.replace(/^dex-ai-/, "");
219
+ const withoutVersion = withoutPrefix.replace(/-\d+\.\d+\.\d+(-[\w.]+)?$/, "");
220
+ return withoutVersion || withoutPrefix;
221
+ }
222
+
223
+ /** Find the actual primary package name installed in an extension's node_modules. */
224
+ function findInstalledPackageName(extensionDir: string): string | null {
225
+ // Read the extension's package.json to find what was installed as a dependency
226
+ const extPkgJsonPath = join(extensionDir, "package.json");
227
+ if (existsSync(extPkgJsonPath)) {
228
+ try {
229
+ const pkg = JSON.parse(readFileSync(extPkgJsonPath, "utf-8"));
230
+ const deps = pkg.dependencies ?? {};
231
+ const depNames = Object.keys(deps);
232
+ if (depNames.length > 0) {
233
+ // Return the first (and should be only) dependency
234
+ return depNames[0]!;
235
+ }
236
+ } catch {
237
+ // Fall through to node_modules scan
238
+ }
239
+ }
240
+
241
+ // Fallback: scan node_modules for scoped @dex-ai packages first
242
+ const nmDir = join(extensionDir, "node_modules");
243
+ if (!existsSync(nmDir)) return null;
244
+
245
+ const entries = readdirSync(nmDir);
246
+
247
+ // Prefer scoped @dex-ai packages
248
+ for (const entry of entries) {
249
+ if (entry === "@dex-ai") {
250
+ const scopeDir = join(nmDir, entry);
251
+ const scopeEntries = readdirSync(scopeDir);
252
+ // Find one that matches the extension dir name
253
+ for (const sub of scopeEntries) {
254
+ if (sub.includes("extension")) {
255
+ return `${entry}/${sub}`;
256
+ }
257
+ }
258
+ // Otherwise return the first
259
+ if (scopeEntries.length > 0) {
260
+ return `${entry}/${scopeEntries[0]}`;
261
+ }
262
+ }
263
+ }
264
+
265
+ // Fallback: first non-hidden package
266
+ for (const entry of entries) {
267
+ if (entry.startsWith(".")) continue;
268
+ if (entry.startsWith("@")) continue;
269
+ const pkgJsonPath = join(nmDir, entry, "package.json");
270
+ if (existsSync(pkgJsonPath)) {
271
+ return entry;
272
+ }
273
+ }
274
+
275
+ return null;
276
+ }
277
+
278
+ /** Resolve a file path (supports ~). */
279
+ function resolveFilePath(filePath: string): string {
280
+ if (filePath.startsWith("~/")) {
281
+ return join(homedir(), filePath.slice(2));
282
+ }
283
+ if (filePath.startsWith("~")) {
284
+ return join(homedir(), filePath.slice(1));
285
+ }
286
+ return resolve(filePath);
287
+ }
288
+
289
+ /* ------------------------------------------------------------------ */
290
+ /* Package inspection */
291
+ /* ------------------------------------------------------------------ */
292
+
293
+ interface PackageInfo {
294
+ name: string;
295
+ version?: string;
296
+ hasDexField: boolean;
297
+ hasPiField: boolean;
298
+ dexExtensions?: string[];
299
+ piExtensions?: string[];
300
+ }
301
+
302
+ /** Read package.json from an installed extension's node_modules. */
303
+ function readInstalledPackageInfo(
304
+ extensionDir: string,
305
+ packageName: string,
306
+ ): PackageInfo | null {
307
+ const pkgJsonPath = join(
308
+ extensionDir,
309
+ "node_modules",
310
+ packageName,
311
+ "package.json",
312
+ );
313
+ if (!existsSync(pkgJsonPath)) return null;
314
+ try {
315
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
316
+ return {
317
+ name: pkg.name ?? packageName,
318
+ version: pkg.version,
319
+ hasDexField: !!(pkg.dex && typeof pkg.dex === "object"),
320
+ hasPiField: !!(pkg.pi && typeof pkg.pi === "object"),
321
+ dexExtensions: pkg.dex?.extensions,
322
+ piExtensions: pkg.pi?.extensions,
323
+ };
324
+ } catch {
325
+ return null;
326
+ }
327
+ }
328
+
329
+ /** Read package.json from a source directory (for file: installs). */
330
+ function readSourcePackageInfo(dirPath: string): PackageInfo | null {
331
+ const pkgJsonPath = join(dirPath, "package.json");
332
+ if (!existsSync(pkgJsonPath)) return null;
333
+ try {
334
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
335
+ return {
336
+ name: pkg.name ?? "unknown",
337
+ version: pkg.version,
338
+ hasDexField: !!(pkg.dex && typeof pkg.dex === "object"),
339
+ hasPiField: !!(pkg.pi && typeof pkg.pi === "object"),
340
+ dexExtensions: pkg.dex?.extensions,
341
+ piExtensions: pkg.pi?.extensions,
342
+ };
343
+ } catch {
344
+ return null;
345
+ }
346
+ }
347
+
348
+ /* ------------------------------------------------------------------ */
349
+ /* Extension directory management */
350
+ /* ------------------------------------------------------------------ */
351
+
352
+ function getExtensionDir(name: string, local: boolean, cwd: string): string {
353
+ if (local) {
354
+ return join(getProjectExtensionsDir(cwd), name);
355
+ }
356
+ return join(USER_PACKAGES_DIR, name);
357
+ }
358
+
359
+ function createExtensionPackageJson(
360
+ extensionDir: string,
361
+ name: string,
362
+ packageName: string,
363
+ depValue: string,
364
+ ): void {
365
+ mkdirSync(extensionDir, { recursive: true });
366
+ const pkgJson = {
367
+ name: `dex-ext-${name}`,
368
+ private: true,
369
+ type: "module",
370
+ dependencies: {
371
+ [packageName]: depValue,
372
+ },
373
+ };
374
+ writeFileSync(
375
+ join(extensionDir, "package.json"),
376
+ JSON.stringify(pkgJson, null, 2) + "\n",
377
+ );
378
+ }
379
+
380
+ /* ------------------------------------------------------------------ */
381
+ /* Install */
382
+ /* ------------------------------------------------------------------ */
383
+
384
+ function cmdInstall(source: string, local: boolean): void {
385
+ if (!source) {
386
+ console.error("Usage: dex extension install <source> [-l|--local]");
387
+ console.error("");
388
+ console.error("Sources:");
389
+ console.error(" npm:<package> Install from npm registry");
390
+ console.error(" file:<path> Install from local filesystem");
391
+ console.error("");
392
+ console.error("Options:");
393
+ console.error(" -l, --local Install to project scope");
394
+ console.error("");
395
+ console.error("Examples:");
396
+ console.error(" dex extension install npm:@dex-ai/memory");
397
+ console.error(" dex extension install npm:pi-lens");
398
+ console.error(" dex extension install file:./my-extension");
399
+ console.error(
400
+ " dex extension install npm:@dex-ai/devtools-extension --local",
401
+ );
402
+ process.exit(1);
403
+ }
404
+
405
+ const parsed = parseSource(source);
406
+ if (!parsed) {
407
+ console.error(`Invalid source format: ${source}`);
408
+ console.error("Use npm:<package> or file:<path>");
409
+ process.exit(1);
410
+ }
411
+
412
+ const cwd = process.cwd();
413
+
414
+ if (parsed.type === "npm") {
415
+ installFromNpm(parsed.ref, local, cwd);
416
+ } else {
417
+ installFromFile(parsed.ref, local, cwd);
418
+ }
419
+ }
420
+
421
+ function installFromNpm(
422
+ packageName: string,
423
+ local: boolean,
424
+ cwd: string,
425
+ ): void {
426
+ // Detect if this is a tarball path (e.g. /path/to/foo-0.1.0.tgz)
427
+ const isTarball =
428
+ packageName.endsWith(".tgz") || packageName.endsWith(".tar.gz");
429
+
430
+ const name = isTarball
431
+ ? deriveNameFromTarball(packageName)
432
+ : deriveNameFromPackage(packageName);
433
+ const extensionDir = getExtensionDir(name, local, cwd);
434
+ const registryPath = local ? getProjectRegistryPath(cwd) : USER_REGISTRY_PATH;
435
+ const registry = loadRegistry(registryPath);
436
+ const scope = local ? "project" : "user";
437
+
438
+ if (registry.extensions[name]) {
439
+ console.log(`Updating existing extension "${name}"...`);
440
+ }
441
+
442
+ console.log(`Installing ${packageName} (${scope} scope)...`);
443
+
444
+ // Create isolated extension directory with its own package.json
445
+ // For tarballs, use the file path directly as the dep value
446
+ const depValue = isTarball ? packageName : "latest";
447
+ const depKey = isTarball ? name : packageName;
448
+
449
+ // For tarballs, we use the tarball name as both dep key and use `npm install`
450
+ // which resolves the tarball. For registry packages, use packageName@latest.
451
+ if (isTarball) {
452
+ // npm install <tarball> installs directly — use add command
453
+ mkdirSync(extensionDir, { recursive: true });
454
+ ensureNpmrc(extensionDir);
455
+ writeFileSync(
456
+ join(extensionDir, "package.json"),
457
+ JSON.stringify(
458
+ { name: `dex-ext-${name}`, private: true, type: "module" },
459
+ null,
460
+ 2,
461
+ ) + "\n",
462
+ );
463
+ const pm = getConfiguredPackageManager();
464
+ const cmds = getPackageManagerCommands(pm);
465
+ const addCmd = cmds.add(extensionDir, packageName);
466
+ const { success } = runPackageManager(addCmd, extensionDir);
467
+
468
+ if (!success) {
469
+ rmSync(extensionDir, { recursive: true, force: true });
470
+ console.error(`Failed to install ${packageName}`);
471
+ process.exit(1);
472
+ }
473
+ } else {
474
+ createExtensionPackageJson(extensionDir, name, packageName, "latest");
475
+ ensureNpmrc(extensionDir);
476
+
477
+ // Run package manager install
478
+ const pm = getConfiguredPackageManager();
479
+ const cmds = getPackageManagerCommands(pm);
480
+ const installCmd = cmds.install(extensionDir);
481
+ const { success } = runPackageManager(installCmd, extensionDir);
482
+
483
+ if (!success) {
484
+ rmSync(extensionDir, { recursive: true, force: true });
485
+ console.error(`Failed to install ${packageName}`);
486
+ process.exit(1);
487
+ }
488
+ }
489
+
490
+ // Read installed package info — for tarballs, find the actual package name from node_modules
491
+ const actualPackageName = isTarball
492
+ ? findInstalledPackageName(extensionDir)
493
+ : packageName;
494
+ const info = actualPackageName
495
+ ? readInstalledPackageInfo(extensionDir, actualPackageName)
496
+ : null;
497
+ const type = info?.hasPiField ? "pi-compat" : "native";
498
+
499
+ // Check pi-compat requirements
500
+ if (type === "pi-compat") {
501
+ // Verify @dex-ai/pi-compat is installed somewhere
502
+ const piCompatInstalled =
503
+ existsSync(join(USER_PACKAGES_DIR, "pi-compat")) ||
504
+ (local && existsSync(join(getProjectExtensionsDir(cwd), "pi-compat")));
505
+ if (!piCompatInstalled) {
506
+ console.error("");
507
+ console.error(
508
+ `Warning: "${name}" is a pi-compat extension and requires @dex-ai/pi-compat.`,
509
+ );
510
+ console.error("Install it with:");
511
+ console.error(" dex extension install npm:@dex-ai/pi-compat");
512
+ console.error("");
513
+ }
514
+ }
515
+
516
+ // Update registry
517
+ registry.extensions[name] = {
518
+ source: "npm",
519
+ package: actualPackageName ?? packageName,
520
+ ...(info?.version ? { version: info.version } : {}),
521
+ type,
522
+ installedAt: new Date().toISOString(),
523
+ };
524
+ saveRegistry(registryPath, registry);
525
+
526
+ console.log("");
527
+ console.log(
528
+ `Installed ${name}${info?.version ? ` v${info.version}` : ""} (${scope} scope)`,
529
+ );
530
+ if (type === "pi-compat") {
531
+ console.log(" Type: pi-compat");
532
+ }
533
+ }
534
+
535
+ function installFromFile(filePath: string, local: boolean, cwd: string): void {
536
+ const resolved = resolveFilePath(filePath);
537
+
538
+ if (!existsSync(resolved)) {
539
+ console.error(`Path not found: ${resolved}`);
540
+ process.exit(1);
541
+ }
542
+
543
+ // Read package.json from the source to get the package name
544
+ const info = readSourcePackageInfo(resolved);
545
+ if (!info) {
546
+ console.error(`No package.json found at: ${resolved}`);
547
+ console.error(
548
+ "For file: installs, the path must be a directory with a package.json.",
549
+ );
550
+ process.exit(1);
551
+ }
552
+
553
+ const packageName = info.name;
554
+ const name = deriveNameFromPackage(packageName);
555
+ const extensionDir = getExtensionDir(name, local, cwd);
556
+ const registryPath = local ? getProjectRegistryPath(cwd) : USER_REGISTRY_PATH;
557
+ const registry = loadRegistry(registryPath);
558
+ const scope = local ? "project" : "user";
559
+
560
+ console.log(`Installing ${packageName} from ${resolved} (${scope} scope)...`);
561
+
562
+ // Create isolated extension directory with file: dependency
563
+ createExtensionPackageJson(
564
+ extensionDir,
565
+ name,
566
+ packageName,
567
+ `file:${resolved}`,
568
+ );
569
+
570
+ // Run package manager install (installs via symlink + transitive deps)
571
+ const pm = getConfiguredPackageManager();
572
+ const cmds = getPackageManagerCommands(pm);
573
+ const installCmd = cmds.install(extensionDir);
574
+ const { success } = runPackageManager(installCmd, extensionDir);
575
+
576
+ if (!success) {
577
+ rmSync(extensionDir, { recursive: true, force: true });
578
+ console.error(`Failed to install from ${resolved}`);
579
+ process.exit(1);
580
+ }
581
+
582
+ // Read installed package info for type detection
583
+ const installedInfo = readInstalledPackageInfo(extensionDir, packageName);
584
+ const type = installedInfo?.hasPiField ? "pi-compat" : "native";
585
+
586
+ // Check pi-compat requirements
587
+ if (type === "pi-compat") {
588
+ const piCompatInstalled =
589
+ existsSync(join(USER_PACKAGES_DIR, "pi-compat")) ||
590
+ (local && existsSync(join(getProjectExtensionsDir(cwd), "pi-compat")));
591
+ if (!piCompatInstalled) {
592
+ console.error("");
593
+ console.error(
594
+ `Warning: "${name}" is a pi-compat extension and requires @dex-ai/pi-compat.`,
595
+ );
596
+ console.error("Install it with:");
597
+ console.error(" dex extension install npm:@dex-ai/pi-compat");
598
+ console.error("");
599
+ }
600
+ }
601
+
602
+ // Update registry
603
+ registry.extensions[name] = {
604
+ source: "file",
605
+ package: packageName,
606
+ path: resolved,
607
+ ...(installedInfo?.version ? { version: installedInfo.version } : {}),
608
+ type,
609
+ installedAt: new Date().toISOString(),
610
+ };
611
+ saveRegistry(registryPath, registry);
612
+
613
+ console.log("");
614
+ console.log(`Installed ${name} (file) (${scope} scope)`);
615
+ }
616
+
617
+ /* ------------------------------------------------------------------ */
618
+ /* Uninstall */
619
+ /* ------------------------------------------------------------------ */
620
+
621
+ function cmdUninstall(name: string, local: boolean): void {
622
+ if (!name) {
623
+ console.error("Usage: dex extension uninstall <name> [-l|--local]");
624
+ process.exit(1);
625
+ }
626
+
627
+ const cwd = process.cwd();
628
+ const registryPath = local ? getProjectRegistryPath(cwd) : USER_REGISTRY_PATH;
629
+ const registry = loadRegistry(registryPath);
630
+ const entry = registry.extensions[name];
631
+
632
+ if (!entry) {
633
+ console.error(`Extension "${name}" is not installed.`);
634
+ const installed = Object.keys(registry.extensions);
635
+ if (installed.length > 0) {
636
+ console.error("");
637
+ console.error("Installed extensions:");
638
+ for (const key of installed) {
639
+ console.error(` ${key}`);
640
+ }
641
+ }
642
+ process.exit(1);
643
+ }
644
+
645
+ // Remove the extension directory
646
+ const extensionDir = getExtensionDir(name, local, cwd);
647
+ if (existsSync(extensionDir)) {
648
+ console.log(`Removing ${name}...`);
649
+ rmSync(extensionDir, { recursive: true, force: true });
650
+ }
651
+
652
+ // Remove from registry
653
+ delete registry.extensions[name];
654
+ saveRegistry(registryPath, registry);
655
+
656
+ console.log(`Uninstalled "${name}"`);
657
+ }
658
+
659
+ /* ------------------------------------------------------------------ */
660
+ /* Update */
661
+ /* ------------------------------------------------------------------ */
662
+
663
+ function cmdUpdate(nameOrFlag: string, local: boolean): void {
664
+ const cwd = process.cwd();
665
+ const registryPath = local ? getProjectRegistryPath(cwd) : USER_REGISTRY_PATH;
666
+ const registry = loadRegistry(registryPath);
667
+
668
+ if (nameOrFlag === "--all") {
669
+ const npmEntries = Object.entries(registry.extensions).filter(
670
+ ([_, e]) => e.source === "npm",
671
+ );
672
+ if (npmEntries.length === 0) {
673
+ console.log("No npm extensions installed.");
674
+ return;
675
+ }
676
+ console.log(`Updating ${npmEntries.length} extension(s)...`);
677
+ console.log("");
678
+ for (const [name, entry] of npmEntries) {
679
+ updateSingle(name, entry, registry, local, cwd);
680
+ }
681
+ saveRegistry(registryPath, registry);
682
+ return;
683
+ }
684
+
685
+ if (!nameOrFlag) {
686
+ console.error(
687
+ "Usage: dex extension update <name> | dex extension update --all",
688
+ );
689
+ process.exit(1);
690
+ }
691
+
692
+ const entry = registry.extensions[nameOrFlag];
693
+ if (!entry) {
694
+ console.error(`Extension "${nameOrFlag}" is not installed.`);
695
+ process.exit(1);
696
+ }
697
+ if (entry.source !== "npm") {
698
+ console.error(
699
+ `Extension "${nameOrFlag}" is a file install. Run "npm install" in the source directory to update.`,
700
+ );
701
+ process.exit(1);
702
+ }
703
+
704
+ updateSingle(nameOrFlag, entry, registry, local, cwd);
705
+ saveRegistry(registryPath, registry);
706
+ }
707
+
708
+ function updateSingle(
709
+ name: string,
710
+ entry: RegistryEntry,
711
+ registry: Registry,
712
+ local: boolean,
713
+ cwd: string,
714
+ ): void {
715
+ const oldVersion = entry.version ?? "unknown";
716
+ const extensionDir = getExtensionDir(name, local, cwd);
717
+
718
+ if (!existsSync(extensionDir)) {
719
+ console.error(
720
+ ` Extension directory not found for "${name}". Re-install it.`,
721
+ );
722
+ return;
723
+ }
724
+
725
+ console.log(`Updating ${entry.package}...`);
726
+
727
+ // Ensure .npmrc is present so we resolve against the configured registry
728
+ ensureNpmrc(extensionDir);
729
+
730
+ const pm = getConfiguredPackageManager();
731
+ const cmds = getPackageManagerCommands(pm);
732
+ const updateCmd = cmds.update(extensionDir, entry.package);
733
+ const { success } = runPackageManager(updateCmd, extensionDir);
734
+
735
+ if (!success) {
736
+ console.error(` Failed to update ${name}`);
737
+ return;
738
+ }
739
+
740
+ // Read new version
741
+ const info = readInstalledPackageInfo(extensionDir, entry.package);
742
+ const newVersion = info?.version ?? "unknown";
743
+
744
+ registry.extensions[name] = {
745
+ ...entry,
746
+ ...(info?.version ? { version: info.version } : {}),
747
+ installedAt: new Date().toISOString(),
748
+ };
749
+
750
+ if (oldVersion !== newVersion) {
751
+ console.log(` ${name}: ${oldVersion} -> ${newVersion}`);
752
+ } else {
753
+ console.log(` ${name}: already at latest (${newVersion})`);
754
+ }
755
+ }
756
+
757
+ /* ------------------------------------------------------------------ */
758
+ /* List */
759
+ /* ------------------------------------------------------------------ */
760
+
761
+ function cmdList(): void {
762
+ const cwd = process.cwd();
763
+ const userRegistry = loadRegistry(USER_REGISTRY_PATH);
764
+ const projectRegistryPath = getProjectRegistryPath(cwd);
765
+
766
+ // Don't show project scope if it resolves to the same file as user scope
767
+ const isSamePath =
768
+ resolve(projectRegistryPath) === resolve(USER_REGISTRY_PATH);
769
+ const projectRegistry = isSamePath
770
+ ? { version: 1 as const, extensions: {} }
771
+ : loadRegistry(projectRegistryPath);
772
+
773
+ const userEntries = Object.entries(userRegistry.extensions);
774
+ const projectEntries = Object.entries(projectRegistry.extensions);
775
+
776
+ if (userEntries.length === 0 && projectEntries.length === 0) {
777
+ console.log("No extensions installed.");
778
+ console.log("");
779
+ console.log("Install one with:");
780
+ console.log(" dex extension install npm:<package>");
781
+ console.log(" dex extension install file:<path>");
782
+ return;
783
+ }
784
+
785
+ console.log("Installed extensions:");
786
+ console.log("");
787
+
788
+ if (userEntries.length > 0) {
789
+ console.log(" User:");
790
+ for (const [name, entry] of userEntries) {
791
+ // For file: sources, read live version from disk
792
+ let version = entry.version ?? "";
793
+ if (entry.source === "file" && entry.path) {
794
+ const livePkgPath = join(entry.path, "package.json");
795
+ if (existsSync(livePkgPath)) {
796
+ try {
797
+ const livePkg = JSON.parse(readFileSync(livePkgPath, "utf-8"));
798
+ if (livePkg.version) version = livePkg.version;
799
+ } catch {
800
+ /* use registry version */
801
+ }
802
+ }
803
+ }
804
+ const versionStr = version ? `v${version}` : "";
805
+ const source =
806
+ entry.source === "npm"
807
+ ? `npm:${entry.package}`
808
+ : `file:${entry.path ?? entry.package}`;
809
+ const type = entry.type === "pi-compat" ? " [pi-compat]" : "";
810
+ console.log(
811
+ ` ${name.padEnd(24)} ${versionStr.padEnd(10)} ${source}${type}`,
812
+ );
813
+ }
814
+ }
815
+
816
+ if (projectEntries.length > 0) {
817
+ if (userEntries.length > 0) console.log("");
818
+ console.log(` Project (${cwd}):`);
819
+ for (const [name, entry] of projectEntries) {
820
+ let version = entry.version ?? "";
821
+ if (entry.source === "file" && entry.path) {
822
+ const livePkgPath = join(entry.path, "package.json");
823
+ if (existsSync(livePkgPath)) {
824
+ try {
825
+ const livePkg = JSON.parse(readFileSync(livePkgPath, "utf-8"));
826
+ if (livePkg.version) version = livePkg.version;
827
+ } catch {
828
+ /* use registry version */
829
+ }
830
+ }
831
+ }
832
+ const versionStr = version ? `v${version}` : "";
833
+ const source =
834
+ entry.source === "npm"
835
+ ? `npm:${entry.package}`
836
+ : `file:${entry.path ?? entry.package}`;
837
+ const type = entry.type === "pi-compat" ? " [pi-compat]" : "";
838
+ console.log(
839
+ ` ${name.padEnd(24)} ${versionStr.padEnd(10)} ${source}${type}`,
840
+ );
841
+ }
842
+ }
843
+ }
844
+
845
+ /* ------------------------------------------------------------------ */
846
+ /* Help */
847
+ /* ------------------------------------------------------------------ */
848
+
849
+ function printHelp(): void {
850
+ console.log(`
851
+ dex extension — manage Dex extensions
852
+
853
+ Usage:
854
+ dex extension install <source> [-l] Install an extension
855
+ dex extension uninstall <name> [-l] Remove an extension
856
+ dex extension update <name> Update an npm extension
857
+ dex extension update --all Update all npm extensions
858
+ dex extension list List installed extensions
859
+
860
+ Sources:
861
+ npm:<package> Install from npm registry
862
+ file:<path> Install from local filesystem (directory with package.json)
863
+
864
+ Options:
865
+ -l, --local Operate on project scope (${process.cwd()}/.dex/extensions/)
866
+ Default is user scope (~/.dex/extensions/packages/)
867
+
868
+ Examples:
869
+ dex extension install npm:@dex-ai/memory
870
+ dex extension install npm:@dex-ai/devtools-extension
871
+ dex extension install npm:pi-lens
872
+ dex extension install file:./my-extension
873
+ dex extension install npm:@dex-ai/mcp-extension --local
874
+ dex extension uninstall memory-extension
875
+ dex extension update pi-lens
876
+ dex extension update --all
877
+ dex extension list
878
+ `);
879
+ }
880
+
881
+ /* ------------------------------------------------------------------ */
882
+ /* Main dispatch */
883
+ /* ------------------------------------------------------------------ */
884
+
885
+ export function runExtensionCommand(args: string[]): void {
886
+ const subcommand = args[0] ?? "list";
887
+ const rest = args.slice(1);
888
+
889
+ // Parse --local / -l flag from remaining args
890
+ const localIdx = rest.findIndex((a) => a === "--local" || a === "-l");
891
+ const local = localIdx !== -1;
892
+ const cleanArgs = local
893
+ ? [...rest.slice(0, localIdx), ...rest.slice(localIdx + 1)]
894
+ : rest;
895
+
896
+ switch (subcommand) {
897
+ case "install":
898
+ case "add":
899
+ cmdInstall(cleanArgs[0] ?? "", local);
900
+ break;
901
+ case "uninstall":
902
+ case "remove":
903
+ case "rm":
904
+ cmdUninstall(cleanArgs[0] ?? "", local);
905
+ break;
906
+ case "update":
907
+ case "upgrade":
908
+ cmdUpdate(cleanArgs[0] ?? "", local);
909
+ break;
910
+ case "list":
911
+ case "ls":
912
+ cmdList();
913
+ break;
914
+ case "help":
915
+ case "--help":
916
+ case "-h":
917
+ printHelp();
918
+ break;
919
+ default:
920
+ console.error(`Unknown subcommand: ${subcommand}`);
921
+ printHelp();
922
+ process.exit(1);
923
+ }
924
+ }