xtrm-cli 0.5.0 → 0.5.27
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.
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.combined.log +17 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stderr.log +0 -0
- package/.pi/structured-returns/0e4a7405-1ac3-4ae1-8dbc-d31507b2e2e4.stdout.log +17 -0
- package/dist/index.cjs +969 -1059
- package/dist/index.cjs.map +1 -1
- package/package.json +1 -1
- package/src/commands/clean.ts +7 -6
- package/src/commands/debug.ts +255 -0
- package/src/commands/docs.ts +180 -0
- package/src/commands/help.ts +92 -171
- package/src/commands/init.ts +9 -32
- package/src/commands/install-pi.ts +9 -16
- package/src/commands/install.ts +150 -2
- package/src/commands/pi-install.ts +10 -44
- package/src/core/context.ts +4 -52
- package/src/core/diff.ts +3 -16
- package/src/core/preflight.ts +0 -1
- package/src/index.ts +7 -4
- package/src/types/config.ts +0 -2
- package/src/utils/config-injector.ts +3 -3
- package/src/utils/pi-extensions.ts +41 -0
- package/src/utils/worktree-session.ts +86 -50
- package/test/extensions/beads-claim-lifecycle.test.ts +93 -0
- package/test/extensions/beads-parity.test.ts +94 -0
- package/test/extensions/extension-harness.ts +5 -5
- package/test/extensions/quality-gates-parity.test.ts +89 -0
- package/test/extensions/session-flow.test.ts +91 -0
- package/test/extensions/xtrm-loader.test.ts +38 -20
- package/test/install-pi.test.ts +22 -11
- package/test/pi-extensions.test.ts +50 -0
- package/test/session-launcher.test.ts +28 -38
- package/extensions/beads.ts +0 -109
- package/extensions/core/adapter.ts +0 -45
- package/extensions/core/lib.ts +0 -3
- package/extensions/core/logger.ts +0 -45
- package/extensions/core/runner.ts +0 -71
- package/extensions/custom-footer.ts +0 -160
- package/extensions/main-guard-post-push.ts +0 -44
- package/extensions/main-guard.ts +0 -126
- package/extensions/minimal-mode.ts +0 -201
- package/extensions/quality-gates.ts +0 -67
- package/extensions/service-skills.ts +0 -150
- package/extensions/xtrm-loader.ts +0 -89
- package/hooks/gitnexus-impact-reminder.py +0 -13
- package/src/commands/finish.ts +0 -25
- package/src/core/session-state.ts +0 -139
- package/src/core/xtrm-finish.ts +0 -267
- package/src/tests/session-flow-parity.test.ts +0 -118
- package/src/tests/session-state.test.ts +0 -124
- package/src/tests/xtrm-finish.test.ts +0 -148
|
@@ -5,6 +5,7 @@ import { spawnSync } from 'node:child_process';
|
|
|
5
5
|
import { homedir } from 'node:os';
|
|
6
6
|
import { findRepoRoot } from '../utils/repo-root.js';
|
|
7
7
|
import { t, sym } from '../utils/theme.js';
|
|
8
|
+
import { syncManagedPiExtensions } from '../utils/pi-extensions.js';
|
|
8
9
|
|
|
9
10
|
const PI_AGENT_DIR = process.env.PI_AGENT_DIR || path.join(homedir(), '.pi', 'agent');
|
|
10
11
|
|
|
@@ -19,24 +20,6 @@ function isPiInstalled(): boolean {
|
|
|
19
20
|
return r.status === 0;
|
|
20
21
|
}
|
|
21
22
|
|
|
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
23
|
/**
|
|
41
24
|
* Non-interactive Pi install: copies extensions + installs npm packages.
|
|
42
25
|
* Called automatically as part of `xtrm install`.
|
|
@@ -65,34 +48,17 @@ export async function runPiInstall(dryRun: boolean = false): Promise<void> {
|
|
|
65
48
|
console.log(t.success(` ✓ pi ${v.stdout.trim()} already installed`));
|
|
66
49
|
}
|
|
67
50
|
|
|
68
|
-
//
|
|
51
|
+
// Sync managed extensions (Pi auto-discovers from ~/.pi/agent/extensions)
|
|
69
52
|
const extensionsSrc = path.join(piConfigDir, 'extensions');
|
|
70
53
|
const extensionsDst = path.join(PI_AGENT_DIR, 'extensions');
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
}
|
|
54
|
+
const managedPackages = await syncManagedPiExtensions({
|
|
55
|
+
sourceDir: extensionsSrc,
|
|
56
|
+
targetDir: extensionsDst,
|
|
57
|
+
dryRun,
|
|
58
|
+
log: (message) => console.log(kleur.dim(message)),
|
|
59
|
+
});
|
|
60
|
+
if (managedPackages > 0) {
|
|
61
|
+
console.log(t.success(` ${sym.ok} extensions synced (${managedPackages} packages)`));
|
|
96
62
|
}
|
|
97
63
|
|
|
98
64
|
// Install npm packages from schema
|
package/src/core/context.ts
CHANGED
|
@@ -3,8 +3,6 @@ import path from 'path';
|
|
|
3
3
|
import fs from 'fs-extra';
|
|
4
4
|
// @ts-ignore
|
|
5
5
|
import Conf from 'conf';
|
|
6
|
-
// @ts-ignore
|
|
7
|
-
import prompts from 'prompts';
|
|
8
6
|
import kleur from 'kleur';
|
|
9
7
|
import type { SyncMode } from '../types/config.js';
|
|
10
8
|
|
|
@@ -68,73 +66,27 @@ export function resolveTargets(
|
|
|
68
66
|
|
|
69
67
|
export async function getContext(options: GetContextOptions = {}): Promise<Context> {
|
|
70
68
|
const { selector, createMissingDirs = true } = options;
|
|
71
|
-
const choices = [];
|
|
72
69
|
const candidates = getCandidatePaths();
|
|
73
70
|
const directTargets = resolveTargets(selector, candidates);
|
|
74
71
|
|
|
75
|
-
if (directTargets) {
|
|
76
|
-
const activeConfig = getConfig();
|
|
77
|
-
if (createMissingDirs) {
|
|
78
|
-
for (const target of directTargets) {
|
|
79
|
-
await fs.ensureDir(target);
|
|
80
|
-
}
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
return {
|
|
84
|
-
targets: directTargets,
|
|
85
|
-
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
86
|
-
config: activeConfig,
|
|
87
|
-
};
|
|
88
|
-
}
|
|
89
|
-
|
|
90
72
|
const activeConfig = getConfig();
|
|
91
73
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const icon = exists ? kleur.green('●') : kleur.gray('○');
|
|
95
|
-
const desc = exists ? 'Found' : 'Not found (will create)';
|
|
96
|
-
|
|
97
|
-
choices.push({
|
|
98
|
-
title: `${icon} ${c.label} (${c.path})`,
|
|
99
|
-
description: desc,
|
|
100
|
-
value: c.path,
|
|
101
|
-
selected: exists, // Pre-select existing environments
|
|
102
|
-
});
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const response = await prompts({
|
|
106
|
-
type: 'multiselect',
|
|
107
|
-
name: 'targets',
|
|
108
|
-
message: 'Select target environment(s):',
|
|
109
|
-
choices: choices,
|
|
110
|
-
hint: '- Space to select. Return to submit',
|
|
111
|
-
instructions: false,
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
if (response.targets === undefined) {
|
|
115
|
-
console.log(kleur.gray('\nCancelled.'));
|
|
116
|
-
process.exit(130);
|
|
117
|
-
}
|
|
118
|
-
if (response.targets.length === 0) {
|
|
119
|
-
console.log(kleur.gray('No targets selected.'));
|
|
120
|
-
process.exit(0);
|
|
121
|
-
}
|
|
74
|
+
// Use explicitly specified targets, or default to all candidates
|
|
75
|
+
const selectedPaths = directTargets ?? candidates.map(c => c.path);
|
|
122
76
|
|
|
123
|
-
// Ensure directories exist for selected targets
|
|
124
77
|
if (createMissingDirs) {
|
|
125
|
-
for (const target of
|
|
78
|
+
for (const target of selectedPaths) {
|
|
126
79
|
await fs.ensureDir(target);
|
|
127
80
|
}
|
|
128
81
|
}
|
|
129
82
|
|
|
130
83
|
return {
|
|
131
|
-
targets:
|
|
84
|
+
targets: selectedPaths,
|
|
132
85
|
syncMode: activeConfig.get('syncMode') as SyncMode,
|
|
133
86
|
config: activeConfig,
|
|
134
87
|
};
|
|
135
88
|
|
|
136
89
|
}
|
|
137
|
-
|
|
138
90
|
export function resetContext(): void {
|
|
139
91
|
getConfig().clear();
|
|
140
92
|
console.log(kleur.yellow('Configuration cleared.'));
|
package/src/core/diff.ts
CHANGED
|
@@ -2,7 +2,6 @@ import { join, normalize } from 'path';
|
|
|
2
2
|
import fs from 'fs-extra';
|
|
3
3
|
import { hashDirectory, getNewestMtime } from '../utils/hash.js';
|
|
4
4
|
import type { ChangeSet } from '../types/config.js';
|
|
5
|
-
import { getAdapter } from '../adapters/registry.js';
|
|
6
5
|
import { detectAdapter } from '../adapters/registry.js';
|
|
7
6
|
|
|
8
7
|
// Items to ignore from diff scanning (similar to .gitignore)
|
|
@@ -18,7 +17,6 @@ export class PruneModeReadError extends Error {
|
|
|
18
17
|
export async function calculateDiff(repoRoot: string, systemRoot: string, pruneMode: boolean = false): Promise<ChangeSet> {
|
|
19
18
|
const adapter = detectAdapter(systemRoot);
|
|
20
19
|
const isClaude = adapter?.toolName === 'claude-code';
|
|
21
|
-
const isQwen = adapter?.toolName === 'qwen';
|
|
22
20
|
const normalizedRoot = normalize(systemRoot).replace(/\\/g, '/');
|
|
23
21
|
const isAgentsSkills = normalizedRoot.includes('.agents/skills');
|
|
24
22
|
|
|
@@ -27,8 +25,6 @@ export async function calculateDiff(repoRoot: string, systemRoot: string, pruneM
|
|
|
27
25
|
hooks: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
28
26
|
config: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
29
27
|
commands: { missing: [], outdated: [], drifted: [], total: 0 },
|
|
30
|
-
'qwen-commands': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
31
|
-
'antigravity-workflows': { missing: [], outdated: [], drifted: [], total: 0 },
|
|
32
28
|
};
|
|
33
29
|
|
|
34
30
|
// Load installed file hashes from manifest for precise drift classification
|
|
@@ -61,20 +57,11 @@ export async function calculateDiff(repoRoot: string, systemRoot: string, pruneM
|
|
|
61
57
|
|
|
62
58
|
// 1. Folders: Skills & Hooks & Commands
|
|
63
59
|
const folders = ['skills', 'hooks'];
|
|
64
|
-
if (
|
|
65
|
-
else if (!isClaude) folders.push('commands');
|
|
60
|
+
if (!isClaude) folders.push('commands');
|
|
66
61
|
|
|
67
62
|
for (const category of folders) {
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (category === 'qwen-commands') {
|
|
72
|
-
repoPath = join(repoRoot, '.qwen', 'commands');
|
|
73
|
-
systemPath = join(systemRoot, 'commands');
|
|
74
|
-
} else {
|
|
75
|
-
repoPath = join(repoRoot, category);
|
|
76
|
-
systemPath = join(systemRoot, category);
|
|
77
|
-
}
|
|
63
|
+
const repoPath = join(repoRoot, category);
|
|
64
|
+
const systemPath = join(systemRoot, category);
|
|
78
65
|
|
|
79
66
|
if (!(await fs.pathExists(repoPath))) continue;
|
|
80
67
|
|
package/src/core/preflight.ts
CHANGED
|
@@ -54,7 +54,6 @@ function getCandidatePaths(): Array<{ label: string; path: string }> {
|
|
|
54
54
|
const home = os.homedir();
|
|
55
55
|
return [
|
|
56
56
|
{ label: '~/.claude (hooks + skills)', path: path.join(home, '.claude') },
|
|
57
|
-
{ label: '.qwen', path: path.join(home, '.qwen') },
|
|
58
57
|
{ label: '~/.agents/skills', path: path.join(home, '.agents', 'skills') },
|
|
59
58
|
];
|
|
60
59
|
}
|
package/src/index.ts
CHANGED
|
@@ -16,9 +16,10 @@ import { createStatusCommand } from './commands/status.js';
|
|
|
16
16
|
import { createResetCommand } from './commands/reset.js';
|
|
17
17
|
import { createHelpCommand } from './commands/help.js';
|
|
18
18
|
import { createCleanCommand } from './commands/clean.js';
|
|
19
|
-
import { createFinishCommand } from './commands/finish.js';
|
|
20
19
|
import { createEndCommand } from './commands/end.js';
|
|
21
20
|
import { createWorktreeCommand } from './commands/worktree.js';
|
|
21
|
+
import { createDocsCommand } from './commands/docs.js';
|
|
22
|
+
import { createDebugCommand } from './commands/debug.js';
|
|
22
23
|
import { printBanner } from './utils/banner.js';
|
|
23
24
|
|
|
24
25
|
const program = new Command();
|
|
@@ -51,9 +52,10 @@ program
|
|
|
51
52
|
program.addCommand(createStatusCommand());
|
|
52
53
|
program.addCommand(createResetCommand());
|
|
53
54
|
program.addCommand(createCleanCommand());
|
|
54
|
-
program.addCommand(createFinishCommand());
|
|
55
55
|
program.addCommand(createEndCommand());
|
|
56
56
|
program.addCommand(createWorktreeCommand());
|
|
57
|
+
program.addCommand(createDocsCommand());
|
|
58
|
+
program.addCommand(createDebugCommand());
|
|
57
59
|
program.addCommand(createHelpCommand());
|
|
58
60
|
|
|
59
61
|
// Default action: show help
|
|
@@ -76,11 +78,12 @@ process.on('unhandledRejection', (reason) => {
|
|
|
76
78
|
process.exit(1);
|
|
77
79
|
});
|
|
78
80
|
|
|
79
|
-
// Show
|
|
81
|
+
// Show banner only for the install command (never for help/version output)
|
|
80
82
|
const isHelpOrVersion = process.argv.some(a => a === '--help' || a === '-h' || a === '--version' || a === '-V');
|
|
83
|
+
const isInstallCommand = (process.argv[2] ?? '') === 'install';
|
|
81
84
|
|
|
82
85
|
(async () => {
|
|
83
|
-
if (!isHelpOrVersion) {
|
|
86
|
+
if (!isHelpOrVersion && isInstallCommand) {
|
|
84
87
|
await printBanner(version);
|
|
85
88
|
}
|
|
86
89
|
program.parseAsync(process.argv);
|
package/src/types/config.ts
CHANGED
|
@@ -23,8 +23,6 @@ export const ChangeSetSchema = z.object({
|
|
|
23
23
|
hooks: ChangeSetCategorySchema,
|
|
24
24
|
config: ChangeSetCategorySchema,
|
|
25
25
|
commands: ChangeSetCategorySchema,
|
|
26
|
-
'qwen-commands': ChangeSetCategorySchema,
|
|
27
|
-
'antigravity-workflows': ChangeSetCategorySchema,
|
|
28
26
|
});
|
|
29
27
|
export type ChangeSet = z.infer<typeof ChangeSetSchema>;
|
|
30
28
|
|
|
@@ -32,9 +32,9 @@ export async function injectHookConfig(targetDir: string, repoRoot: string): Pro
|
|
|
32
32
|
events: ['userPromptSubmit'],
|
|
33
33
|
},
|
|
34
34
|
{
|
|
35
|
-
name: '
|
|
36
|
-
path: path.join(targetDir, 'hooks', '
|
|
37
|
-
events: ['
|
|
35
|
+
name: 'using-xtrm-reminder',
|
|
36
|
+
path: path.join(targetDir, 'hooks', 'using-xtrm-reminder.mjs'),
|
|
37
|
+
events: ['sessionStart'],
|
|
38
38
|
},
|
|
39
39
|
];
|
|
40
40
|
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import fs from 'fs-extra';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
|
|
4
|
+
export interface SyncPiExtensionsOptions {
|
|
5
|
+
sourceDir: string;
|
|
6
|
+
targetDir: string;
|
|
7
|
+
dryRun?: boolean;
|
|
8
|
+
log?: (message: string) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sync managed extension packages into ~/.pi/agent/extensions.
|
|
13
|
+
*
|
|
14
|
+
* Pi auto-discovers extensions from this directory, so we intentionally do not
|
|
15
|
+
* run `pi install -l` for these managed packages (prevents double registration).
|
|
16
|
+
*/
|
|
17
|
+
export async function syncManagedPiExtensions({
|
|
18
|
+
sourceDir,
|
|
19
|
+
targetDir,
|
|
20
|
+
dryRun = false,
|
|
21
|
+
log,
|
|
22
|
+
}: SyncPiExtensionsOptions): Promise<number> {
|
|
23
|
+
if (!await fs.pathExists(sourceDir)) return 0;
|
|
24
|
+
|
|
25
|
+
if (!dryRun) {
|
|
26
|
+
await fs.ensureDir(path.dirname(targetDir));
|
|
27
|
+
await fs.copy(sourceDir, targetDir, { overwrite: true });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const entries = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
31
|
+
const managedPackages = entries.filter((entry) => entry.isDirectory()).length;
|
|
32
|
+
|
|
33
|
+
if (log) {
|
|
34
|
+
if (dryRun) {
|
|
35
|
+
log(` [DRY RUN] sync extensions ${sourceDir} -> ${targetDir}`);
|
|
36
|
+
}
|
|
37
|
+
log(` Pi will auto-discover ${managedPackages} extension package(s) from ${targetDir}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return managedPackages;
|
|
41
|
+
}
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import kleur from 'kleur';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
5
|
+
import { homedir } from 'node:os';
|
|
6
6
|
|
|
7
7
|
export interface WorktreeSessionOptions {
|
|
8
8
|
runtime: 'claude' | 'pi';
|
|
@@ -13,78 +13,114 @@ function randomSlug(len: number = 4): string {
|
|
|
13
13
|
return Math.random().toString(36).slice(2, 2 + len);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
-
function
|
|
17
|
-
const
|
|
18
|
-
|
|
16
|
+
function gitRepoRoot(cwd: string): string | null {
|
|
17
|
+
const r = spawnSync('git', ['rev-parse', '--show-toplevel'], {
|
|
18
|
+
cwd, stdio: 'pipe', encoding: 'utf8',
|
|
19
|
+
});
|
|
20
|
+
return r.status === 0 ? (r.stdout ?? '').trim() : null;
|
|
19
21
|
}
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* Launch a Claude or Pi session in a sandboxed git worktree.
|
|
23
25
|
*
|
|
24
|
-
* Worktree path:
|
|
26
|
+
* Worktree path: inside repo under .xtrm/worktrees/, named <cwd-basename>-xt-<runtime>-<slug>
|
|
25
27
|
* Branch: xt/<name> if name provided, xt/<4-char-random> otherwise
|
|
26
|
-
*
|
|
28
|
+
* Beads: bd worktree create sets up canonical .beads/redirect to share the main db
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve the statusline.mjs path: prefer the plugin cache (stays in sync with
|
|
33
|
+
* plugin version), fall back to ~/.claude/hooks/statusline.mjs.
|
|
27
34
|
*/
|
|
35
|
+
function resolveStatuslineScript(): string | null {
|
|
36
|
+
const pluginsFile = path.join(homedir(), '.claude', 'plugins', 'installed_plugins.json');
|
|
37
|
+
try {
|
|
38
|
+
const plugins = JSON.parse(readFileSync(pluginsFile, 'utf8'));
|
|
39
|
+
for (const [key, entries] of Object.entries(plugins?.plugins ?? {}) as [string, any[]][]) {
|
|
40
|
+
if (!key.startsWith('xtrm-tools@') || !entries?.length) continue;
|
|
41
|
+
const p = path.join(entries[0].installPath, 'hooks', 'statusline.mjs');
|
|
42
|
+
if (existsSync(p)) return p;
|
|
43
|
+
}
|
|
44
|
+
} catch { /* fall through */ }
|
|
45
|
+
// Fallback: ~/.claude/hooks/statusline.mjs
|
|
46
|
+
const fallback = path.join(homedir(), '.claude', 'hooks', 'statusline.mjs');
|
|
47
|
+
return existsSync(fallback) ? fallback : null;
|
|
48
|
+
}
|
|
49
|
+
|
|
28
50
|
export async function launchWorktreeSession(opts: WorktreeSessionOptions): Promise<void> {
|
|
29
51
|
const { runtime, name } = opts;
|
|
30
52
|
const cwd = process.cwd();
|
|
31
|
-
const repoRoot = await findRepoRoot();
|
|
32
|
-
const cwdBasename = path.basename(cwd);
|
|
33
53
|
|
|
34
|
-
//
|
|
35
|
-
const
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
// Use git to find the user's actual repo root — not xtrm-tools' package root
|
|
55
|
+
const repoRoot = gitRepoRoot(cwd);
|
|
56
|
+
if (!repoRoot) {
|
|
57
|
+
console.error(kleur.red('\n ✗ Not inside a git repository\n'));
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const cwdBasename = path.basename(repoRoot);
|
|
62
|
+
|
|
63
|
+
// Resolve slug — shared by both branch and worktree path so they're linked
|
|
64
|
+
const slug = name ?? randomSlug(4);
|
|
38
65
|
|
|
39
|
-
//
|
|
40
|
-
const
|
|
66
|
+
// Worktree path: inside repo under .xtrm/worktrees/
|
|
67
|
+
const worktreeName = `${cwdBasename}-xt-${runtime}-${slug}`;
|
|
68
|
+
const worktreePath = path.join(repoRoot, '.xtrm', 'worktrees', worktreeName);
|
|
69
|
+
|
|
70
|
+
// Branch name
|
|
71
|
+
const branchName = `xt/${slug}`;
|
|
41
72
|
|
|
42
73
|
console.log(kleur.bold(`\n Launching ${runtime} session`));
|
|
43
74
|
console.log(kleur.dim(` worktree: ${worktreePath}`));
|
|
44
75
|
console.log(kleur.dim(` branch: ${branchName}\n`));
|
|
45
76
|
|
|
46
|
-
//
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
const worktreeArgs = branchExists
|
|
52
|
-
? ['worktree', 'add', worktreePath, branchName]
|
|
53
|
-
: ['worktree', 'add', '-b', branchName, worktreePath];
|
|
54
|
-
|
|
55
|
-
const worktreeResult = spawnSync('git', worktreeArgs, { cwd: repoRoot, stdio: 'inherit' });
|
|
56
|
-
if (worktreeResult.status !== 0) {
|
|
57
|
-
console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
|
|
58
|
-
process.exit(1);
|
|
59
|
-
}
|
|
77
|
+
// Use bd worktree create — sets up git worktree + canonical .beads/redirect in one step.
|
|
78
|
+
// Falls back to plain git worktree add if bd is unavailable or the project has no .beads/.
|
|
79
|
+
const bdResult = spawnSync('bd', ['worktree', 'create', worktreePath, '--branch', branchName], {
|
|
80
|
+
cwd: repoRoot, stdio: 'inherit',
|
|
81
|
+
});
|
|
60
82
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
} else {
|
|
79
|
-
console.log(kleur.dim(' beads: no port file found in main checkout, skipping redirect'));
|
|
83
|
+
if (bdResult.error || bdResult.status !== 0) {
|
|
84
|
+
// Fall back to plain git worktree add (bd not found or no .beads/ in project)
|
|
85
|
+
if (bdResult.status !== 0 && !bdResult.error) {
|
|
86
|
+
console.log(kleur.dim(' beads: no database found, creating worktree without redirect'));
|
|
87
|
+
}
|
|
88
|
+
const branchExists = spawnSync('git', ['rev-parse', '--verify', branchName], {
|
|
89
|
+
cwd: repoRoot, stdio: 'pipe',
|
|
90
|
+
}).status === 0;
|
|
91
|
+
|
|
92
|
+
const gitArgs = branchExists
|
|
93
|
+
? ['worktree', 'add', worktreePath, branchName]
|
|
94
|
+
: ['worktree', 'add', '-b', branchName, worktreePath];
|
|
95
|
+
|
|
96
|
+
const gitResult = spawnSync('git', gitArgs, { cwd: repoRoot, stdio: 'inherit' });
|
|
97
|
+
if (gitResult.status !== 0) {
|
|
98
|
+
console.error(kleur.red(`\n ✗ Failed to create worktree at ${worktreePath}\n`));
|
|
99
|
+
process.exit(1);
|
|
80
100
|
}
|
|
81
101
|
}
|
|
82
102
|
|
|
83
103
|
console.log(kleur.green(`\n ✓ Worktree ready — launching ${runtime}...\n`));
|
|
84
104
|
|
|
105
|
+
// Inject statusLine config for claude worktree sessions
|
|
106
|
+
if (runtime === 'claude') {
|
|
107
|
+
const statuslinePath = resolveStatuslineScript();
|
|
108
|
+
if (statuslinePath) {
|
|
109
|
+
const claudeDir = path.join(worktreePath, '.claude');
|
|
110
|
+
const localSettingsPath = path.join(claudeDir, 'settings.local.json');
|
|
111
|
+
try {
|
|
112
|
+
mkdirSync(claudeDir, { recursive: true });
|
|
113
|
+
writeFileSync(localSettingsPath, JSON.stringify({
|
|
114
|
+
statusLine: { type: 'command', command: `node ${statuslinePath}`, padding: 1 },
|
|
115
|
+
}, null, 2));
|
|
116
|
+
} catch { /* non-fatal — statusline is cosmetic */ }
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
85
120
|
// Launch the runtime in the worktree
|
|
86
121
|
const runtimeCmd = runtime === 'claude' ? 'claude' : 'pi';
|
|
87
|
-
const
|
|
122
|
+
const runtimeArgs = runtime === 'claude' ? ['--dangerously-skip-permissions'] : [];
|
|
123
|
+
const launchResult = spawnSync(runtimeCmd, runtimeArgs, {
|
|
88
124
|
cwd: worktreePath,
|
|
89
125
|
stdio: 'inherit',
|
|
90
126
|
});
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import { ExtensionHarness } from "./extension-harness";
|
|
3
|
+
import beadsExtension from "../../../config/pi/extensions/beads/index";
|
|
4
|
+
import { SubprocessRunner } from "../../../config/pi/extensions/core/lib";
|
|
5
|
+
|
|
6
|
+
vi.mock("@mariozechner/pi-coding-agent", () => ({
|
|
7
|
+
isToolCallEventType: (name: string, event: any) => event?.toolName === name,
|
|
8
|
+
isBashToolResult: (event: any) => event?.toolName === "bash",
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
vi.mock("../../../config/pi/extensions/core/lib", async () => {
|
|
12
|
+
const actual = await vi.importActual<any>("../../../config/pi/extensions/core/lib");
|
|
13
|
+
return {
|
|
14
|
+
...actual,
|
|
15
|
+
SubprocessRunner: {
|
|
16
|
+
run: vi.fn(),
|
|
17
|
+
},
|
|
18
|
+
EventAdapter: {
|
|
19
|
+
isBeadsProject: vi.fn(() => true),
|
|
20
|
+
isMutatingFileTool: vi.fn((event: any) => event?.toolName === "write"),
|
|
21
|
+
parseBdCounts: vi.fn(() => ({ open: 2, inProgress: 0 })),
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("Pi beads claim lifecycle", () => {
|
|
27
|
+
let harness: ExtensionHarness;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
vi.resetAllMocks();
|
|
31
|
+
harness = new ExtensionHarness();
|
|
32
|
+
harness.pi.sendUserMessage = vi.fn();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it("clears stale claim and blocks edits until a fresh claim is made", async () => {
|
|
36
|
+
const calls: string[][] = [];
|
|
37
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
38
|
+
calls.push(args);
|
|
39
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-old\n", stderr: "" };
|
|
40
|
+
if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-old", status: "closed" }), stderr: "" };
|
|
41
|
+
if (args[0] === "list") return { code: 0, stdout: "Total: 2 issues (2 open, 0 in progress)", stderr: "" };
|
|
42
|
+
if (args[0] === "kv" && args[1] === "clear") return { code: 0, stdout: "", stderr: "" };
|
|
43
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
beadsExtension(harness.pi);
|
|
47
|
+
|
|
48
|
+
const result = await harness.emit("tool_call", {
|
|
49
|
+
toolName: "write",
|
|
50
|
+
input: { path: "src/main.ts" },
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
expect(calls.some((a) => a[0] === "kv" && a[1] === "clear" && `${a[2]}`.startsWith("claimed:"))).toBe(true);
|
|
54
|
+
expect(result?.block).toBe(true);
|
|
55
|
+
expect(result?.reason).toContain("No active claim");
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it("does not block commit when claim is stale/closed", async () => {
|
|
59
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
60
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-old\n", stderr: "" };
|
|
61
|
+
if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-old", status: "closed" }), stderr: "" };
|
|
62
|
+
if (args[0] === "kv" && args[1] === "clear") return { code: 0, stdout: "", stderr: "" };
|
|
63
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
beadsExtension(harness.pi);
|
|
67
|
+
|
|
68
|
+
const result = await harness.emit("tool_call", {
|
|
69
|
+
toolName: "bash",
|
|
70
|
+
input: { command: "git commit -m 'test'" },
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
expect(result).toBeUndefined();
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it("blocks commit when active claimed issue is still in progress", async () => {
|
|
77
|
+
(SubprocessRunner.run as any).mockImplementation(async (_cmd: string, args: string[]) => {
|
|
78
|
+
if (args[0] === "kv" && args[1] === "get") return { code: 0, stdout: "xtrm-live\n", stderr: "" };
|
|
79
|
+
if (args[0] === "show") return { code: 0, stdout: JSON.stringify({ id: "xtrm-live", status: "in_progress" }), stderr: "" };
|
|
80
|
+
return { code: 0, stdout: "", stderr: "" };
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
beadsExtension(harness.pi);
|
|
84
|
+
|
|
85
|
+
const result = await harness.emit("tool_call", {
|
|
86
|
+
toolName: "bash",
|
|
87
|
+
input: { command: "git commit -m 'test'" },
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
expect(result?.block).toBe(true);
|
|
91
|
+
expect(result?.reason).toContain("Active claim [xtrm-live]");
|
|
92
|
+
});
|
|
93
|
+
});
|