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,131 @@
1
+ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+ import { mkdtemp, rm } from 'node:fs/promises';
3
+ import { spawnSync } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+ import path from 'node:path';
6
+ import fsExtra from 'fs-extra';
7
+ import { mergeSettingsHooks, installSkills, installGitHooks } from '../src/commands/install-service-skills.js';
8
+
9
+ // __dirname in vitest context = cli/test/
10
+ const REPO_ROOT = path.resolve(__dirname, '../..');
11
+ const ACTUAL_SKILLS_SRC = path.join(REPO_ROOT, 'project-skills', 'service-skills-set', '.claude', 'skills');
12
+ const ACTUAL_CLAUDE_SRC = path.join(REPO_ROOT, 'project-skills', 'service-skills-set', '.claude');
13
+
14
+ describe('mergeSettingsHooks', () => {
15
+ it('adds all three hooks to empty settings', () => {
16
+ const { result, added, skipped } = mergeSettingsHooks({});
17
+ const hooks = result.hooks as Record<string, unknown>;
18
+ expect(added).toEqual(['SessionStart', 'PreToolUse', 'PostToolUse']);
19
+ expect(skipped).toEqual([]);
20
+ expect(hooks).toHaveProperty('SessionStart');
21
+ expect(hooks).toHaveProperty('PreToolUse');
22
+ expect(hooks).toHaveProperty('PostToolUse');
23
+ });
24
+
25
+ it('preserves existing keys and skips them', () => {
26
+ const existing = { hooks: { SessionStart: [{ custom: true }] } };
27
+ const { result, added, skipped } = mergeSettingsHooks(existing);
28
+ const hooks = result.hooks as Record<string, unknown>;
29
+ expect(skipped).toEqual(['SessionStart']);
30
+ expect(added).toEqual(['PreToolUse', 'PostToolUse']);
31
+ expect(hooks.SessionStart).toEqual([{ custom: true }]);
32
+ });
33
+
34
+ it('preserves non-hook keys in settings', () => {
35
+ const existing = { apiKey: 'abc', permissions: { allow: [] } };
36
+ const { result } = mergeSettingsHooks(existing);
37
+ expect(result.apiKey).toBe('abc');
38
+ expect(result.permissions).toEqual({ allow: [] });
39
+ });
40
+ });
41
+
42
+ describe('installSkills', () => {
43
+ let tmpDir: string;
44
+
45
+ beforeEach(async () => {
46
+ tmpDir = await mkdtemp(path.join(tmpdir(), 'jaggers-test-'));
47
+ });
48
+
49
+ afterEach(async () => {
50
+ await rm(tmpDir, { recursive: true, force: true });
51
+ });
52
+
53
+ it('creates .claude/skills/<skill> directories', async () => {
54
+ await installSkills(tmpDir, ACTUAL_SKILLS_SRC);
55
+ for (const skill of ['creating-service-skills', 'using-service-skills', 'updating-service-skills', 'scoping-service-skills']) {
56
+ const dest = path.join(tmpDir, '.claude', 'skills', skill);
57
+ expect(await fsExtra.pathExists(dest)).toBe(true);
58
+ }
59
+ });
60
+
61
+ it('is idempotent (safe to run twice)', async () => {
62
+ await installSkills(tmpDir, ACTUAL_SKILLS_SRC);
63
+ await expect(installSkills(tmpDir, ACTUAL_SKILLS_SRC)).resolves.not.toThrow();
64
+ });
65
+ });
66
+
67
+ describe('installGitHooks', () => {
68
+ let tmpDir: string;
69
+
70
+ beforeEach(async () => {
71
+ tmpDir = await mkdtemp(path.join(tmpdir(), 'jaggers-test-'));
72
+ await fsExtra.mkdirp(path.join(tmpDir, '.git', 'hooks'));
73
+ });
74
+
75
+ afterEach(async () => {
76
+ await rm(tmpDir, { recursive: true, force: true });
77
+ });
78
+
79
+ it('creates .githooks/pre-commit with doc-reminder snippet', async () => {
80
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
81
+ const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-commit'), 'utf8');
82
+ expect(content).toContain('# [jaggers] doc-reminder');
83
+ expect(content).toContain('.claude/git-hooks/doc_reminder.py');
84
+ });
85
+
86
+ it('creates .githooks/pre-push with skill-staleness snippet', async () => {
87
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
88
+ const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-push'), 'utf8');
89
+ expect(content).toContain('# [jaggers] skill-staleness');
90
+ expect(content).toContain('.claude/git-hooks/skill_staleness.py');
91
+ });
92
+
93
+ it('copies hook scripts into .claude/git-hooks/', async () => {
94
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
95
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'doc_reminder.py'))).toBe(true);
96
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.claude', 'git-hooks', 'skill_staleness.py'))).toBe(true);
97
+ });
98
+
99
+ it('activates hooks in .git/hooks/', async () => {
100
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
101
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-commit'))).toBe(true);
102
+ expect(await fsExtra.pathExists(path.join(tmpDir, '.git', 'hooks', 'pre-push'))).toBe(true);
103
+ });
104
+
105
+ it('is idempotent — does not duplicate snippets on re-run', async () => {
106
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
107
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
108
+ const content = await fsExtra.readFile(path.join(tmpDir, '.githooks', 'pre-commit'), 'utf8');
109
+ const count = (content.match(/# \[jaggers\] doc-reminder/g) ?? []).length;
110
+ expect(count).toBe(1);
111
+ });
112
+
113
+ it('chains hooks into configured core.hooksPath when beads owns hooks path', async () => {
114
+ spawnSync('git', ['init'], { cwd: tmpDir, stdio: 'pipe' });
115
+ spawnSync('git', ['config', 'core.hooksPath', '.beads/hooks'], { cwd: tmpDir, stdio: 'pipe' });
116
+
117
+ await installGitHooks(tmpDir, ACTUAL_CLAUDE_SRC);
118
+
119
+ const beadsPreCommit = path.join(tmpDir, '.beads', 'hooks', 'pre-commit');
120
+ const beadsPrePush = path.join(tmpDir, '.beads', 'hooks', 'pre-push');
121
+ expect(await fsExtra.pathExists(beadsPreCommit)).toBe(true);
122
+ expect(await fsExtra.pathExists(beadsPrePush)).toBe(true);
123
+
124
+ const preCommitContent = await fsExtra.readFile(beadsPreCommit, 'utf8');
125
+ const prePushContent = await fsExtra.readFile(beadsPrePush, 'utf8');
126
+ expect(preCommitContent).toContain('# [jaggers] chain-githooks');
127
+ expect(prePushContent).toContain('# [jaggers] chain-githooks');
128
+ expect(preCommitContent).toContain(path.join(tmpDir, '.githooks', 'pre-commit'));
129
+ expect(prePushContent).toContain(path.join(tmpDir, '.githooks', 'pre-push'));
130
+ });
131
+ });
@@ -0,0 +1,72 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const CLI_BIN = path.join(__dirname, '../dist/index.cjs');
8
+
9
+ function run(args: string[]): { stdout: string; stderr: string; status: number } {
10
+ const r = spawnSync('node', [CLI_BIN, ...args], {
11
+ encoding: 'utf8',
12
+ timeout: 10000,
13
+ env: { ...process.env },
14
+ });
15
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
16
+ }
17
+
18
+ describe('install command surface (c1qd, j2jk, 6gpf, a875)', () => {
19
+
20
+ it('xtrm install all prints deprecation notice', () => {
21
+ const r = run(['install', 'all']);
22
+ expect(r.stdout + r.stderr).toMatch(/deprecated/i);
23
+ });
24
+
25
+ it('xtrm install basic prints deprecation notice', () => {
26
+ const r = run(['install', 'basic']);
27
+ expect(r.stdout + r.stderr).toMatch(/deprecated/i);
28
+ });
29
+
30
+ it('xtrm install project exits with unknown command error', () => {
31
+ const r = run(['install', 'project', 'something']);
32
+ const combined = r.stdout + r.stderr;
33
+ expect(combined.toLowerCase()).toMatch(/unknown command|error/);
34
+ });
35
+
36
+ it('xtrm project exits with unknown command error', () => {
37
+ const r = run(['project', 'init']);
38
+ const combined = r.stdout + r.stderr;
39
+ expect(combined.toLowerCase()).toMatch(/unknown command|error/);
40
+ });
41
+
42
+ it('xtrm install --help does not describe all/basic as primary install subcommands', () => {
43
+ const r = run(['install', '--help']);
44
+ expect(r.stdout).not.toMatch(/install everything.*beads/i);
45
+ expect(r.stdout).not.toMatch(/no beads gate/i);
46
+ });
47
+
48
+ it('xtrm install pi shows install help (pi is a target-selector, not a pi-runtime subcommand)', () => {
49
+ // 'pi' is treated as a target-selector argument by the install command,
50
+ // not as a pi-runtime management subcommand (that lives under xt pi install)
51
+ const r = run(['install', 'pi', '--help']);
52
+ expect(r.stdout).toMatch(/install/i);
53
+ expect(r.stdout).not.toMatch(/launch.*pi.*session|worktree.*session/i);
54
+ });
55
+
56
+ it('xt pi install is registered under xt pi namespace', () => {
57
+ const r = run(['pi', 'install', '--help']);
58
+ expect(r.status).toBe(0);
59
+ expect(r.stdout).toMatch(/extension|package|install/i);
60
+ });
61
+
62
+ it('xtrm init is registered', () => {
63
+ const r = run(['init', '--help']);
64
+ expect(r.status).toBe(0);
65
+ });
66
+
67
+ it('xtrm --help does not mention gemini', () => {
68
+ const r = run(['--help']);
69
+ expect(r.stdout).not.toMatch(/gemini/i);
70
+ expect(r.stdout).not.toMatch(/qwen/i);
71
+ });
72
+ });
@@ -0,0 +1,121 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import { fileURLToPath } from 'node:url';
5
+
6
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
+ const CLI_BIN = path.join(__dirname, '../dist/index.cjs');
8
+
9
+ function run(args: string[]): { stdout: string; stderr: string; status: number } {
10
+ const r = spawnSync('node', [CLI_BIN, ...args], {
11
+ encoding: 'utf8',
12
+ timeout: 10000,
13
+ env: { ...process.env },
14
+ });
15
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
16
+ }
17
+
18
+ describe('xt claude runtime subcommands (bjvn)', () => {
19
+
20
+ it('xt claude --help lists subcommands', () => {
21
+ const r = run(['claude', '--help']);
22
+ expect(r.status).toBe(0);
23
+ const out = r.stdout;
24
+ expect(out).toMatch(/install/);
25
+ expect(out).toMatch(/reload/);
26
+ expect(out).toMatch(/status/);
27
+ expect(out).toMatch(/doctor/);
28
+ });
29
+
30
+ it('xt claude install --help exits 0', () => {
31
+ const r = run(['claude', 'install', '--help']);
32
+ expect(r.status).toBe(0);
33
+ expect(r.stdout).toMatch(/install|plugin/i);
34
+ });
35
+
36
+ it('xt claude install --dry-run exits without error', () => {
37
+ const r = run(['claude', 'install', '--dry-run']);
38
+ // dry-run may fail if repo root can't be found in test env, but should not crash
39
+ const combined = r.stdout + r.stderr;
40
+ expect(combined).not.toMatch(/TypeError|ReferenceError|Cannot read/i);
41
+ });
42
+
43
+ it('xt claude reload --help exits 0', () => {
44
+ const r = run(['claude', 'reload', '--help']);
45
+ expect(r.status).toBe(0);
46
+ });
47
+
48
+ it('xt claude status --help exits 0', () => {
49
+ const r = run(['claude', 'status', '--help']);
50
+ expect(r.status).toBe(0);
51
+ });
52
+
53
+ it('xt claude doctor --help exits 0', () => {
54
+ const r = run(['claude', 'doctor', '--help']);
55
+ expect(r.status).toBe(0);
56
+ });
57
+
58
+ it('xt claude is described as session launcher with worktree', () => {
59
+ const r = run(['claude', '--help']);
60
+ expect(r.status).toBe(0);
61
+ expect(r.stdout).toMatch(/session|worktree/i);
62
+ });
63
+ });
64
+
65
+ describe('xt pi runtime subcommands (bjvn)', () => {
66
+
67
+ it('xt pi --help lists subcommands', () => {
68
+ const r = run(['pi', '--help']);
69
+ expect(r.status).toBe(0);
70
+ const out = r.stdout;
71
+ expect(out).toMatch(/install/);
72
+ expect(out).toMatch(/setup/);
73
+ expect(out).toMatch(/status/);
74
+ expect(out).toMatch(/doctor/);
75
+ expect(out).toMatch(/reload/);
76
+ });
77
+
78
+ it('xt pi install --help exits 0', () => {
79
+ const r = run(['pi', 'install', '--help']);
80
+ expect(r.status).toBe(0);
81
+ expect(r.stdout).toMatch(/install|extension|package/i);
82
+ });
83
+
84
+ it('xt pi setup --help exits 0', () => {
85
+ const r = run(['pi', 'setup', '--help']);
86
+ expect(r.status).toBe(0);
87
+ expect(r.stdout).toMatch(/setup|api|key|interactive/i);
88
+ });
89
+
90
+ it('xt pi status --help exits 0', () => {
91
+ const r = run(['pi', 'status', '--help']);
92
+ expect(r.status).toBe(0);
93
+ });
94
+
95
+ it('xt pi doctor --help exits 0', () => {
96
+ const r = run(['pi', 'doctor', '--help']);
97
+ expect(r.status).toBe(0);
98
+ });
99
+
100
+ it('xt pi reload --help exits 0', () => {
101
+ const r = run(['pi', 'reload', '--help']);
102
+ expect(r.status).toBe(0);
103
+ });
104
+
105
+ it('xt pi is described as session launcher with worktree', () => {
106
+ const r = run(['pi', '--help']);
107
+ expect(r.status).toBe(0);
108
+ expect(r.stdout).toMatch(/session|worktree/i);
109
+ });
110
+
111
+ it('xt pi install is under the pi namespace (not xtrm install)', () => {
112
+ // xt install treats "pi" as a target-selector (not a pi runtime subcommand)
113
+ // The install command help should NOT mention pi runtime management
114
+ const r = run(['install', 'pi', '--help']);
115
+ expect(r.stdout).not.toMatch(/launch.*pi.*session|worktree.*session/i);
116
+ // xt pi install --help IS the pi runtime install
117
+ const r2 = run(['pi', 'install', '--help']);
118
+ expect(r2.status).toBe(0);
119
+ expect(r2.stdout).toMatch(/extension|package|install/i);
120
+ });
121
+ });
@@ -0,0 +1,139 @@
1
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
2
+ import { spawnSync } from 'node:child_process';
3
+ import path from 'node:path';
4
+ import fs from 'node:fs';
5
+ import os from 'node:os';
6
+ import { fileURLToPath } from 'node:url';
7
+
8
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
9
+ const CLI_BIN = path.join(__dirname, '../dist/index.cjs');
10
+
11
+ function run(args: string[], opts: { cwd?: string; env?: NodeJS.ProcessEnv } = {}): { stdout: string; stderr: string; status: number } {
12
+ const r = spawnSync('node', [CLI_BIN, ...args], {
13
+ encoding: 'utf8',
14
+ timeout: 15000,
15
+ cwd: opts.cwd,
16
+ env: { ...process.env, ...opts.env },
17
+ });
18
+ return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status ?? -1 };
19
+ }
20
+
21
+ function git(args: string[], cwd: string): void {
22
+ const r = spawnSync('git', args, { cwd, encoding: 'utf8', stdio: 'pipe' });
23
+ if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`);
24
+ }
25
+
26
+ function removeWorktree(wtPath: string, repoDir: string): void {
27
+ spawnSync('git', ['worktree', 'remove', wtPath, '--force'], { cwd: repoDir, stdio: 'pipe' });
28
+ try { fs.rmSync(wtPath, { recursive: true, force: true }); } catch { /* ignore */ }
29
+ }
30
+
31
+ let siblingBase: string;
32
+ let repoDir: string;
33
+
34
+ beforeAll(() => {
35
+ siblingBase = fs.mkdtempSync(path.join(os.tmpdir(), 'xtrm-parent-'));
36
+ repoDir = path.join(siblingBase, 'myproject');
37
+ fs.mkdirSync(repoDir);
38
+ git(['init'], repoDir);
39
+ git(['config', 'user.email', 'test@test.com'], repoDir);
40
+ git(['config', 'user.name', 'Test'], repoDir);
41
+ fs.writeFileSync(path.join(repoDir, 'README.md'), '# test');
42
+ git(['add', '.'], repoDir);
43
+ git(['commit', '-m', 'init'], repoDir);
44
+ });
45
+
46
+ afterAll(() => {
47
+ try { fs.rmSync(siblingBase, { recursive: true, force: true }); } catch { /* ignore */ }
48
+ });
49
+
50
+ describe('session launcher CLI surface (2q8j)', () => {
51
+
52
+ it('xt claude --help shows [name] as optional argument', () => {
53
+ const r = run(['claude', '--help']);
54
+ expect(r.status).toBe(0);
55
+ expect(r.stdout).toMatch(/\[name\]/);
56
+ });
57
+
58
+ it('xt pi --help shows [name] as optional argument', () => {
59
+ const r = run(['pi', '--help']);
60
+ expect(r.status).toBe(0);
61
+ expect(r.stdout).toMatch(/\[name\]/);
62
+ });
63
+
64
+ it('xt claude fails gracefully if claude binary not found', () => {
65
+ const r = run(['claude'], {
66
+ cwd: repoDir,
67
+ env: { PATH: '/usr/bin:/bin' },
68
+ });
69
+ const combined = r.stdout + r.stderr;
70
+ expect(combined).not.toMatch(/TypeError|ReferenceError|Cannot read properties/i);
71
+ });
72
+
73
+ it('xt pi fails gracefully if pi binary not found', () => {
74
+ const r = run(['pi'], {
75
+ cwd: repoDir,
76
+ env: { PATH: '/usr/bin:/bin' },
77
+ });
78
+ const combined = r.stdout + r.stderr;
79
+ expect(combined).not.toMatch(/TypeError|ReferenceError|Cannot read properties/i);
80
+ });
81
+ });
82
+
83
+ describe('worktree creation naming convention (2q8j)', () => {
84
+
85
+ it('creates worktree with xt/<name> branch when name is provided', () => {
86
+ const today = new Date();
87
+ const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
88
+ const expectedName = `myproject-xt-claude-${dateStr}`;
89
+ const expectedPath = path.join(siblingBase, expectedName);
90
+
91
+ // Pre-clean any stale worktree from previous runs
92
+ if (fs.existsSync(expectedPath)) {
93
+ removeWorktree(expectedPath, repoDir);
94
+ }
95
+
96
+ run(['claude', 'mysession'], {
97
+ cwd: repoDir,
98
+ env: { PATH: '/usr/bin:/bin' },
99
+ });
100
+
101
+ if (fs.existsSync(expectedPath)) {
102
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
103
+ cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
104
+ });
105
+ expect(branchResult.stdout.trim()).toBe('xt/mysession');
106
+
107
+ // Verify it's a sibling of repoDir, not nested inside it
108
+ // Use repoDir + sep to avoid "myproject-xt-..." startsWith "myproject"
109
+ expect(expectedPath.startsWith(repoDir + path.sep)).toBe(false);
110
+ expect(path.dirname(expectedPath)).toBe(siblingBase);
111
+
112
+ removeWorktree(expectedPath, repoDir);
113
+ }
114
+ });
115
+
116
+ it('creates worktree with random xt/<slug> branch when no name provided', () => {
117
+ const today = new Date();
118
+ const dateStr = `${today.getFullYear()}${String(today.getMonth() + 1).padStart(2, '0')}${String(today.getDate()).padStart(2, '0')}`;
119
+ const expectedName = `myproject-xt-pi-${dateStr}`;
120
+ const expectedPath = path.join(siblingBase, expectedName);
121
+
122
+ if (fs.existsSync(expectedPath)) {
123
+ removeWorktree(expectedPath, repoDir);
124
+ }
125
+
126
+ run(['pi'], {
127
+ cwd: repoDir,
128
+ env: { PATH: '/usr/bin:/bin' },
129
+ });
130
+
131
+ if (fs.existsSync(expectedPath)) {
132
+ const branchResult = spawnSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
133
+ cwd: expectedPath, encoding: 'utf8', stdio: 'pipe',
134
+ });
135
+ expect(branchResult.stdout.trim()).toMatch(/^xt\/[a-z0-9]{4}$/);
136
+ removeWorktree(expectedPath, repoDir);
137
+ }
138
+ });
139
+ });
package/tsconfig.json ADDED
@@ -0,0 +1,22 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "./dist",
7
+ "rootDir": "./src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "declaration": true,
12
+ "sourceMap": true
13
+ },
14
+ "include": [
15
+ "src/**/*"
16
+ ],
17
+ "exclude": [
18
+ "node_modules",
19
+ "dist",
20
+ "test"
21
+ ]
22
+ }
package/tsup.config.ts ADDED
@@ -0,0 +1,17 @@
1
+ import { defineConfig } from 'tsup';
2
+
3
+ export default defineConfig({
4
+ entry: ['src/index.ts'],
5
+ format: ['cjs'],
6
+ target: 'node18',
7
+ clean: true,
8
+ dts: true,
9
+ sourcemap: true,
10
+ outDir: 'dist',
11
+ banner: { js: '#!/usr/bin/env node' },
12
+ // Bundle ALL dependencies inline so dist/index.cjs is self-contained.
13
+ // Required for npx-from-git to work (cli/node_modules won't exist).
14
+ noExternal: [/.*/],
15
+ splitting: false,
16
+ });
17
+
@@ -0,0 +1,10 @@
1
+ import { defineConfig } from 'vitest/config'
2
+ import { VitestReporter } from 'tdd-guard-vitest'
3
+ import path from 'path'
4
+
5
+ export default defineConfig({
6
+ test: {
7
+ reporters: ['default', new VitestReporter(path.resolve(__dirname, '..'))],
8
+ testTimeout: 30000,
9
+ },
10
+ })