ralphctl 0.1.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 (118) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -0
  4. package/bin/ralphctl +13 -0
  5. package/package.json +92 -0
  6. package/schemas/config.schema.json +20 -0
  7. package/schemas/ideate-output.schema.json +22 -0
  8. package/schemas/projects.schema.json +53 -0
  9. package/schemas/requirements-output.schema.json +24 -0
  10. package/schemas/sprint.schema.json +109 -0
  11. package/schemas/task-import.schema.json +49 -0
  12. package/schemas/tasks.schema.json +72 -0
  13. package/src/ai/executor.ts +973 -0
  14. package/src/ai/lifecycle.ts +45 -0
  15. package/src/ai/parser.ts +40 -0
  16. package/src/ai/permissions.ts +207 -0
  17. package/src/ai/process-manager.ts +248 -0
  18. package/src/ai/prompts/ideate-auto.md +144 -0
  19. package/src/ai/prompts/ideate.md +165 -0
  20. package/src/ai/prompts/index.ts +89 -0
  21. package/src/ai/prompts/plan-auto.md +131 -0
  22. package/src/ai/prompts/plan-common.md +157 -0
  23. package/src/ai/prompts/plan-interactive.md +190 -0
  24. package/src/ai/prompts/task-execution.md +159 -0
  25. package/src/ai/prompts/ticket-refine.md +230 -0
  26. package/src/ai/rate-limiter.ts +89 -0
  27. package/src/ai/runner.ts +478 -0
  28. package/src/ai/session.ts +319 -0
  29. package/src/ai/task-context.ts +270 -0
  30. package/src/cli-metadata.ts +7 -0
  31. package/src/cli.ts +65 -0
  32. package/src/commands/completion/index.ts +33 -0
  33. package/src/commands/config/config.ts +58 -0
  34. package/src/commands/config/index.ts +33 -0
  35. package/src/commands/dashboard/dashboard.ts +5 -0
  36. package/src/commands/dashboard/index.ts +6 -0
  37. package/src/commands/doctor/doctor.ts +271 -0
  38. package/src/commands/doctor/index.ts +25 -0
  39. package/src/commands/progress/index.ts +25 -0
  40. package/src/commands/progress/log.ts +64 -0
  41. package/src/commands/progress/show.ts +14 -0
  42. package/src/commands/project/add.ts +336 -0
  43. package/src/commands/project/index.ts +104 -0
  44. package/src/commands/project/list.ts +31 -0
  45. package/src/commands/project/remove.ts +43 -0
  46. package/src/commands/project/repo.ts +118 -0
  47. package/src/commands/project/show.ts +49 -0
  48. package/src/commands/sprint/close.ts +180 -0
  49. package/src/commands/sprint/context.ts +109 -0
  50. package/src/commands/sprint/create.ts +60 -0
  51. package/src/commands/sprint/current.ts +75 -0
  52. package/src/commands/sprint/delete.ts +72 -0
  53. package/src/commands/sprint/health.ts +229 -0
  54. package/src/commands/sprint/ideate.ts +496 -0
  55. package/src/commands/sprint/index.ts +226 -0
  56. package/src/commands/sprint/list.ts +86 -0
  57. package/src/commands/sprint/plan-utils.ts +207 -0
  58. package/src/commands/sprint/plan.ts +549 -0
  59. package/src/commands/sprint/refine.ts +359 -0
  60. package/src/commands/sprint/requirements.ts +58 -0
  61. package/src/commands/sprint/show.ts +140 -0
  62. package/src/commands/sprint/start.ts +119 -0
  63. package/src/commands/sprint/switch.ts +20 -0
  64. package/src/commands/task/add.ts +316 -0
  65. package/src/commands/task/import.ts +150 -0
  66. package/src/commands/task/index.ts +123 -0
  67. package/src/commands/task/list.ts +145 -0
  68. package/src/commands/task/next.ts +45 -0
  69. package/src/commands/task/remove.ts +47 -0
  70. package/src/commands/task/reorder.ts +45 -0
  71. package/src/commands/task/show.ts +111 -0
  72. package/src/commands/task/status.ts +99 -0
  73. package/src/commands/ticket/add.ts +265 -0
  74. package/src/commands/ticket/edit.ts +166 -0
  75. package/src/commands/ticket/index.ts +114 -0
  76. package/src/commands/ticket/list.ts +128 -0
  77. package/src/commands/ticket/refine-utils.ts +89 -0
  78. package/src/commands/ticket/refine.ts +268 -0
  79. package/src/commands/ticket/remove.ts +48 -0
  80. package/src/commands/ticket/show.ts +74 -0
  81. package/src/completion/handle.ts +30 -0
  82. package/src/completion/resolver.ts +241 -0
  83. package/src/interactive/dashboard.ts +268 -0
  84. package/src/interactive/escapable.ts +81 -0
  85. package/src/interactive/file-browser.ts +153 -0
  86. package/src/interactive/index.ts +429 -0
  87. package/src/interactive/menu.ts +403 -0
  88. package/src/interactive/selectors.ts +273 -0
  89. package/src/interactive/wizard.ts +221 -0
  90. package/src/providers/claude.ts +53 -0
  91. package/src/providers/copilot.ts +86 -0
  92. package/src/providers/index.ts +43 -0
  93. package/src/providers/types.ts +85 -0
  94. package/src/schemas/index.ts +130 -0
  95. package/src/store/config.ts +74 -0
  96. package/src/store/progress.ts +230 -0
  97. package/src/store/project.ts +276 -0
  98. package/src/store/sprint.ts +229 -0
  99. package/src/store/task.ts +443 -0
  100. package/src/store/ticket.ts +178 -0
  101. package/src/theme/index.ts +215 -0
  102. package/src/theme/ui.ts +872 -0
  103. package/src/utils/detect-scripts.ts +247 -0
  104. package/src/utils/editor-input.ts +41 -0
  105. package/src/utils/editor.ts +37 -0
  106. package/src/utils/exit-codes.ts +27 -0
  107. package/src/utils/file-lock.ts +135 -0
  108. package/src/utils/git.ts +185 -0
  109. package/src/utils/ids.ts +37 -0
  110. package/src/utils/issue-fetch.ts +244 -0
  111. package/src/utils/json-extract.ts +62 -0
  112. package/src/utils/multiline.ts +61 -0
  113. package/src/utils/path-selector.ts +236 -0
  114. package/src/utils/paths.ts +108 -0
  115. package/src/utils/provider.ts +34 -0
  116. package/src/utils/requirements-export.ts +63 -0
  117. package/src/utils/storage.ts +107 -0
  118. package/tsconfig.json +25 -0
@@ -0,0 +1,316 @@
1
+ import { resolve } from 'node:path';
2
+ import { confirm, input } from '@inquirer/prompts';
3
+ import { error, muted } from '@src/theme/index.ts';
4
+ import { emoji, field, icons, log, showError, showNextSteps, showSuccess } from '@src/theme/ui.ts';
5
+ import { editorInput } from '@src/utils/editor-input.ts';
6
+ import { validateProjectPath } from '@src/utils/paths.ts';
7
+ import { addTask } from '@src/store/task.ts';
8
+ import { formatTicketDisplay, getTicket, listTickets } from '@src/store/ticket.ts';
9
+ import { getProject, listProjects } from '@src/store/project.ts';
10
+ import {
11
+ assertSprintStatus,
12
+ getSprint,
13
+ NoCurrentSprintError,
14
+ resolveSprintId,
15
+ SprintStatusError,
16
+ } from '@src/store/sprint.ts';
17
+ import { EXIT_ERROR, exitWithCode } from '@src/utils/exit-codes.ts';
18
+ import { selectProjectRepository } from '@src/interactive/selectors.ts';
19
+
20
+ export interface TaskAddOptions {
21
+ name?: string;
22
+ description?: string;
23
+ steps?: string[];
24
+ ticket?: string;
25
+ project?: string;
26
+ interactive?: boolean; // Set by REPL or CLI (default true unless --no-interactive)
27
+ }
28
+
29
+ export async function taskAddCommand(options: TaskAddOptions = {}): Promise<void> {
30
+ const isInteractive = options.interactive !== false;
31
+
32
+ // FAIL FAST: Check sprint status before collecting any input
33
+ try {
34
+ const sprintId = await resolveSprintId();
35
+ const sprint = await getSprint(sprintId);
36
+ assertSprintStatus(sprint, ['draft'], 'add tasks');
37
+ } catch (err) {
38
+ if (err instanceof SprintStatusError) {
39
+ // Show only the main error, not the built-in hint
40
+ const mainError = err.message.split('\n')[0] ?? err.message;
41
+ showError(mainError);
42
+ showNextSteps([
43
+ ['ralphctl sprint close', 'close current sprint'],
44
+ ['ralphctl sprint create', 'start a new draft sprint'],
45
+ ]);
46
+ log.newline();
47
+ if (!isInteractive) exitWithCode(EXIT_ERROR);
48
+ return;
49
+ }
50
+ if (err instanceof NoCurrentSprintError) {
51
+ showError('No current sprint set.');
52
+ showNextSteps([['ralphctl sprint create', 'create a new sprint']]);
53
+ log.newline();
54
+ if (!isInteractive) exitWithCode(EXIT_ERROR);
55
+ return;
56
+ }
57
+ throw err;
58
+ }
59
+
60
+ let name: string;
61
+ let description: string | undefined;
62
+ let steps: string[];
63
+ let ticketId: string | undefined;
64
+ let projectPath: string | undefined;
65
+
66
+ if (options.interactive === false) {
67
+ // Non-interactive mode: validate required params
68
+ const errors: string[] = [];
69
+ const trimmedName = options.name?.trim();
70
+ const trimmedProject = options.project?.trim();
71
+
72
+ if (!trimmedName) {
73
+ errors.push('--name is required');
74
+ }
75
+
76
+ // Project is required unless we can get it from a ticket
77
+ if (!trimmedProject && !options.ticket) {
78
+ errors.push('--project is required (or --ticket to inherit from ticket)');
79
+ }
80
+
81
+ if (errors.length > 0 || !trimmedName) {
82
+ showError('Validation failed');
83
+ for (const e of errors) {
84
+ log.item(error(e));
85
+ }
86
+ log.newline();
87
+ exitWithCode(EXIT_ERROR);
88
+ }
89
+
90
+ name = trimmedName;
91
+ const trimmedDesc = options.description?.trim();
92
+ description = trimmedDesc === '' ? undefined : trimmedDesc;
93
+ steps = options.steps ?? [];
94
+ const trimmedTicket = options.ticket?.trim();
95
+ ticketId = trimmedTicket === '' ? undefined : trimmedTicket;
96
+
97
+ // Get project path from ticket or option
98
+ if (ticketId) {
99
+ try {
100
+ const ticket = await getTicket(ticketId);
101
+ // Get first repository path from project
102
+ const project = await getProject(ticket.projectName);
103
+ projectPath = project.repositories[0]?.path;
104
+ } catch {
105
+ if (!trimmedProject) {
106
+ showError(`Ticket not found: ${ticketId}`);
107
+ console.log(muted(' Provide --project or a valid --ticket\n'));
108
+ exitWithCode(EXIT_ERROR);
109
+ }
110
+ const validation = await validateProjectPath(trimmedProject);
111
+ if (validation !== true) {
112
+ showError(`Invalid project path: ${validation}`);
113
+ exitWithCode(EXIT_ERROR);
114
+ }
115
+ projectPath = resolve(trimmedProject);
116
+ }
117
+ } else if (trimmedProject) {
118
+ const validation = await validateProjectPath(trimmedProject);
119
+ if (validation !== true) {
120
+ showError(`Invalid project path: ${validation}`);
121
+ exitWithCode(EXIT_ERROR);
122
+ }
123
+ projectPath = resolve(trimmedProject);
124
+ } else {
125
+ // This shouldn't happen due to earlier validation
126
+ showError('--project is required');
127
+ exitWithCode(EXIT_ERROR);
128
+ }
129
+ } else {
130
+ // Interactive mode (default): prompt for missing params, use provided values as defaults
131
+ name = await input({
132
+ message: `${icons.task} Task name:`,
133
+ default: options.name?.trim(),
134
+ validate: (v) => (v.trim().length > 0 ? true : 'Name is required'),
135
+ });
136
+
137
+ description = await editorInput({
138
+ message: 'Description (optional):',
139
+ default: options.description?.trim(),
140
+ });
141
+
142
+ // Add steps one by one
143
+ steps = options.steps ? [...options.steps] : [];
144
+ const addSteps = await confirm({
145
+ message: `${emoji.donut} ${steps.length > 0 ? `Add more steps? (${String(steps.length)} pre-filled)` : 'Add implementation steps?'}`,
146
+ default: steps.length === 0,
147
+ });
148
+
149
+ if (addSteps) {
150
+ let stepNum = steps.length + 1;
151
+ let adding = true;
152
+ while (adding) {
153
+ const step = await input({
154
+ message: ` Step ${String(stepNum)} (empty to finish):`,
155
+ });
156
+ if (step.trim()) {
157
+ steps.push(step.trim());
158
+ stepNum++;
159
+ } else {
160
+ adding = false;
161
+ }
162
+ }
163
+ }
164
+
165
+ // Optionally link to a ticket
166
+ const tickets = await listTickets();
167
+
168
+ if (tickets.length > 0) {
169
+ const { select } = await import('@inquirer/prompts');
170
+ const defaultTicketValue = options.ticket ? (tickets.find((t) => t.id === options.ticket)?.id ?? '') : '';
171
+ const ticketChoice = await select({
172
+ message: `${icons.ticket} Link to ticket:`,
173
+ default: defaultTicketValue,
174
+ choices: [
175
+ { name: `${emoji.donut} None (select project/repo manually)`, value: '' },
176
+ ...tickets.map((t) => ({
177
+ name: `${icons.ticket} ${formatTicketDisplay(t)} ${muted(`(${t.projectName})`)}`,
178
+ value: t.id,
179
+ })),
180
+ ],
181
+ });
182
+ if (ticketChoice) {
183
+ ticketId = ticketChoice;
184
+ const ticket = tickets.find((t) => t.id === ticketChoice);
185
+ if (ticket) {
186
+ try {
187
+ const project = await getProject(ticket.projectName);
188
+ // Auto-select first repo for ticket, or prompt if multiple
189
+ if (project.repositories.length === 1) {
190
+ projectPath = project.repositories[0]?.path;
191
+ } else {
192
+ // Multiple repos - let user pick
193
+ const { select: selectRepo } = await import('@inquirer/prompts');
194
+ projectPath = await selectRepo({
195
+ message: `${emoji.donut} Select repository for this task:`,
196
+ choices: project.repositories.map((r) => ({
197
+ name: `${r.name} (${r.path})`,
198
+ value: r.path,
199
+ })),
200
+ });
201
+ }
202
+ } catch {
203
+ log.warn(`Project '${ticket.projectName}' not found, will prompt for path.`);
204
+ }
205
+ }
206
+ }
207
+ } else if (options.ticket) {
208
+ ticketId = options.ticket;
209
+ try {
210
+ const ticket = await getTicket(ticketId);
211
+ const project = await getProject(ticket.projectName);
212
+ projectPath = project.repositories[0]?.path;
213
+ } catch {
214
+ // Will prompt for project below
215
+ }
216
+ }
217
+
218
+ // If no project from ticket, use two-step selector
219
+ if (projectPath === undefined) {
220
+ const projects = await listProjects();
221
+
222
+ if (projects.length > 0) {
223
+ const { select } = await import('@inquirer/prompts');
224
+ const choice = await select({
225
+ message: `${icons.project} Select project:`,
226
+ choices: [
227
+ { name: `${icons.edit} Enter path manually`, value: '__manual__' },
228
+ { name: `${emoji.donut} Select project/repository`, value: '__select__' },
229
+ ],
230
+ });
231
+
232
+ if (choice === '__manual__') {
233
+ projectPath = await input({
234
+ message: `${icons.project} Project path:`,
235
+ default: options.project?.trim() ?? process.cwd(),
236
+ validate: async (v) => {
237
+ const result = await validateProjectPath(v.trim());
238
+ return result;
239
+ },
240
+ });
241
+ projectPath = resolve(projectPath.trim());
242
+ } else {
243
+ // Two-step selector: project → repository
244
+ const selectedPath = await selectProjectRepository('Select repository:');
245
+ if (!selectedPath) {
246
+ showError('No repository selected');
247
+ exitWithCode(EXIT_ERROR);
248
+ }
249
+ projectPath = selectedPath;
250
+ }
251
+ } else {
252
+ projectPath = await input({
253
+ message: `${icons.project} Project path:`,
254
+ default: options.project?.trim() ?? process.cwd(),
255
+ validate: async (v) => {
256
+ const result = await validateProjectPath(v.trim());
257
+ return result;
258
+ },
259
+ });
260
+ projectPath = resolve(projectPath.trim());
261
+ }
262
+ }
263
+
264
+ name = name.trim();
265
+ const trimmedDescription = description.trim();
266
+ description = trimmedDescription === '' ? undefined : trimmedDescription;
267
+ }
268
+
269
+ // projectPath must be set by this point
270
+ if (!projectPath) {
271
+ showError('Project path is required');
272
+ exitWithCode(EXIT_ERROR);
273
+ }
274
+
275
+ try {
276
+ const task = await addTask({
277
+ name,
278
+ description,
279
+ steps,
280
+ ticketId,
281
+ projectPath,
282
+ });
283
+
284
+ showSuccess('Task added!', [
285
+ ['ID', task.id],
286
+ ['Name', task.name],
287
+ ['Project', task.projectPath],
288
+ ['Order', String(task.order)],
289
+ ]);
290
+
291
+ if (task.ticketId) {
292
+ console.log(field('Ticket', task.ticketId));
293
+ }
294
+ if (task.steps.length > 0) {
295
+ console.log(field('Steps', ''));
296
+ task.steps.forEach((step, i) => {
297
+ console.log(muted(` ${String(i + 1)}. ${step}`));
298
+ });
299
+ }
300
+ console.log('');
301
+ } catch (err) {
302
+ if (err instanceof SprintStatusError) {
303
+ // Fallback handler (shouldn't reach here due to early check)
304
+ const mainError = err.message.split('\n')[0] ?? err.message;
305
+ showError(mainError);
306
+ showNextSteps([
307
+ ['ralphctl sprint close', 'close current sprint'],
308
+ ['ralphctl sprint create', 'start a new draft sprint'],
309
+ ]);
310
+ log.newline();
311
+ if (!isInteractive) exitWithCode(EXIT_ERROR);
312
+ return;
313
+ }
314
+ throw err;
315
+ }
316
+ }
@@ -0,0 +1,150 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { error, muted } from '@src/theme/index.ts';
3
+ import { createSpinner, log, showError, showNextStep } from '@src/theme/ui.ts';
4
+ import { addTask, getTasks, saveTasks, validateImportTasks } from '@src/store/task.ts';
5
+ import { getSprint, resolveSprintId, SprintStatusError } from '@src/store/sprint.ts';
6
+ import { ImportTasksSchema } from '@src/schemas/index.ts';
7
+ import { withFileLock } from '@src/utils/file-lock.ts';
8
+ import { getTasksFilePath } from '@src/utils/paths.ts';
9
+
10
+ export async function taskImportCommand(args: string[]): Promise<void> {
11
+ const filePath = args[0];
12
+
13
+ if (!filePath) {
14
+ showError('File path required.');
15
+ showNextStep('ralphctl task import <file.json>', 'provide a task file');
16
+ log.dim('Expected JSON format:');
17
+ console.log(
18
+ muted(`[
19
+ {
20
+ "id": "1",
21
+ "name": "Task name",
22
+ "projectPath": "/path/to/repo",
23
+ "description": "Optional description",
24
+ "steps": ["Step 1", "Step 2"],
25
+ "ticketId": "abc12345",
26
+ "blockedBy": ["task-001"]
27
+ }
28
+ ]`)
29
+ );
30
+ log.dim('Note: projectPath is required for each task.');
31
+ log.newline();
32
+ return;
33
+ }
34
+
35
+ let content: string;
36
+ try {
37
+ content = await readFile(filePath, 'utf-8');
38
+ } catch {
39
+ showError(`Failed to read file: ${filePath}`);
40
+ log.newline();
41
+ return;
42
+ }
43
+
44
+ let data: unknown;
45
+ try {
46
+ data = JSON.parse(content);
47
+ } catch {
48
+ showError('Invalid JSON format.');
49
+ log.newline();
50
+ return;
51
+ }
52
+
53
+ const result = ImportTasksSchema.safeParse(data);
54
+ if (!result.success) {
55
+ showError('Invalid task format');
56
+ for (const issue of result.error.issues) {
57
+ log.item(error(`${issue.path.join('.')}: ${issue.message}`));
58
+ }
59
+ log.newline();
60
+ return;
61
+ }
62
+
63
+ const tasks = result.data;
64
+ if (tasks.length === 0) {
65
+ showError('No tasks to import.');
66
+ log.newline();
67
+ return;
68
+ }
69
+
70
+ // Validate dependencies and ticketId references before importing
71
+ const existingTasks = await getTasks();
72
+ const sprintId = await resolveSprintId();
73
+ const sprint = await getSprint(sprintId);
74
+ const ticketIds = new Set(sprint.tickets.map((t) => t.id));
75
+ const validationErrors = validateImportTasks(tasks, existingTasks, ticketIds);
76
+ if (validationErrors.length > 0) {
77
+ showError('Dependency validation failed');
78
+ for (const err of validationErrors) {
79
+ log.item(error(err));
80
+ }
81
+ log.newline();
82
+ return;
83
+ }
84
+
85
+ // Build local ID to real ID mapping
86
+ const localToRealId = new Map<string, string>();
87
+ const createdTasks: { task: (typeof tasks)[0]; realId: string }[] = [];
88
+
89
+ // First pass: create tasks without blockedBy
90
+ const spinner = createSpinner(`Importing ${String(tasks.length)} task(s)...`).start();
91
+ let imported = 0;
92
+ for (const taskInput of tasks) {
93
+ try {
94
+ // projectPath is required from the schema
95
+ const task = await addTask({
96
+ name: taskInput.name,
97
+ description: taskInput.description,
98
+ steps: taskInput.steps ?? [],
99
+ ticketId: taskInput.ticketId,
100
+ blockedBy: [], // Set later
101
+ projectPath: taskInput.projectPath,
102
+ });
103
+
104
+ // Map local ID to real ID
105
+ if (taskInput.id) {
106
+ localToRealId.set(taskInput.id, task.id);
107
+ }
108
+
109
+ createdTasks.push({ task: taskInput, realId: task.id });
110
+ imported++;
111
+ spinner.text = `Importing tasks... (${String(imported)}/${String(tasks.length)})`;
112
+ } catch (err) {
113
+ if (err instanceof SprintStatusError) {
114
+ spinner.fail('Import failed');
115
+ showError(err.message);
116
+ log.newline();
117
+ return;
118
+ }
119
+ log.itemError(`Failed to add: ${taskInput.name}`);
120
+ if (err instanceof Error) {
121
+ console.log(muted(` ${err.message}`));
122
+ }
123
+ }
124
+ }
125
+
126
+ // Second pass: update blockedBy with resolved real IDs (under file lock)
127
+ spinner.text = 'Resolving task dependencies...';
128
+ const tasksFilePath = getTasksFilePath(sprintId);
129
+ await withFileLock(tasksFilePath, async () => {
130
+ const allTasks = await getTasks();
131
+ for (const { task: taskInput, realId } of createdTasks) {
132
+ const blockedBy = (taskInput.blockedBy ?? [])
133
+ .map((localId) => localToRealId.get(localId) ?? '')
134
+ .filter((id) => id !== '');
135
+
136
+ if (blockedBy.length > 0) {
137
+ const taskToUpdate = allTasks.find((t) => t.id === realId);
138
+ if (taskToUpdate) {
139
+ taskToUpdate.blockedBy = blockedBy;
140
+ }
141
+ }
142
+ }
143
+ await saveTasks(allTasks);
144
+ });
145
+
146
+ spinner.succeed(`Imported ${String(imported)}/${String(tasks.length)} tasks`);
147
+ for (const { task: taskInput, realId } of createdTasks) {
148
+ log.itemSuccess(`${realId}: ${taskInput.name}`);
149
+ }
150
+ }
@@ -0,0 +1,123 @@
1
+ import type { Command } from 'commander';
2
+ import { taskAddCommand } from '@src/commands/task/add.ts';
3
+ import { taskListCommand } from '@src/commands/task/list.ts';
4
+ import { taskShowCommand } from '@src/commands/task/show.ts';
5
+ import { taskRemoveCommand } from '@src/commands/task/remove.ts';
6
+ import { taskStatusCommand } from '@src/commands/task/status.ts';
7
+ import { taskNextCommand } from '@src/commands/task/next.ts';
8
+ import { taskReorderCommand } from '@src/commands/task/reorder.ts';
9
+ import { taskImportCommand } from '@src/commands/task/import.ts';
10
+
11
+ export function registerTaskCommands(program: Command): void {
12
+ const task = program.command('task').description('Manage tasks');
13
+
14
+ task.addHelpText(
15
+ 'after',
16
+ `
17
+ Examples:
18
+ $ ralphctl task add --name "Implement login" --ticket abc123
19
+ $ ralphctl task list
20
+ $ ralphctl task status abc123 done
21
+ $ ralphctl task next
22
+ `
23
+ );
24
+
25
+ task
26
+ .command('add')
27
+ .description('Add task to current sprint')
28
+ .option('--name <name>', 'Task name')
29
+ .option('-d, --description <desc>', 'Description')
30
+ .option('--step <step...>', 'Implementation step (repeatable)')
31
+ .option('--ticket <id>', 'Link to ticket ID')
32
+ .option('-p, --project <path>', 'Project path')
33
+ .option('-n, --no-interactive', 'Non-interactive mode (error on missing params)')
34
+ .action(
35
+ async (opts: {
36
+ name?: string;
37
+ description?: string;
38
+ step?: string[];
39
+ ticket?: string;
40
+ project?: string;
41
+ interactive?: boolean;
42
+ }) => {
43
+ await taskAddCommand({
44
+ name: opts.name,
45
+ description: opts.description,
46
+ steps: opts.step,
47
+ ticket: opts.ticket,
48
+ project: opts.project,
49
+ // --no-interactive sets interactive=false, otherwise true (prompt for missing)
50
+ interactive: opts.interactive !== false,
51
+ });
52
+ }
53
+ );
54
+
55
+ task
56
+ .command('import <file>')
57
+ .description('Import tasks from JSON file')
58
+ .action(async (file: string) => {
59
+ await taskImportCommand([file]);
60
+ });
61
+
62
+ task
63
+ .command('list')
64
+ .description('List tasks')
65
+ .option('-b, --brief', 'Brief format')
66
+ .option('--status <status>', 'Filter by status (todo, in_progress, done)')
67
+ .option('--project <name>', 'Filter by project path')
68
+ .option('--ticket <id>', 'Filter by ticket ID')
69
+ .option('--blocked', 'Show only blocked tasks')
70
+ .action(
71
+ async (opts: { brief?: boolean; status?: string; project?: string; ticket?: string; blocked?: boolean }) => {
72
+ const args: string[] = [];
73
+ if (opts.brief) args.push('-b');
74
+ if (opts.status) args.push('--status', opts.status);
75
+ if (opts.project) args.push('--project', opts.project);
76
+ if (opts.ticket) args.push('--ticket', opts.ticket);
77
+ if (opts.blocked) args.push('--blocked');
78
+ await taskListCommand(args);
79
+ }
80
+ );
81
+
82
+ task
83
+ .command('show [id]')
84
+ .description('Show task details')
85
+ .action(async (id?: string) => {
86
+ await taskShowCommand(id ? [id] : []);
87
+ });
88
+
89
+ task
90
+ .command('remove [id]')
91
+ .description('Remove a task')
92
+ .option('-y, --yes', 'Skip confirmation')
93
+ .action(async (id?: string, opts?: { yes?: boolean }) => {
94
+ const args: string[] = [];
95
+ if (id) args.push(id);
96
+ if (opts?.yes) args.push('-y');
97
+ await taskRemoveCommand(args);
98
+ });
99
+
100
+ task
101
+ .command('status [id] [status]')
102
+ .description('Update task status (todo/in_progress/done)')
103
+ .option('-n, --no-interactive', 'Non-interactive mode (exit with error codes)')
104
+ .action(async (id?: string, status?: string, opts?: { interactive?: boolean }) => {
105
+ await taskStatusCommand([], {
106
+ taskId: id,
107
+ status,
108
+ noInteractive: opts?.interactive === false,
109
+ });
110
+ });
111
+
112
+ task.command('next').description('Get next task').action(taskNextCommand);
113
+
114
+ task
115
+ .command('reorder [id] [position]')
116
+ .description('Change task priority')
117
+ .action(async (id?: string, position?: string) => {
118
+ const args: string[] = [];
119
+ if (id) args.push(id);
120
+ if (position) args.push(position);
121
+ await taskReorderCommand(args);
122
+ });
123
+ }