@damper/cli 0.9.12 → 0.9.14
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/dist/commands/start.js +77 -1
- package/dist/services/claude.js +126 -45
- package/dist/services/state.d.ts +7 -0
- package/dist/services/state.js +25 -0
- package/package.json +1 -1
package/dist/commands/start.js
CHANGED
|
@@ -5,7 +5,7 @@ import { createWorktree, getMainProjectRoot, removeWorktreeDir } from '../servic
|
|
|
5
5
|
import { bootstrapContext, refreshContext } from '../services/context-bootstrap.js';
|
|
6
6
|
import { pickTask } from '../ui/task-picker.js';
|
|
7
7
|
import { launchClaude, launchClaudeForReview, postTaskFlow, isClaudeInstalled, isDamperMcpConfigured, configureDamperMcp } from '../services/claude.js';
|
|
8
|
-
import { getWorktreesForProject, cleanupStaleWorktrees } from '../services/state.js';
|
|
8
|
+
import { getWorktreesForProject, cleanupStaleWorktrees, getPreference, setPreference } from '../services/state.js';
|
|
9
9
|
import { getApiKey, isProjectConfigured, getProjectConfigPath } from '../services/config.js';
|
|
10
10
|
import { shortIdRaw } from '../ui/format.js';
|
|
11
11
|
export async function startCommand(options) {
|
|
@@ -39,6 +39,8 @@ export async function startCommand(options) {
|
|
|
39
39
|
}
|
|
40
40
|
process.exit(1);
|
|
41
41
|
}
|
|
42
|
+
// Check if tmux is installed (enables persistent task title bar)
|
|
43
|
+
await ensureTmux();
|
|
42
44
|
// Ensure MCP is configured globally (without key - key comes from env)
|
|
43
45
|
if (!isDamperMcpConfigured()) {
|
|
44
46
|
console.log(pc.dim('Configuring Damper MCP...'));
|
|
@@ -310,3 +312,77 @@ async function handleReviewAndComplete(options) {
|
|
|
310
312
|
}
|
|
311
313
|
console.log();
|
|
312
314
|
}
|
|
315
|
+
/**
|
|
316
|
+
* Check if tmux is installed; offer to install or recommend it if not.
|
|
317
|
+
* Skipped if the user previously dismissed the prompt.
|
|
318
|
+
*/
|
|
319
|
+
async function ensureTmux() {
|
|
320
|
+
// Already dismissed?
|
|
321
|
+
if (getPreference('tmuxPromptDismissed'))
|
|
322
|
+
return;
|
|
323
|
+
// Already installed?
|
|
324
|
+
try {
|
|
325
|
+
const { execa } = await import('execa');
|
|
326
|
+
await execa('tmux', ['-V'], { stdio: 'pipe' });
|
|
327
|
+
return; // tmux is available
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// not installed — continue
|
|
331
|
+
}
|
|
332
|
+
// Check if brew is available
|
|
333
|
+
let hasBrew = false;
|
|
334
|
+
try {
|
|
335
|
+
const { execa } = await import('execa');
|
|
336
|
+
await execa('brew', ['--version'], { stdio: 'pipe' });
|
|
337
|
+
hasBrew = true;
|
|
338
|
+
}
|
|
339
|
+
catch {
|
|
340
|
+
// no brew
|
|
341
|
+
}
|
|
342
|
+
console.log(pc.yellow('\n⚠ tmux not found'));
|
|
343
|
+
console.log(pc.dim(' tmux enables a persistent task title bar while Claude runs.'));
|
|
344
|
+
if (hasBrew) {
|
|
345
|
+
const { select } = await import('@inquirer/prompts');
|
|
346
|
+
const action = await select({
|
|
347
|
+
message: 'Install tmux via Homebrew?',
|
|
348
|
+
choices: [
|
|
349
|
+
{ name: 'Install now (brew install tmux)', value: 'install' },
|
|
350
|
+
{ name: 'Skip for now', value: 'skip' },
|
|
351
|
+
{ name: "Don't ask again", value: 'dismiss' },
|
|
352
|
+
],
|
|
353
|
+
});
|
|
354
|
+
if (action === 'install') {
|
|
355
|
+
console.log(pc.dim('\nInstalling tmux...'));
|
|
356
|
+
try {
|
|
357
|
+
const { execa } = await import('execa');
|
|
358
|
+
await execa('brew', ['install', 'tmux'], { stdio: 'inherit' });
|
|
359
|
+
console.log(pc.green('✓ tmux installed\n'));
|
|
360
|
+
}
|
|
361
|
+
catch {
|
|
362
|
+
console.log(pc.red('Failed to install tmux. You can install it manually: brew install tmux\n'));
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else if (action === 'dismiss') {
|
|
366
|
+
setPreference('tmuxPromptDismissed', true);
|
|
367
|
+
console.log(pc.dim('You can install it later: brew install tmux\n'));
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
console.log(pc.dim('Continuing without tmux.\n'));
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
else {
|
|
374
|
+
console.log(pc.dim(' Install it with: brew install tmux'));
|
|
375
|
+
console.log(pc.dim(' Or see: https://github.com/tmux/tmux/wiki/Installing\n'));
|
|
376
|
+
const { select } = await import('@inquirer/prompts');
|
|
377
|
+
const action = await select({
|
|
378
|
+
message: 'What would you like to do?',
|
|
379
|
+
choices: [
|
|
380
|
+
{ name: 'Continue without tmux', value: 'skip' },
|
|
381
|
+
{ name: "Don't remind me again", value: 'dismiss' },
|
|
382
|
+
],
|
|
383
|
+
});
|
|
384
|
+
if (action === 'dismiss') {
|
|
385
|
+
setPreference('tmuxPromptDismissed', true);
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
package/dist/services/claude.js
CHANGED
|
@@ -4,7 +4,7 @@ import * as os from 'node:os';
|
|
|
4
4
|
import { spawn } from 'node:child_process';
|
|
5
5
|
import { execa } from 'execa';
|
|
6
6
|
import pc from 'picocolors';
|
|
7
|
-
import { shortIdRaw, setTerminalTitle, clearTerminalTitle
|
|
7
|
+
import { shortIdRaw, setTerminalTitle, clearTerminalTitle } from '../ui/format.js';
|
|
8
8
|
const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude');
|
|
9
9
|
const CLAUDE_SETTINGS_FILE = path.join(CLAUDE_SETTINGS_DIR, 'settings.json');
|
|
10
10
|
/**
|
|
@@ -81,6 +81,62 @@ export function configureDamperMcp() {
|
|
|
81
81
|
// Write back
|
|
82
82
|
fs.writeFileSync(CLAUDE_SETTINGS_FILE, JSON.stringify(settings, null, 2));
|
|
83
83
|
}
|
|
84
|
+
/**
|
|
85
|
+
* Check if tmux is available
|
|
86
|
+
*/
|
|
87
|
+
async function isTmuxAvailable() {
|
|
88
|
+
try {
|
|
89
|
+
await execa('tmux', ['-V'], { stdio: 'pipe' });
|
|
90
|
+
return true;
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Launch a command inside a tmux session with a persistent status bar.
|
|
98
|
+
* The session auto-exits when the command finishes.
|
|
99
|
+
*/
|
|
100
|
+
async function launchInTmux(options) {
|
|
101
|
+
const { label, command, args, cwd, env } = options;
|
|
102
|
+
const sessionName = `damper-${Date.now()}`;
|
|
103
|
+
// Build the full command string to run inside tmux
|
|
104
|
+
// When the command finishes, tmux exits automatically via remain-on-exit off
|
|
105
|
+
const escapedArgs = args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(' ');
|
|
106
|
+
const fullCmd = `${command} ${escapedArgs}`;
|
|
107
|
+
// Create a detached tmux session running the command directly
|
|
108
|
+
// Using tmux new-session with the command means the session dies when the command exits
|
|
109
|
+
await execa('tmux', [
|
|
110
|
+
'new-session', '-d',
|
|
111
|
+
'-s', sessionName,
|
|
112
|
+
'-x', String(process.stdout.columns || 80),
|
|
113
|
+
'-y', String(process.stdout.rows || 24),
|
|
114
|
+
fullCmd,
|
|
115
|
+
], { cwd, env: { ...process.env, ...env }, stdio: 'pipe' });
|
|
116
|
+
// Configure the status bar
|
|
117
|
+
const statusBarCmds = [
|
|
118
|
+
['set-option', '-t', sessionName, 'status', 'on'],
|
|
119
|
+
['set-option', '-t', sessionName, 'status-position', 'top'],
|
|
120
|
+
['set-option', '-t', sessionName, 'status-style', 'bg=#1e1e2e,fg=#cdd6f4'],
|
|
121
|
+
['set-option', '-t', sessionName, 'status-left', ` ${label} `],
|
|
122
|
+
['set-option', '-t', sessionName, 'status-left-length', '100'],
|
|
123
|
+
['set-option', '-t', sessionName, 'status-right', ''],
|
|
124
|
+
];
|
|
125
|
+
for (const cmd of statusBarCmds) {
|
|
126
|
+
await execa('tmux', cmd, { stdio: 'pipe' }).catch(() => { });
|
|
127
|
+
}
|
|
128
|
+
// Attach to the session — blocks until Claude exits and tmux session ends
|
|
129
|
+
await new Promise((resolve) => {
|
|
130
|
+
const child = spawn('tmux', ['attach-session', '-t', sessionName], {
|
|
131
|
+
cwd,
|
|
132
|
+
stdio: 'inherit',
|
|
133
|
+
});
|
|
134
|
+
child.on('error', () => resolve());
|
|
135
|
+
child.on('close', () => resolve());
|
|
136
|
+
});
|
|
137
|
+
// Cleanup in case session lingers
|
|
138
|
+
await execa('tmux', ['kill-session', '-t', sessionName], { stdio: 'pipe' }).catch(() => { });
|
|
139
|
+
}
|
|
84
140
|
/**
|
|
85
141
|
* Launch Claude Code in a directory
|
|
86
142
|
*/
|
|
@@ -136,35 +192,42 @@ export async function launchClaude(options) {
|
|
|
136
192
|
args = yolo ? ['--dangerously-skip-permissions', initialPrompt] : [initialPrompt];
|
|
137
193
|
console.log(pc.dim(`Launching Claude in ${cwd}...`));
|
|
138
194
|
}
|
|
139
|
-
// Set terminal title
|
|
195
|
+
// Set terminal title (works in tab bar regardless of tmux)
|
|
140
196
|
const taskLabel = `#${shortIdRaw(taskId)}: ${taskTitle}`;
|
|
141
197
|
setTerminalTitle(taskLabel);
|
|
142
|
-
const
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
198
|
+
const hasTmux = await isTmuxAvailable();
|
|
199
|
+
if (hasTmux) {
|
|
200
|
+
// Launch inside tmux with a persistent status bar
|
|
201
|
+
await launchInTmux({
|
|
202
|
+
label: taskLabel,
|
|
203
|
+
command: 'claude',
|
|
204
|
+
args,
|
|
149
205
|
cwd,
|
|
150
|
-
|
|
151
|
-
env: {
|
|
152
|
-
...process.env,
|
|
153
|
-
DAMPER_API_KEY: apiKey,
|
|
154
|
-
},
|
|
206
|
+
env: { DAMPER_API_KEY: apiKey },
|
|
155
207
|
});
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
208
|
+
}
|
|
209
|
+
else {
|
|
210
|
+
// Fallback: direct spawn (no status bar, just terminal title)
|
|
211
|
+
await new Promise((resolve) => {
|
|
212
|
+
const child = spawn('claude', args, {
|
|
213
|
+
cwd,
|
|
214
|
+
stdio: 'inherit',
|
|
215
|
+
env: {
|
|
216
|
+
...process.env,
|
|
217
|
+
DAMPER_API_KEY: apiKey,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
child.on('error', (err) => {
|
|
221
|
+
if (err.code === 'ENOENT') {
|
|
222
|
+
console.log(pc.red('\nError: Claude Code CLI not found.'));
|
|
223
|
+
console.log(pc.dim('Install it with: npm install -g @anthropic-ai/claude-code\n'));
|
|
224
|
+
process.exit(1);
|
|
225
|
+
}
|
|
226
|
+
resolve();
|
|
227
|
+
});
|
|
228
|
+
child.on('close', () => resolve());
|
|
163
229
|
});
|
|
164
|
-
|
|
165
|
-
});
|
|
166
|
-
// Clean up status bar and terminal title
|
|
167
|
-
statusBar.hide();
|
|
230
|
+
}
|
|
168
231
|
clearTerminalTitle();
|
|
169
232
|
console.log(pc.dim('\n─────────────────────────────────────────\n'));
|
|
170
233
|
return { cwd, taskId, apiKey };
|
|
@@ -549,18 +612,27 @@ export async function launchClaudeForReview(options) {
|
|
|
549
612
|
].join('\n');
|
|
550
613
|
const reviewLabel = `Review #${shortIdRaw(taskId)}`;
|
|
551
614
|
setTerminalTitle(reviewLabel);
|
|
552
|
-
const
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
615
|
+
const hasTmux = await isTmuxAvailable();
|
|
616
|
+
if (hasTmux) {
|
|
617
|
+
await launchInTmux({
|
|
618
|
+
label: reviewLabel,
|
|
619
|
+
command: 'claude',
|
|
620
|
+
args: [prompt],
|
|
556
621
|
cwd,
|
|
557
|
-
|
|
558
|
-
env: { ...process.env, DAMPER_API_KEY: apiKey },
|
|
622
|
+
env: { DAMPER_API_KEY: apiKey },
|
|
559
623
|
});
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
624
|
+
}
|
|
625
|
+
else {
|
|
626
|
+
await new Promise((resolve) => {
|
|
627
|
+
const child = spawn('claude', [prompt], {
|
|
628
|
+
cwd,
|
|
629
|
+
stdio: 'inherit',
|
|
630
|
+
env: { ...process.env, DAMPER_API_KEY: apiKey },
|
|
631
|
+
});
|
|
632
|
+
child.on('error', () => resolve());
|
|
633
|
+
child.on('close', () => resolve());
|
|
634
|
+
});
|
|
635
|
+
}
|
|
564
636
|
clearTerminalTitle();
|
|
565
637
|
}
|
|
566
638
|
/**
|
|
@@ -573,18 +645,27 @@ async function launchClaudeForMerge(options) {
|
|
|
573
645
|
const prompt = 'IMPORTANT: Your ONLY job is to resolve merge conflicts. Do NOT read TASK_CONTEXT.md or work on any task. Run: git merge origin/main --no-edit. If there are conflicts, resolve them, stage, and commit. Do not use any MCP tools.';
|
|
574
646
|
const mergeLabel = 'Resolving merge conflicts';
|
|
575
647
|
setTerminalTitle(mergeLabel);
|
|
576
|
-
const
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
648
|
+
const hasTmux = await isTmuxAvailable();
|
|
649
|
+
if (hasTmux) {
|
|
650
|
+
await launchInTmux({
|
|
651
|
+
label: mergeLabel,
|
|
652
|
+
command: 'claude',
|
|
653
|
+
args: ['--allowedTools', 'Bash,Read,Write,Edit,Glob,Grep', prompt],
|
|
580
654
|
cwd,
|
|
581
|
-
|
|
582
|
-
env: { ...process.env, DAMPER_API_KEY: apiKey },
|
|
655
|
+
env: { DAMPER_API_KEY: apiKey },
|
|
583
656
|
});
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
657
|
+
}
|
|
658
|
+
else {
|
|
659
|
+
await new Promise((resolve) => {
|
|
660
|
+
const child = spawn('claude', ['--allowedTools', 'Bash,Read,Write,Edit,Glob,Grep', prompt], {
|
|
661
|
+
cwd,
|
|
662
|
+
stdio: 'inherit',
|
|
663
|
+
env: { ...process.env, DAMPER_API_KEY: apiKey },
|
|
664
|
+
});
|
|
665
|
+
child.on('error', () => resolve());
|
|
666
|
+
child.on('close', () => resolve());
|
|
667
|
+
});
|
|
668
|
+
}
|
|
588
669
|
clearTerminalTitle();
|
|
589
670
|
}
|
|
590
671
|
/**
|
package/dist/services/state.d.ts
CHANGED
|
@@ -13,3 +13,10 @@ export declare function removeWorktree(taskId: string): void;
|
|
|
13
13
|
export declare function removeWorktreeByPath(worktreePath: string): void;
|
|
14
14
|
export declare function cleanupStaleWorktrees(): WorktreeState[];
|
|
15
15
|
export declare function getWorktreesForProject(projectRoot: string): WorktreeState[];
|
|
16
|
+
interface Preferences {
|
|
17
|
+
tmuxPromptDismissed?: boolean;
|
|
18
|
+
[key: string]: unknown;
|
|
19
|
+
}
|
|
20
|
+
export declare function getPreference<K extends keyof Preferences>(key: K): Preferences[K];
|
|
21
|
+
export declare function setPreference<K extends keyof Preferences>(key: K, value: Preferences[K]): void;
|
|
22
|
+
export {};
|
package/dist/services/state.js
CHANGED
|
@@ -75,3 +75,28 @@ export function getWorktreesForProject(projectRoot) {
|
|
|
75
75
|
const normalized = path.resolve(projectRoot);
|
|
76
76
|
return readState().worktrees.filter(w => path.resolve(w.projectRoot) === normalized);
|
|
77
77
|
}
|
|
78
|
+
// ── Preferences (persistent user choices) ──
|
|
79
|
+
const PREFS_FILE = path.join(STATE_DIR, 'preferences.json');
|
|
80
|
+
function readPrefs() {
|
|
81
|
+
ensureStateDir();
|
|
82
|
+
if (!fs.existsSync(PREFS_FILE))
|
|
83
|
+
return {};
|
|
84
|
+
try {
|
|
85
|
+
return JSON.parse(fs.readFileSync(PREFS_FILE, 'utf-8'));
|
|
86
|
+
}
|
|
87
|
+
catch {
|
|
88
|
+
return {};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
function writePrefs(prefs) {
|
|
92
|
+
ensureStateDir();
|
|
93
|
+
fs.writeFileSync(PREFS_FILE, JSON.stringify(prefs, null, 2));
|
|
94
|
+
}
|
|
95
|
+
export function getPreference(key) {
|
|
96
|
+
return readPrefs()[key];
|
|
97
|
+
}
|
|
98
|
+
export function setPreference(key, value) {
|
|
99
|
+
const prefs = readPrefs();
|
|
100
|
+
prefs[key] = value;
|
|
101
|
+
writePrefs(prefs);
|
|
102
|
+
}
|