xtrm-cli 0.5.0

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 (93) hide show
  1. package/.gemini/settings.json +39 -0
  2. package/dist/index.cjs +57378 -0
  3. package/dist/index.cjs.map +1 -0
  4. package/dist/index.d.cts +2 -0
  5. package/extensions/beads.ts +109 -0
  6. package/extensions/core/adapter.ts +45 -0
  7. package/extensions/core/lib.ts +3 -0
  8. package/extensions/core/logger.ts +45 -0
  9. package/extensions/core/runner.ts +71 -0
  10. package/extensions/custom-footer.ts +160 -0
  11. package/extensions/main-guard-post-push.ts +44 -0
  12. package/extensions/main-guard.ts +126 -0
  13. package/extensions/minimal-mode.ts +201 -0
  14. package/extensions/quality-gates.ts +67 -0
  15. package/extensions/service-skills.ts +150 -0
  16. package/extensions/xtrm-loader.ts +89 -0
  17. package/hooks/gitnexus-impact-reminder.py +13 -0
  18. package/lib/atomic-config.js +236 -0
  19. package/lib/config-adapter.js +231 -0
  20. package/lib/config-injector.js +80 -0
  21. package/lib/context.js +73 -0
  22. package/lib/diff.js +142 -0
  23. package/lib/env-manager.js +160 -0
  24. package/lib/sync-mcp-cli.js +345 -0
  25. package/lib/sync.js +227 -0
  26. package/package.json +47 -0
  27. package/src/adapters/base.ts +29 -0
  28. package/src/adapters/claude.ts +38 -0
  29. package/src/adapters/registry.ts +21 -0
  30. package/src/commands/claude.ts +122 -0
  31. package/src/commands/clean.ts +371 -0
  32. package/src/commands/end.ts +239 -0
  33. package/src/commands/finish.ts +25 -0
  34. package/src/commands/help.ts +180 -0
  35. package/src/commands/init.ts +959 -0
  36. package/src/commands/install-pi.ts +276 -0
  37. package/src/commands/install-service-skills.ts +281 -0
  38. package/src/commands/install.ts +427 -0
  39. package/src/commands/pi-install.ts +119 -0
  40. package/src/commands/pi.ts +128 -0
  41. package/src/commands/reset.ts +12 -0
  42. package/src/commands/status.ts +170 -0
  43. package/src/commands/worktree.ts +193 -0
  44. package/src/core/context.ts +141 -0
  45. package/src/core/diff.ts +174 -0
  46. package/src/core/interactive-plan.ts +165 -0
  47. package/src/core/manifest.ts +26 -0
  48. package/src/core/preflight.ts +142 -0
  49. package/src/core/rollback.ts +32 -0
  50. package/src/core/session-state.ts +139 -0
  51. package/src/core/sync-executor.ts +427 -0
  52. package/src/core/xtrm-finish.ts +267 -0
  53. package/src/index.ts +87 -0
  54. package/src/tests/policy-parity.test.ts +204 -0
  55. package/src/tests/session-flow-parity.test.ts +118 -0
  56. package/src/tests/session-state.test.ts +124 -0
  57. package/src/tests/xtrm-finish.test.ts +148 -0
  58. package/src/types/config.ts +51 -0
  59. package/src/types/models.ts +52 -0
  60. package/src/utils/atomic-config.ts +467 -0
  61. package/src/utils/banner.ts +194 -0
  62. package/src/utils/config-adapter.ts +90 -0
  63. package/src/utils/config-injector.ts +81 -0
  64. package/src/utils/env-manager.ts +193 -0
  65. package/src/utils/hash.ts +42 -0
  66. package/src/utils/repo-root.ts +39 -0
  67. package/src/utils/sync-mcp-cli.ts +395 -0
  68. package/src/utils/theme.ts +37 -0
  69. package/src/utils/worktree-session.ts +93 -0
  70. package/test/atomic-config-prune.test.ts +101 -0
  71. package/test/atomic-config.test.ts +138 -0
  72. package/test/clean.test.ts +172 -0
  73. package/test/config-schema.test.ts +52 -0
  74. package/test/context.test.ts +33 -0
  75. package/test/end-worktree.test.ts +168 -0
  76. package/test/extensions/beads.test.ts +166 -0
  77. package/test/extensions/extension-harness.ts +85 -0
  78. package/test/extensions/main-guard.test.ts +77 -0
  79. package/test/extensions/minimal-mode.test.ts +107 -0
  80. package/test/extensions/quality-gates.test.ts +79 -0
  81. package/test/extensions/service-skills.test.ts +84 -0
  82. package/test/extensions/xtrm-loader.test.ts +53 -0
  83. package/test/hooks/quality-check-hooks.test.ts +45 -0
  84. package/test/hooks.test.ts +1075 -0
  85. package/test/install-pi.test.ts +185 -0
  86. package/test/install-project.test.ts +378 -0
  87. package/test/install-service-skills.test.ts +131 -0
  88. package/test/install-surface.test.ts +72 -0
  89. package/test/runtime-subcommands.test.ts +121 -0
  90. package/test/session-launcher.test.ts +139 -0
  91. package/tsconfig.json +22 -0
  92. package/tsup.config.ts +17 -0
  93. package/vitest.config.ts +10 -0
@@ -0,0 +1,427 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import prompts from 'prompts';
4
+ import { Listr } from 'listr2';
5
+ import fs from 'fs-extra';
6
+ import { getContext } from '../core/context.js';
7
+ import { calculateDiff, PruneModeReadError } from '../core/diff.js';
8
+ import { executeSync, syncMcpForTargets } from '../core/sync-executor.js';
9
+ import { findRepoRoot } from '../utils/repo-root.js';
10
+ import { t, sym } from '../utils/theme.js';
11
+ import path from 'path';
12
+ import { runPiInstall } from './pi-install.js';
13
+
14
+ interface TargetChanges {
15
+ target: string;
16
+ changeSet: any;
17
+ totalChanges: number;
18
+ skippedDrifted: string[];
19
+ error?: string;
20
+ }
21
+
22
+ interface DiffCtx {
23
+ allChanges: TargetChanges[];
24
+ }
25
+
26
+ import type { ChangeSet } from '../types/config.js';
27
+
28
+ function renderPlanTable(allChanges: TargetChanges[]): void {
29
+ const Table = require('cli-table3');
30
+
31
+ const table = new Table({
32
+ head: [
33
+ t.header('Target'),
34
+ t.header(kleur.green('+ New')),
35
+ t.header(kleur.yellow('↑ Update')),
36
+ t.header('Total'),
37
+ ],
38
+ style: { head: [], border: [] },
39
+ });
40
+
41
+ for (const { target, changeSet, totalChanges } of allChanges) {
42
+ const missing = Object.values(changeSet).reduce((s: number, c: any) => s + c.missing.length, 0) as number;
43
+ const outdated = Object.values(changeSet).reduce((s: number, c: any) => s + c.outdated.length, 0) as number;
44
+
45
+ table.push([
46
+ kleur.white(formatTargetLabel(target)),
47
+ missing > 0 ? kleur.green(String(missing)) : t.label('—'),
48
+ outdated > 0 ? kleur.yellow(String(outdated)) : t.label('—'),
49
+ kleur.bold().white(String(totalChanges)),
50
+ ]);
51
+ }
52
+
53
+ console.log('\n' + table.toString() + '\n');
54
+ }
55
+
56
+ async function renderSummaryCard(
57
+ allChanges: TargetChanges[],
58
+ totalCount: number,
59
+ allSkipped: string[],
60
+ isDryRun: boolean,
61
+ ): Promise<void> {
62
+ const boxen = (await import('boxen')).default;
63
+
64
+ const hasDrift = allSkipped.length > 0;
65
+ const lines = [
66
+ hasDrift ? t.boldGreen(' ✓ Install complete') + t.warning(' (with skipped drift)') : t.boldGreen(' ✓ Install complete'),
67
+ '',
68
+ ` ${t.label('Targets')} ${allChanges.length} environment${allChanges.length !== 1 ? 's' : ''}`,
69
+ ` ${t.label('Installed')} ${totalCount} item${totalCount !== 1 ? 's' : ''}`,
70
+ ...(hasDrift ? [
71
+ ` ${t.label('Skipped')} ${kleur.yellow(String(allSkipped.length))} drifted (local changes preserved)`,
72
+ ` ${t.label('Hint')} run ${t.accent('xtrm install --backport')} to push them back`,
73
+ ] : []),
74
+ ...(isDryRun ? ['', t.accent(' Dry run — no changes written')] : []),
75
+ ];
76
+
77
+ console.log('\n' + boxen(lines.join('\n'), {
78
+ padding: { top: 1, bottom: 1, left: 1, right: 3 },
79
+ borderStyle: 'round',
80
+ borderColor: hasDrift ? 'yellow' : 'green',
81
+ }) + '\n');
82
+ }
83
+
84
+ import { execSync } from 'child_process';
85
+
86
+ import { spawnSync } from 'child_process';
87
+ import { detectAgent } from '../utils/sync-mcp-cli.js';
88
+ function formatTargetLabel(target: string): string {
89
+ const normalized = target.replace(/\\/g, '/').toLowerCase();
90
+ if (normalized.endsWith('/.agents/skills') || normalized.includes('/.agents/skills/')) return '~/.agents/skills';
91
+ if (normalized.endsWith('/.claude') || normalized.includes('/.claude/')) return '~/.claude';
92
+ return path.basename(target);
93
+ }
94
+
95
+ function isBeadsInstalled(): boolean {
96
+ try {
97
+ execSync('bd --version', { stdio: 'ignore' });
98
+ return true;
99
+ } catch {
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function isDoltInstalled(): boolean {
105
+ try {
106
+ execSync('dolt version', { stdio: 'ignore' });
107
+ return true;
108
+ } catch {
109
+ return false;
110
+ }
111
+ }
112
+
113
+ function isGitnexusInstalled(): boolean {
114
+ try {
115
+ execSync('gitnexus --version', { stdio: 'ignore' });
116
+ return true;
117
+ } catch {
118
+ return false;
119
+ }
120
+ }
121
+
122
+ async function needsSettingsSync(repoRoot: string, target: string): Promise<boolean> {
123
+ const normalizedTarget = target.replace(/\\/g, '/').toLowerCase();
124
+ if (normalizedTarget.includes('.agents/skills')) return false;
125
+ // Claude Code: hooks and MCP are managed by the xtrm-tools plugin — no settings wiring needed
126
+ if (detectAgent(target) === 'claude') return false;
127
+
128
+ const hooksTemplatePath = path.join(repoRoot, 'config', 'hooks.json');
129
+ if (!await fs.pathExists(hooksTemplatePath)) return false;
130
+
131
+ const requiredEvents = Object.keys((await fs.readJson(hooksTemplatePath)).hooks ?? {});
132
+ if (requiredEvents.length === 0) return false;
133
+
134
+ const targetSettingsPath = path.join(target, 'settings.json');
135
+ if (!await fs.pathExists(targetSettingsPath)) return true;
136
+
137
+ let settings: any = {};
138
+ try {
139
+ settings = await fs.readJson(targetSettingsPath);
140
+ } catch {
141
+ return true;
142
+ }
143
+
144
+ const targetHooks = settings?.hooks;
145
+ if (!targetHooks || typeof targetHooks !== 'object' || Object.keys(targetHooks).length === 0) {
146
+ return true;
147
+ }
148
+
149
+ return requiredEvents.some((event) => !(event in targetHooks));
150
+ }
151
+
152
+ const OFFICIAL_CLAUDE_MARKETPLACE = 'https://github.com/anthropics/claude-plugins-official';
153
+ const OFFICIAL_CLAUDE_PLUGINS = [
154
+ 'serena@claude-plugins-official',
155
+ 'context7@claude-plugins-official',
156
+ 'github@claude-plugins-official',
157
+ 'ralph-loop@claude-plugins-official',
158
+ ] as const;
159
+
160
+ export async function installOfficialClaudePlugins(dryRun: boolean): Promise<void> {
161
+ console.log(t.bold('\n ⚙ official Claude plugins (serena/context7/github/ralph-loop)'));
162
+
163
+ if (dryRun) {
164
+ console.log(t.accent(' [DRY RUN] Would register claude-plugins-official marketplace and install official plugins\n'));
165
+ return;
166
+ }
167
+
168
+ // Ensure official marketplace is registered
169
+ spawnSync('claude', ['plugin', 'marketplace', 'add', OFFICIAL_CLAUDE_MARKETPLACE, '--scope', 'user'], { stdio: 'pipe' });
170
+
171
+ const listResult = spawnSync('claude', ['plugin', 'list'], { encoding: 'utf8', stdio: 'pipe' });
172
+ const installedOutput = listResult.stdout ?? '';
173
+
174
+ let installedCount = 0;
175
+ let alreadyInstalledCount = 0;
176
+
177
+ for (const pluginId of OFFICIAL_CLAUDE_PLUGINS) {
178
+ if (installedOutput.includes(pluginId)) {
179
+ alreadyInstalledCount += 1;
180
+ continue;
181
+ }
182
+
183
+ const result = spawnSync('claude', ['plugin', 'install', pluginId, '--scope', 'user'], { stdio: 'inherit' });
184
+ if (result.status === 0) {
185
+ installedCount += 1;
186
+ } else {
187
+ console.log(t.warning(` ! Failed to install ${pluginId}. Install manually: claude plugin install ${pluginId} --scope user`));
188
+ }
189
+ }
190
+
191
+ console.log(t.success(` ✓ Official plugins ready (${installedCount} installed, ${alreadyInstalledCount} already present)\n`));
192
+ }
193
+
194
+ export async function installPlugin(repoRoot: string, dryRun: boolean): Promise<void> {
195
+ console.log(t.bold('\n ⚙ xtrm-tools (Claude Code plugin)'));
196
+
197
+ if (dryRun) {
198
+ console.log(t.accent(' [DRY RUN] Would register xtrm-tools marketplace and install plugin\n'));
199
+ await installOfficialClaudePlugins(true);
200
+ return;
201
+ }
202
+
203
+ // Register marketplace (re-register to pick up any path changes)
204
+ spawnSync('claude', ['plugin', 'marketplace', 'add', repoRoot, '--scope', 'user'], { stdio: 'pipe' });
205
+
206
+ // Always uninstall + reinstall to refresh the cached copy from the live repo
207
+ const listResult = spawnSync('claude', ['plugin', 'list'], { encoding: 'utf8', stdio: 'pipe' });
208
+ if (listResult.stdout?.includes('xtrm-tools@xtrm-tools')) {
209
+ spawnSync('claude', ['plugin', 'uninstall', 'xtrm-tools@xtrm-tools'], { stdio: 'inherit' });
210
+ }
211
+ spawnSync('claude', ['plugin', 'install', 'xtrm-tools@xtrm-tools', '--scope', 'user'], { stdio: 'inherit' });
212
+
213
+ console.log(t.success(' ✓ xtrm-tools plugin installed'));
214
+
215
+ await installOfficialClaudePlugins(false);
216
+ }
217
+
218
+ export function createInstallAllCommand(): Command {
219
+ // Deprecated: kept temporarily for backward compat; use bare 'xtrm install'
220
+ return new Command('all')
221
+ .description('[deprecated] Use xtrm install')
222
+ .option('--dry-run', 'Preview changes without making any modifications', false)
223
+ .option('-y, --yes', 'Skip confirmation prompts', false)
224
+ .option('--no-mcp', 'Skip MCP server registration', false)
225
+ .option('--force', 'Overwrite locally drifted files', false)
226
+ .action(async (_opts) => {
227
+ console.log('xtrm install all is deprecated — use: xtrm install');
228
+ });
229
+ }
230
+
231
+ export function createInstallBasicCommand(): Command {
232
+ // Deprecated: kept temporarily for backward compat; use bare 'xtrm install'
233
+ return new Command('basic')
234
+ .description('[deprecated] Use xtrm install')
235
+ .option('--dry-run', 'Preview changes without making any modifications', false)
236
+ .option('-y, --yes', 'Skip confirmation prompts', false)
237
+ .option('--no-mcp', 'Skip MCP server registration', false)
238
+ .option('--force', 'Overwrite locally drifted files', false)
239
+ .action(async (_opts) => {
240
+ console.log('xtrm install basic is deprecated — use: xtrm install');
241
+ });
242
+ }
243
+
244
+ export function createInstallCommand(): Command {
245
+ const installCmd = new Command('install')
246
+ .description('Install Claude Code tools (skills, hooks, MCP servers)')
247
+ .argument('[target-selector]', 'Install targets: use "*" or "all" to skip interactive target selection')
248
+ .option('--dry-run', 'Preview changes without making any modifications', false)
249
+ .option('-y, --yes', 'Skip confirmation prompts', false)
250
+ .option('--prune', 'Remove items not in the canonical repository', false)
251
+ .option('--backport', 'Backport drifted local changes back to the repository', false)
252
+ .action(async (targetSelector, opts) => {
253
+ const { dryRun, yes, prune, backport } = opts;
254
+ const effectiveYes = yes || process.argv.includes('--yes') || process.argv.includes('-y');
255
+ const syncType: 'sync' | 'backport' = backport ? 'backport' : 'sync';
256
+ const actionLabel = backport ? 'backport' : 'install';
257
+
258
+ const repoRoot = await findRepoRoot();
259
+ const ctx = await getContext({
260
+ selector: targetSelector,
261
+ createMissingDirs: !dryRun,
262
+ });
263
+ const { targets, syncMode } = ctx;
264
+ const claudeTargets = targets.filter(t => detectAgent(t) === 'claude');
265
+ const otherTargets = targets.filter(t => detectAgent(t) !== 'claude');
266
+
267
+ if (!backport) {
268
+ console.log(t.bold('\n ⚙ beads + dolt (workflow enforcement backend)'));
269
+ console.log(t.muted(' beads is a git-backed issue tracker; dolt is its SQL+git storage backend.'));
270
+ console.log(t.muted(' Without them the gate hooks install but provide no enforcement.\n'));
271
+
272
+ const beadsOk = isBeadsInstalled();
273
+ const doltOk = isDoltInstalled();
274
+
275
+ if (beadsOk && doltOk) {
276
+ console.log(t.success(' ✓ beads + dolt already installed\n'));
277
+ } else {
278
+ const missing = [!beadsOk && 'bd', !doltOk && 'dolt'].filter(Boolean).join(', ');
279
+
280
+ let doInstall = effectiveYes;
281
+ if (!effectiveYes) {
282
+ const { install } = await prompts({
283
+ type: 'confirm',
284
+ name: 'install',
285
+ message: `Install beads + dolt? (${missing} not found) — required for workflow enforcement hooks`,
286
+ initial: true,
287
+ });
288
+ doInstall = install;
289
+ }
290
+
291
+ if (doInstall) {
292
+ if (!beadsOk) {
293
+ console.log(t.muted('\n Installing @beads/bd...'));
294
+ spawnSync('npm', ['install', '-g', '@beads/bd'], { stdio: 'inherit' });
295
+ console.log(t.success(' ✓ bd installed'));
296
+ }
297
+ if (!doltOk) {
298
+ console.log(t.muted('\n Installing dolt...'));
299
+ if (process.platform === 'darwin') {
300
+ spawnSync('brew', ['install', 'dolt'], { stdio: 'inherit' });
301
+ } else {
302
+ spawnSync('sudo', ['bash', '-c',
303
+ 'curl -L https://github.com/dolthub/dolt/releases/latest/download/install.sh | bash',
304
+ ], { stdio: 'inherit' });
305
+ }
306
+ console.log(t.success(' ✓ dolt installed'));
307
+ }
308
+ console.log('');
309
+ } else {
310
+ console.log(t.muted(' ℹ Skipped. Re-run after installing beads+dolt.\n'));
311
+ }
312
+ }
313
+ }
314
+
315
+ // Claude Code: install via plugin (no hook/settings wiring needed)
316
+ if (!backport) {
317
+ for (const _claudeTarget of claudeTargets) {
318
+ await installPlugin(repoRoot, dryRun);
319
+ }
320
+ // Pi: non-interactive extension sync + package install
321
+ await runPiInstall(dryRun);
322
+ }
323
+
324
+ // Phase 1: Diff (concurrent via listr2)
325
+ const diffTasks = new Listr<DiffCtx>(
326
+ otherTargets.map(target => ({
327
+ title: formatTargetLabel(target),
328
+ task: async (listCtx, task) => {
329
+ try {
330
+ const changeSet = await calculateDiff(repoRoot, target, prune);
331
+
332
+ if (syncType === 'sync' && !prune) {
333
+ const hasSettingsDiff =
334
+ changeSet.config.missing.includes('settings.json') ||
335
+ changeSet.config.outdated.includes('settings.json') ||
336
+ changeSet.config.drifted.includes('settings.json');
337
+
338
+ if (!hasSettingsDiff && await needsSettingsSync(repoRoot, target)) {
339
+ changeSet.config.outdated.push('settings.json');
340
+ }
341
+ }
342
+
343
+ const totalChanges = Object.values(changeSet).reduce(
344
+ (sum, c: any) => sum + c.missing.length + c.outdated.length + c.drifted.length, 0,
345
+ );
346
+ task.title = `${formatTargetLabel(target)}${t.muted(` — ${totalChanges} change${totalChanges !== 1 ? 's' : ''}`)}`;
347
+ if (totalChanges > 0) {
348
+ listCtx.allChanges.push({ target, changeSet, totalChanges, skippedDrifted: [] });
349
+ }
350
+ } catch (err) {
351
+ if (err instanceof PruneModeReadError) {
352
+ task.title = `${formatTargetLabel(target)} ${kleur.red('(skipped — cannot read in prune mode)')}`;
353
+ } else {
354
+ throw err;
355
+ }
356
+ }
357
+ },
358
+ })),
359
+ { concurrent: true, exitOnError: false },
360
+ );
361
+
362
+ const diffCtx = await diffTasks.run({ allChanges: [] });
363
+ const allChanges = diffCtx.allChanges;
364
+
365
+ // MCP sync always runs regardless of file changes
366
+ if (!backport) {
367
+ await syncMcpForTargets(repoRoot, otherTargets, dryRun);
368
+ }
369
+
370
+ if (allChanges.length === 0) {
371
+ console.log('\n' + t.boldGreen('✓ Files are up-to-date') + '\n');
372
+ return;
373
+ }
374
+
375
+ // Phase 2: Plan table
376
+ renderPlanTable(allChanges);
377
+
378
+ if (dryRun) {
379
+ console.log(t.accent('💡 Dry run — no changes written\n'));
380
+ return;
381
+ }
382
+
383
+ // Phase 3: Confirmation
384
+ if (!effectiveYes) {
385
+ const totalChangesCount = allChanges.reduce((s, c) => s + c.totalChanges, 0);
386
+ const { confirm } = await prompts({
387
+ type: 'confirm',
388
+ name: 'confirm',
389
+ message: `Proceed with ${actionLabel} (${totalChangesCount} total changes)?`,
390
+ initial: true,
391
+ });
392
+ if (!confirm) {
393
+ console.log(t.muted(' Install cancelled.\n'));
394
+ return;
395
+ }
396
+ }
397
+
398
+ // Phase 4: Execute
399
+ let totalCount = 0;
400
+
401
+ for (const { target, changeSet, skippedDrifted } of allChanges) {
402
+ console.log(t.bold(`\n ${sym.arrow} ${formatTargetLabel(target)}`));
403
+
404
+ const count = await executeSync(repoRoot, target, changeSet, syncMode, syncType, dryRun);
405
+ totalCount += count;
406
+
407
+ for (const [category, cat] of Object.entries(changeSet)) {
408
+ const c = cat as any;
409
+ if (c.drifted.length > 0 && syncType === 'sync') {
410
+ skippedDrifted.push(...c.drifted.map((item: string) => `${category}/${item}`));
411
+ }
412
+ }
413
+
414
+ console.log(t.success(` ${sym.ok} ${count} item${count !== 1 ? 's' : ''} installed`));
415
+ }
416
+
417
+ // Phase 5: Summary card
418
+ const allSkipped = allChanges.flatMap(c => c.skippedDrifted);
419
+ await renderSummaryCard(allChanges, totalCount, allSkipped, dryRun);
420
+ });
421
+
422
+ // Add subcommands
423
+ installCmd.addCommand(createInstallAllCommand());
424
+ installCmd.addCommand(createInstallBasicCommand());
425
+
426
+ return installCmd;
427
+ }
@@ -0,0 +1,119 @@
1
+ import kleur from 'kleur';
2
+ import fs from 'fs-extra';
3
+ import path from 'path';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import { findRepoRoot } from '../utils/repo-root.js';
7
+ import { t, sym } from '../utils/theme.js';
8
+
9
+ const PI_AGENT_DIR = process.env.PI_AGENT_DIR || path.join(homedir(), '.pi', 'agent');
10
+
11
+ interface InstallSchema {
12
+ fields: { key: string; label: string; hint: string; secret: boolean; required: boolean }[];
13
+ oauth_providers: { key: string; instruction: string }[];
14
+ packages: string[];
15
+ }
16
+
17
+ function isPiInstalled(): boolean {
18
+ const r = spawnSync('pi', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
19
+ return r.status === 0;
20
+ }
21
+
22
+ /**
23
+ * List extension directories (contain package.json) in a base directory.
24
+ */
25
+ async function listExtensionDirs(baseDir: string): Promise<string[]> {
26
+ if (!await fs.pathExists(baseDir)) return [];
27
+ const entries = await fs.readdir(baseDir, { withFileTypes: true });
28
+ const extDirs: string[] = [];
29
+ for (const entry of entries) {
30
+ if (!entry.isDirectory()) continue;
31
+ const extPath = path.join(baseDir, entry.name);
32
+ const pkgPath = path.join(extPath, 'package.json');
33
+ if (await fs.pathExists(pkgPath)) {
34
+ extDirs.push(extPath);
35
+ }
36
+ }
37
+ return extDirs;
38
+ }
39
+
40
+ /**
41
+ * Non-interactive Pi install: copies extensions + installs npm packages.
42
+ * Called automatically as part of `xtrm install`.
43
+ */
44
+ export async function runPiInstall(dryRun: boolean = false): Promise<void> {
45
+ const repoRoot = await findRepoRoot();
46
+ const piConfigDir = path.join(repoRoot, 'config', 'pi');
47
+ const schemaPath = path.join(piConfigDir, 'install-schema.json');
48
+
49
+ console.log(t.bold('\n ⚙ Pi extensions + packages'));
50
+
51
+ if (!isPiInstalled()) {
52
+ console.log(kleur.yellow(' pi not found — installing oh-pi globally...'));
53
+ if (!dryRun) {
54
+ const r = spawnSync('npm', ['install', '-g', 'oh-pi'], { stdio: 'inherit' });
55
+ if (r.status !== 0) {
56
+ console.error(kleur.red(' ✗ Failed to install oh-pi. Run: npm install -g oh-pi\n'));
57
+ return;
58
+ }
59
+ } else {
60
+ console.log(kleur.cyan(' [DRY RUN] npm install -g oh-pi'));
61
+ }
62
+ console.log(t.success(' ✓ pi installed'));
63
+ } else {
64
+ const v = spawnSync('pi', ['--version'], { encoding: 'utf8' });
65
+ console.log(t.success(` ✓ pi ${v.stdout.trim()} already installed`));
66
+ }
67
+
68
+ // Copy extensions
69
+ const extensionsSrc = path.join(piConfigDir, 'extensions');
70
+ const extensionsDst = path.join(PI_AGENT_DIR, 'extensions');
71
+ if (await fs.pathExists(extensionsSrc)) {
72
+ if (!dryRun) {
73
+ await fs.ensureDir(PI_AGENT_DIR);
74
+ await fs.copy(extensionsSrc, extensionsDst, { overwrite: true });
75
+ }
76
+ console.log(t.success(` ${sym.ok} extensions synced`));
77
+
78
+ // Register each extension with pi install -l
79
+ const extDirs = await listExtensionDirs(extensionsDst);
80
+ if (extDirs.length > 0) {
81
+ console.log(kleur.dim(` Registering ${extDirs.length} extensions...`));
82
+ for (const extPath of extDirs) {
83
+ const extName = path.basename(extPath);
84
+ if (dryRun) {
85
+ console.log(kleur.cyan(` [DRY RUN] pi install -l ~/.pi/agent/extensions/${extName}`));
86
+ continue;
87
+ }
88
+ const r = spawnSync('pi', ['install', '-l', extPath], { stdio: 'pipe', encoding: 'utf8' });
89
+ if (r.status === 0) {
90
+ console.log(t.success(` ${sym.ok} ${extName} registered`));
91
+ } else {
92
+ console.log(kleur.yellow(` ⚠ ${extName} — registration failed`));
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ // Install npm packages from schema
99
+ if (!(await fs.pathExists(schemaPath))) {
100
+ console.log(kleur.dim(' No install-schema.json found, skipping packages'));
101
+ return;
102
+ }
103
+
104
+ const schema: InstallSchema = await fs.readJson(schemaPath);
105
+ for (const pkg of schema.packages) {
106
+ if (dryRun) {
107
+ console.log(kleur.cyan(` [DRY RUN] pi install ${pkg}`));
108
+ continue;
109
+ }
110
+ const r = spawnSync('pi', ['install', pkg], { stdio: 'pipe', encoding: 'utf8' });
111
+ if (r.status === 0) {
112
+ console.log(t.success(` ${sym.ok} ${pkg}`));
113
+ } else {
114
+ console.log(kleur.yellow(` ⚠ ${pkg} — install failed (run manually: pi install ${pkg})`));
115
+ }
116
+ }
117
+
118
+ console.log('');
119
+ }
@@ -0,0 +1,128 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import path from 'path';
4
+ import { execSync, spawnSync } from 'node:child_process';
5
+ import { homedir } from 'node:os';
6
+ import fs from 'fs-extra';
7
+ import { findRepoRoot } from '../utils/repo-root.js';
8
+ import { t } from '../utils/theme.js';
9
+ import { runPiInstall } from './pi-install.js';
10
+ import { diffPiExtensions, createInstallPiCommand } from './install-pi.js';
11
+ import { launchWorktreeSession } from '../utils/worktree-session.js';
12
+
13
+ const PI_AGENT_DIR = process.env.PI_AGENT_DIR || path.join(homedir(), '.pi', 'agent');
14
+
15
+ export function createPiCommand(): Command {
16
+ const cmd = new Command('pi')
17
+ .description('Launch a Pi session in a sandboxed worktree, or manage the Pi runtime')
18
+ .argument('[name]', 'Optional session name — used as xt/<name> branch (random if omitted)')
19
+ .action(async (name: string | undefined) => {
20
+ await launchWorktreeSession({ runtime: 'pi', name });
21
+ });
22
+
23
+ cmd.command('install')
24
+ .description('Non-interactive: sync extensions + install npm packages')
25
+ .option('--dry-run', 'Preview without making changes', false)
26
+ .action(async (opts) => {
27
+ await runPiInstall(opts.dryRun);
28
+ });
29
+
30
+ // 'setup' = interactive first-time API key + OAuth config
31
+ const piSetup = createInstallPiCommand();
32
+ piSetup.name('setup');
33
+ piSetup.description('Interactive first-time setup: API keys, config files, OAuth instructions');
34
+ cmd.addCommand(piSetup);
35
+
36
+ cmd.command('status')
37
+ .description('Check Pi version and extension deployment drift')
38
+ .action(async () => {
39
+ console.log(t.bold('\n Pi Runtime Status\n'));
40
+
41
+ const piResult = spawnSync('pi', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
42
+ if (piResult.status === 0) {
43
+ console.log(t.success(` ✓ pi ${piResult.stdout.trim()} installed`));
44
+ } else {
45
+ console.log(kleur.red(' ✗ pi not found — run: xt pi install'));
46
+ console.log('');
47
+ return;
48
+ }
49
+
50
+ const repoRoot = await findRepoRoot();
51
+ const sourceDir = path.join(repoRoot, 'config', 'pi', 'extensions');
52
+ const targetDir = path.join(PI_AGENT_DIR, 'extensions');
53
+ const diff = await diffPiExtensions(sourceDir, targetDir);
54
+
55
+ if (diff.missing.length === 0 && diff.stale.length === 0) {
56
+ console.log(t.success(` ✓ extensions up-to-date (${diff.upToDate.length} deployed)`));
57
+ } else {
58
+ if (diff.missing.length > 0) console.log(kleur.yellow(` ⚠ missing: ${diff.missing.join(', ')}`));
59
+ if (diff.stale.length > 0) console.log(kleur.yellow(` ⚠ stale: ${diff.stale.join(', ')}`));
60
+ console.log(kleur.dim(' → run: xt pi install'));
61
+ }
62
+
63
+ console.log('');
64
+ });
65
+
66
+ cmd.command('doctor')
67
+ .description('Diagnostic checks: pi installed, extensions deployed, packages present')
68
+ .action(async () => {
69
+ console.log(t.bold('\n Pi Doctor\n'));
70
+
71
+ let allOk = true;
72
+
73
+ const piResult = spawnSync('pi', ['--version'], { encoding: 'utf8', stdio: 'pipe' });
74
+ if (piResult.status === 0) {
75
+ console.log(t.success(` ✓ pi ${piResult.stdout.trim()} installed`));
76
+ } else {
77
+ console.log(kleur.red(' ✗ pi not found — run: xt pi install'));
78
+ allOk = false;
79
+ }
80
+
81
+ const repoRoot = await findRepoRoot();
82
+ const piConfigDir = path.join(repoRoot, 'config', 'pi');
83
+ const sourceDir = path.join(piConfigDir, 'extensions');
84
+ const targetDir = path.join(PI_AGENT_DIR, 'extensions');
85
+ const diff = await diffPiExtensions(sourceDir, targetDir);
86
+
87
+ if (diff.missing.length === 0 && diff.stale.length === 0) {
88
+ console.log(t.success(` ✓ extensions deployed (${diff.upToDate.length})`));
89
+ } else {
90
+ console.log(kleur.yellow(` ⚠ extension drift (${diff.missing.length} missing, ${diff.stale.length} stale)`));
91
+ allOk = false;
92
+ }
93
+
94
+ const schemaPath = path.join(piConfigDir, 'install-schema.json');
95
+ if (await fs.pathExists(schemaPath)) {
96
+ try {
97
+ execSync('pi --version', { stdio: 'ignore' });
98
+ const schema = await fs.readJson(schemaPath);
99
+ const listResult = spawnSync('pi', ['list'], { encoding: 'utf8', stdio: 'pipe' });
100
+ const installed = listResult.stdout ?? '';
101
+ const missing = schema.packages.filter((p: string) => !installed.includes(p.replace('npm:', '')));
102
+ if (missing.length === 0) {
103
+ console.log(t.success(` ✓ all ${schema.packages.length} packages installed`));
104
+ } else {
105
+ console.log(kleur.yellow(` ⚠ ${missing.length} package(s) missing: ${missing.join(', ')}`));
106
+ allOk = false;
107
+ }
108
+ } catch {
109
+ console.log(kleur.dim(' ○ could not check packages (pi not available)'));
110
+ }
111
+ }
112
+
113
+ console.log('');
114
+ if (allOk) {
115
+ console.log(t.boldGreen(' ✓ All checks passed\n'));
116
+ } else {
117
+ console.log(kleur.yellow(' ⚠ Some checks failed — run: xt pi install\n'));
118
+ }
119
+ });
120
+
121
+ cmd.command('reload')
122
+ .description('Re-sync extensions and reinstall packages from repo')
123
+ .action(async () => {
124
+ await runPiInstall(false);
125
+ });
126
+
127
+ return cmd;
128
+ }
@@ -0,0 +1,12 @@
1
+ import { Command } from 'commander';
2
+ import kleur from 'kleur';
3
+ import { resetContext } from '../core/context.js';
4
+
5
+ export function createResetCommand(): Command {
6
+ return new Command('reset')
7
+ .description('Reset CLI configuration (clears saved sync mode and preferences)')
8
+ .action(() => {
9
+ resetContext();
10
+ console.log(kleur.green('✓ Configuration reset. Run sync again to reconfigure.'));
11
+ });
12
+ }