@damper/cli 0.4.2 → 0.5.1

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.
@@ -72,6 +72,15 @@ export async function cleanupCommand() {
72
72
  hasUncommittedChanges: uncommitted,
73
73
  });
74
74
  }
75
+ else if (task.status === 'in_progress') {
76
+ // Task is still in progress - can drop it
77
+ candidates.push({
78
+ worktree: wt,
79
+ task,
80
+ reason: 'in_progress',
81
+ hasUncommittedChanges: uncommitted,
82
+ });
83
+ }
75
84
  }
76
85
  catch {
77
86
  // Could not fetch task - might be deleted
@@ -92,7 +101,7 @@ export async function cleanupCommand() {
92
101
  }
93
102
  }
94
103
  if (candidates.length === 0) {
95
- console.log(pc.green('\n✓ All worktrees are for active tasks. Nothing to clean up.\n'));
104
+ console.log(pc.green('\n✓ No worktrees to clean up.\n'));
96
105
  return;
97
106
  }
98
107
  // Show candidates and let user select
@@ -111,6 +120,9 @@ export async function cleanupCommand() {
111
120
  case 'abandoned':
112
121
  reasonBadge = pc.yellow('[abandoned]');
113
122
  break;
123
+ case 'in_progress':
124
+ reasonBadge = pc.cyan('[in progress - will release]');
125
+ break;
114
126
  case 'missing':
115
127
  reasonBadge = pc.red('[missing]');
116
128
  break;
@@ -161,6 +173,18 @@ export async function cleanupCommand() {
161
173
  const { worktree } = candidate;
162
174
  const worktreeName = worktree.path.split('/').pop() || worktree.path;
163
175
  try {
176
+ // Release the task first if it's in progress
177
+ if (candidate.reason === 'in_progress' && api) {
178
+ try {
179
+ await api.abandonTask(worktree.taskId, 'Dropped via CLI cleanup');
180
+ console.log(pc.green(`✓ Released task #${worktree.taskId}`));
181
+ }
182
+ catch (err) {
183
+ const error = err;
184
+ console.log(pc.yellow(`⚠ Could not release task: ${error.message}`));
185
+ // Continue with worktree removal anyway
186
+ }
187
+ }
164
188
  if (candidate.reason === 'missing') {
165
189
  // Just remove from state
166
190
  removeWorktree(worktree.taskId);
@@ -0,0 +1 @@
1
+ export declare function releaseCommand(): Promise<void>;
@@ -0,0 +1,95 @@
1
+ import { confirm, select } from '@inquirer/prompts';
2
+ import pc from 'picocolors';
3
+ import { createDamperApi } from '../services/damper-api.js';
4
+ import { getWorktrees, removeWorktree } from '../services/state.js';
5
+ import { removeWorktreeDir, getGitRoot } from '../services/worktree.js';
6
+ import { getApiKey } from '../services/config.js';
7
+ export async function releaseCommand() {
8
+ // Get project root
9
+ let projectRoot;
10
+ try {
11
+ projectRoot = await getGitRoot(process.cwd());
12
+ }
13
+ catch {
14
+ console.log(pc.red('\nError: Not in a git repository.\n'));
15
+ process.exit(1);
16
+ }
17
+ // Get API key
18
+ const apiKey = getApiKey(projectRoot);
19
+ if (!apiKey) {
20
+ console.log(pc.yellow('\nProject not configured.'));
21
+ console.log(pc.dim(`Run ${pc.cyan('npx @damper/cli setup')} first.\n`));
22
+ process.exit(1);
23
+ }
24
+ const api = createDamperApi(apiKey);
25
+ // Get tasks that are in_progress (locked)
26
+ const { tasks, project } = await api.listTasks({ status: 'in_progress' });
27
+ if (tasks.length === 0) {
28
+ console.log(pc.yellow('\nNo in-progress tasks to release.\n'));
29
+ return;
30
+ }
31
+ console.log(pc.bold(`\nProject: ${project}`));
32
+ console.log(pc.dim('Select a task to release back to planned status.\n'));
33
+ // Build choices
34
+ const worktrees = getWorktrees();
35
+ const choices = tasks.map(task => {
36
+ const worktree = worktrees.find(w => w.taskId === task.id);
37
+ const lockedInfo = task.lockedBy ? pc.dim(` (locked by ${task.lockedBy})`) : '';
38
+ const worktreeInfo = worktree ? pc.dim(` [has worktree]`) : '';
39
+ return {
40
+ name: `#${task.id} ${task.title}${lockedInfo}${worktreeInfo}`,
41
+ value: { task, worktree },
42
+ };
43
+ });
44
+ const selected = await select({
45
+ message: 'Select task to release:',
46
+ choices,
47
+ });
48
+ const { task, worktree } = selected;
49
+ // Confirm
50
+ console.log();
51
+ console.log(pc.yellow(`This will:`));
52
+ console.log(pc.dim(` • Release the lock on task #${task.id}`));
53
+ console.log(pc.dim(` • Set task status back to "planned"`));
54
+ if (worktree) {
55
+ console.log(pc.dim(` • Optionally remove the worktree`));
56
+ }
57
+ console.log();
58
+ const shouldRelease = await confirm({
59
+ message: 'Release this task?',
60
+ default: true,
61
+ });
62
+ if (!shouldRelease) {
63
+ console.log(pc.dim('\nCancelled.\n'));
64
+ return;
65
+ }
66
+ // Abandon the task in Damper
67
+ try {
68
+ await api.abandonTask(task.id, 'Released via CLI');
69
+ console.log(pc.green(`\n✓ Task #${task.id} released to planned status`));
70
+ }
71
+ catch (err) {
72
+ const error = err;
73
+ console.log(pc.red(`\nFailed to release task: ${error.message}\n`));
74
+ return;
75
+ }
76
+ // Offer to remove worktree if exists
77
+ if (worktree) {
78
+ const shouldRemoveWorktree = await confirm({
79
+ message: 'Remove the worktree and branch?',
80
+ default: false,
81
+ });
82
+ if (shouldRemoveWorktree) {
83
+ try {
84
+ await removeWorktreeDir(worktree.path, worktree.projectRoot);
85
+ console.log(pc.green('✓ Worktree and branch removed'));
86
+ }
87
+ catch (err) {
88
+ const error = err;
89
+ console.log(pc.yellow(`Could not remove worktree: ${error.message}`));
90
+ removeWorktree(task.id); // At least clean up state
91
+ }
92
+ }
93
+ }
94
+ console.log();
95
+ }
@@ -3,5 +3,6 @@ export interface StartOptions {
3
3
  type?: 'bug' | 'feature' | 'improvement' | 'task';
4
4
  status?: 'planned' | 'in_progress' | 'done' | 'all';
5
5
  yolo?: boolean;
6
+ force?: boolean;
6
7
  }
7
8
  export declare function startCommand(options: StartOptions): Promise<void>;
@@ -57,6 +57,7 @@ export async function startCommand(options) {
57
57
  let taskTitle;
58
58
  let isResume = false;
59
59
  let worktreePath;
60
+ let forceTakeover = options.force || false;
60
61
  if (options.taskId) {
61
62
  // Direct task selection
62
63
  taskId = options.taskId;
@@ -87,6 +88,7 @@ export async function startCommand(options) {
87
88
  taskId = result.task.id;
88
89
  taskTitle = result.task.title;
89
90
  isResume = result.isResume;
91
+ forceTakeover = result.forceTakeover || options.force || false;
90
92
  if (result.worktree) {
91
93
  worktreePath = result.worktree.path;
92
94
  }
@@ -108,14 +110,14 @@ export async function startCommand(options) {
108
110
  // New task - lock it first
109
111
  console.log(pc.dim('\nLocking task in Damper...'));
110
112
  try {
111
- await api.startTask(taskId);
112
- console.log(pc.green('✓ Task locked'));
113
+ await api.startTask(taskId, forceTakeover);
114
+ console.log(pc.green(forceTakeover ? '✓ Task lock taken over' : '✓ Task locked'));
113
115
  }
114
116
  catch (err) {
115
117
  const error = err;
116
118
  if (error.lockInfo) {
117
119
  console.log(pc.yellow(`\n⚠️ Task is locked by "${error.lockInfo.lockedBy}" since ${error.lockInfo.lockedAt}`));
118
- console.log(pc.dim('Use --force to take over the lock (if the other agent abandoned it).\n'));
120
+ console.log(pc.dim('Use --force to take over the lock.\n'));
119
121
  process.exit(1);
120
122
  }
121
123
  throw err;
package/dist/index.js CHANGED
@@ -4,7 +4,8 @@ import { startCommand } from './commands/start.js';
4
4
  import { statusCommand } from './commands/status.js';
5
5
  import { cleanupCommand } from './commands/cleanup.js';
6
6
  import { setupCommand } from './commands/setup.js';
7
- const VERSION = '0.4.2';
7
+ import { releaseCommand } from './commands/release.js';
8
+ const VERSION = '0.5.1';
8
9
  function showHelp() {
9
10
  console.log(`
10
11
  ${pc.bold('@damper/cli')} - Agent orchestration for Damper tasks
@@ -13,12 +14,14 @@ ${pc.bold('Usage:')}
13
14
  npx @damper/cli Start working on a task (interactive picker)
14
15
  npx @damper/cli setup Configure Damper MCP and API key
15
16
  npx @damper/cli status Show all in-progress worktrees
16
- npx @damper/cli cleanup Remove worktrees for completed tasks
17
+ npx @damper/cli cleanup Remove worktrees (completed, abandoned, or in-progress)
18
+ npx @damper/cli release Release a task back to planned status
17
19
 
18
20
  ${pc.bold('Options:')}
19
21
  --task <id> Work on a specific task
20
22
  --type <type> Filter by type: bug, feature, improvement, task
21
23
  --status <status> Filter by status: planned, in_progress, done, all
24
+ --force Take over a task locked by another agent
22
25
  --yolo Skip plan mode, auto-accept edits
23
26
  --reconfigure Change API key (use with setup command)
24
27
  -h, --help Show this help message
@@ -84,6 +87,9 @@ function parseArgs(args) {
84
87
  else if (arg === '--yolo') {
85
88
  result.options.yolo = true;
86
89
  }
90
+ else if (arg === '--force') {
91
+ result.options.force = true;
92
+ }
87
93
  else if (!arg.startsWith('-') && !result.command) {
88
94
  result.command = arg;
89
95
  }
@@ -112,6 +118,9 @@ async function main() {
112
118
  case 'cleanup':
113
119
  await cleanupCommand();
114
120
  break;
121
+ case 'release':
122
+ await releaseCommand();
123
+ break;
115
124
  case undefined:
116
125
  case 'start':
117
126
  await startCommand(options);
@@ -1,6 +1,7 @@
1
1
  import * as fs from 'node:fs';
2
2
  import * as path from 'node:path';
3
3
  import * as os from 'node:os';
4
+ import { spawn } from 'node:child_process';
4
5
  import { execa } from 'execa';
5
6
  import pc from 'picocolors';
6
7
  const CLAUDE_SETTINGS_DIR = path.join(os.homedir(), '.claude');
@@ -148,24 +149,29 @@ Once approved, switch to implementation and start logging your work.
148
149
  '--mcp-config', mcpConfigPath,
149
150
  initialPrompt,
150
151
  ];
151
- // Launch Claude Code in interactive mode
152
+ // Launch Claude Code in interactive mode using spawn for better TTY handling
152
153
  try {
153
- await execa('claude', claudeArgs, {
154
- cwd,
155
- stdio: 'inherit',
156
- env: {
157
- ...process.env,
158
- DAMPER_API_KEY: apiKey,
159
- },
154
+ await new Promise((resolve, reject) => {
155
+ const child = spawn('claude', claudeArgs, {
156
+ cwd,
157
+ stdio: 'inherit',
158
+ env: {
159
+ ...process.env,
160
+ DAMPER_API_KEY: apiKey,
161
+ },
162
+ });
163
+ child.on('close', () => resolve());
164
+ child.on('error', (err) => {
165
+ if (err.code === 'ENOENT') {
166
+ console.log(pc.red('\nError: Claude Code CLI not found.'));
167
+ console.log(pc.dim('Install it with: npm install -g @anthropic-ai/claude-code\n'));
168
+ process.exit(1);
169
+ }
170
+ reject(err);
171
+ });
160
172
  });
161
173
  }
162
- catch (err) {
163
- const error = err;
164
- if (error.code === 'ENOENT') {
165
- console.log(pc.red('\nError: Claude Code CLI not found.'));
166
- console.log(pc.dim('Install it with: npm install -g @anthropic-ai/claude-code\n'));
167
- process.exit(1);
168
- }
174
+ catch {
169
175
  // Claude exited with error - still continue to post-task flow
170
176
  }
171
177
  finally {
@@ -110,6 +110,11 @@ export declare class DamperApi {
110
110
  }>;
111
111
  getTask(taskId: string): Promise<TaskDetail>;
112
112
  startTask(taskId: string, force?: boolean): Promise<StartTaskResult>;
113
+ abandonTask(taskId: string, summary?: string): Promise<{
114
+ id: string;
115
+ status: string;
116
+ message: string;
117
+ }>;
113
118
  getProjectContext(taskId?: string): Promise<ContextIndex>;
114
119
  getContextSection(section: string): Promise<ContextSection | {
115
120
  pattern: string;
@@ -50,6 +50,9 @@ export class DamperApi {
50
50
  async startTask(taskId, force) {
51
51
  return this.request('POST', `/api/agent/tasks/${taskId}/start`, force ? { force: true } : undefined);
52
52
  }
53
+ async abandonTask(taskId, summary) {
54
+ return this.request('POST', `/api/agent/tasks/${taskId}/abandon`, summary ? { summary } : undefined);
55
+ }
53
56
  // Project Context
54
57
  async getProjectContext(taskId) {
55
58
  const params = taskId ? `?task_id=${taskId}` : '';
@@ -10,6 +10,7 @@ interface TaskPickerResult {
10
10
  task: Task;
11
11
  worktree?: WorktreeState;
12
12
  isResume: boolean;
13
+ forceTakeover?: boolean;
13
14
  }
14
15
  export declare function pickTask(options: TaskPickerOptions): Promise<TaskPickerResult | null>;
15
16
  export {};
@@ -1,4 +1,4 @@
1
- import { select, Separator } from '@inquirer/prompts';
1
+ import { select, confirm, Separator } from '@inquirer/prompts';
2
2
  import pc from 'picocolors';
3
3
  function getTypeIcon(type) {
4
4
  switch (type) {
@@ -30,6 +30,11 @@ function formatTaskChoice(choice) {
30
30
  }
31
31
  return description;
32
32
  }
33
+ if (choice.type === 'locked') {
34
+ let description = `${pc.dim('#')}${pc.cyan(task.id)} ${task.title} ${pc.dim(`[${typeIcon}]`)} ${priorityIcon}`;
35
+ description += pc.yellow(` [locked by ${choice.lockedBy}]`);
36
+ return description;
37
+ }
33
38
  // Available task
34
39
  let description = `${pc.dim('#')}${pc.cyan(task.id)} ${task.title} ${pc.dim(`[${typeIcon}]`)} ${priorityIcon}`;
35
40
  if (task.quarter) {
@@ -70,11 +75,26 @@ export async function pickTask(options) {
70
75
  });
71
76
  }
72
77
  }
73
- // Available tasks are those not in our worktrees and either planned or in_progress
74
- const availableChoices = availableTasks
75
- .filter(t => !worktreeTaskIds.has(t.id) && (t.status === 'planned' || t.status === 'in_progress'))
76
- .map(task => ({ type: 'available', task }));
77
- if (inProgressChoices.length === 0 && availableChoices.length === 0) {
78
+ // Separate available tasks from locked tasks
79
+ const availableChoices = [];
80
+ const lockedChoices = [];
81
+ for (const task of availableTasks) {
82
+ if (worktreeTaskIds.has(task.id))
83
+ continue;
84
+ const taskAny = task;
85
+ if (task.status === 'in_progress' && taskAny.lockedBy) {
86
+ // Task is locked by someone else
87
+ lockedChoices.push({
88
+ type: 'locked',
89
+ task,
90
+ lockedBy: taskAny.lockedBy,
91
+ });
92
+ }
93
+ else if (task.status === 'planned') {
94
+ availableChoices.push({ type: 'available', task });
95
+ }
96
+ }
97
+ if (inProgressChoices.length === 0 && availableChoices.length === 0 && lockedChoices.length === 0) {
78
98
  console.log(pc.yellow('\nNo tasks available.'));
79
99
  if (typeFilter) {
80
100
  console.log(pc.dim(`(filtered by type: ${typeFilter})`));
@@ -101,6 +121,15 @@ export async function pickTask(options) {
101
121
  });
102
122
  }
103
123
  }
124
+ if (lockedChoices.length > 0) {
125
+ choices.push(new Separator(pc.bold('\n--- Locked Tasks (can take over) ---')));
126
+ for (const choice of lockedChoices) {
127
+ choices.push({
128
+ name: formatTaskChoice(choice),
129
+ value: choice,
130
+ });
131
+ }
132
+ }
104
133
  console.log(pc.bold(`\nProject: ${project}`));
105
134
  const selected = await select({
106
135
  message: 'Select a task to work on:',
@@ -114,6 +143,24 @@ export async function pickTask(options) {
114
143
  isResume: true,
115
144
  };
116
145
  }
146
+ if (selected.type === 'locked') {
147
+ // Prompt to take over
148
+ console.log();
149
+ console.log(pc.yellow(`This task is locked by "${selected.lockedBy}".`));
150
+ const shouldTakeover = await confirm({
151
+ message: 'Take over this task?',
152
+ default: true,
153
+ });
154
+ if (!shouldTakeover) {
155
+ console.log(pc.dim('\nCancelled.\n'));
156
+ return null;
157
+ }
158
+ return {
159
+ task: selected.task,
160
+ isResume: false,
161
+ forceTakeover: true,
162
+ };
163
+ }
117
164
  return {
118
165
  task: selected.task,
119
166
  isResume: false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@damper/cli",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
4
4
  "description": "CLI tool for orchestrating Damper task workflows with Claude Code",
5
5
  "author": "Damper <hello@usedamper.com>",
6
6
  "repository": {