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,336 @@
1
+ import { existsSync, statSync } from 'node:fs';
2
+ import { basename, join, resolve } from 'node:path';
3
+ import { input, select } from '@inquirer/prompts';
4
+ import { error, muted } from '@src/theme/index.ts';
5
+ import { validateProjectPath } from '@src/utils/paths.ts';
6
+ import { createProject, ProjectExistsError } from '@src/store/project.ts';
7
+ import type { Project, Repository } from '@src/schemas/index.ts';
8
+ import {
9
+ createSpinner,
10
+ emoji,
11
+ field,
12
+ log,
13
+ showError,
14
+ showNextStep,
15
+ showSuccess,
16
+ showTip,
17
+ showWarning,
18
+ } from '@src/theme/ui.ts';
19
+ import { EXIT_ERROR, exitWithCode } from '@src/utils/exit-codes.ts';
20
+ import { browseDirectory } from '@src/interactive/file-browser.ts';
21
+ import { detectCheckScriptCandidates, suggestCheckScript } from '@src/utils/detect-scripts.ts';
22
+
23
+ export interface ProjectAddOptions {
24
+ name?: string;
25
+ displayName?: string;
26
+ paths?: string[];
27
+ description?: string;
28
+ checkScript?: string;
29
+ interactive?: boolean; // Set by REPL, not a CLI flag
30
+ }
31
+
32
+ function validateSlug(slug: string): boolean {
33
+ return /^[a-z0-9-]+$/.test(slug);
34
+ }
35
+
36
+ /**
37
+ * Check if a path is a git repository.
38
+ */
39
+ function isGitRepo(path: string): boolean {
40
+ try {
41
+ const gitDir = join(path, '.git');
42
+ return existsSync(gitDir) && statSync(gitDir).isDirectory();
43
+ } catch {
44
+ return false;
45
+ }
46
+ }
47
+
48
+ /**
49
+ * Check if an AI provider instructions file exists (CLAUDE.md or .github/copilot-instructions.md).
50
+ */
51
+ function hasAiInstructions(repoPath: string): boolean {
52
+ return existsSync(join(repoPath, 'CLAUDE.md')) || existsSync(join(repoPath, '.github', 'copilot-instructions.md'));
53
+ }
54
+
55
+ /**
56
+ * Add check script to a repository interactively.
57
+ * Exported so `project repo add` can reuse the same flow.
58
+ */
59
+ export async function addCheckScriptToRepository(repo: Repository): Promise<Repository> {
60
+ let suggested: string | null = null;
61
+
62
+ try {
63
+ const detection = detectCheckScriptCandidates(repo.path);
64
+ if (detection) {
65
+ log.success(` Detected: ${detection.typeLabel}`);
66
+ suggested = suggestCheckScript(repo.path);
67
+ }
68
+ } catch {
69
+ // Auto-detection failed, continue with manual entry
70
+ }
71
+
72
+ const checkInput = await input({
73
+ message: ' Check script (optional):',
74
+ default: suggested ?? undefined,
75
+ });
76
+ const checkScript = checkInput.trim() || undefined;
77
+
78
+ return {
79
+ ...repo,
80
+ checkScript,
81
+ };
82
+ }
83
+
84
+ export async function projectAddCommand(options: ProjectAddOptions = {}): Promise<void> {
85
+ let name: string;
86
+ let displayName: string;
87
+ let repositories: Repository[];
88
+ let description: string | undefined;
89
+
90
+ if (options.interactive === false) {
91
+ // Non-interactive mode: validate required params
92
+ const errors: string[] = [];
93
+ const trimmedName = options.name?.trim();
94
+ const trimmedDisplayName = options.displayName?.trim();
95
+
96
+ if (!trimmedName) {
97
+ errors.push('--name is required');
98
+ } else if (!validateSlug(trimmedName)) {
99
+ errors.push('--name must be a slug (lowercase, numbers, hyphens only)');
100
+ }
101
+
102
+ if (!trimmedDisplayName) {
103
+ errors.push('--display-name is required');
104
+ }
105
+
106
+ if (!options.paths || options.paths.length === 0) {
107
+ errors.push('--path is required (at least one)');
108
+ }
109
+
110
+ // Validate paths
111
+ if (options.paths) {
112
+ const spinner = options.paths.length > 1 ? createSpinner('Validating repository paths...').start() : null;
113
+ for (const path of options.paths) {
114
+ const resolved = resolve(path.trim());
115
+ const validation = await validateProjectPath(resolved);
116
+ if (validation !== true) {
117
+ errors.push(`--path ${path}: ${validation}`);
118
+ }
119
+ }
120
+ spinner?.succeed('Paths validated');
121
+ }
122
+
123
+ if (errors.length > 0 || !trimmedName || !trimmedDisplayName || !options.paths) {
124
+ showError('Validation failed');
125
+ for (const e of errors) {
126
+ log.item(error(e));
127
+ }
128
+ console.log('');
129
+ exitWithCode(EXIT_ERROR);
130
+ }
131
+
132
+ name = trimmedName;
133
+ displayName = trimmedDisplayName;
134
+ // Convert paths to repositories with auto-derived names
135
+ // In non-interactive mode, apply CLI flags if provided (otherwise no scripts)
136
+ repositories = options.paths.map((p) => {
137
+ const resolved = resolve(p.trim());
138
+ const repo: Repository = { name: basename(resolved), path: resolved };
139
+ if (options.checkScript) repo.checkScript = options.checkScript;
140
+ return repo;
141
+ });
142
+ const trimmedDesc = options.description?.trim();
143
+ description = trimmedDesc === '' ? undefined : trimmedDesc;
144
+ } else {
145
+ // Interactive mode (default) - prompt for missing params
146
+ name = await input({
147
+ message: 'Project name (slug):',
148
+ default: options.name?.trim(),
149
+ validate: (v) => {
150
+ const trimmed = v.trim();
151
+ if (trimmed.length === 0) return 'Name is required';
152
+ if (!validateSlug(trimmed)) return 'Must be lowercase with hyphens only';
153
+ return true;
154
+ },
155
+ });
156
+ name = name.trim();
157
+
158
+ displayName = await input({
159
+ message: 'Display name:',
160
+ default: options.displayName?.trim() ?? name,
161
+ validate: (v) => (v.trim().length > 0 ? true : 'Display name is required'),
162
+ });
163
+ displayName = displayName.trim();
164
+
165
+ // Collect repositories
166
+ repositories = [];
167
+
168
+ // Add any paths from options first
169
+ if (options.paths) {
170
+ for (const p of options.paths) {
171
+ const resolved = resolve(p.trim());
172
+ const validation = await validateProjectPath(resolved);
173
+ if (validation === true) {
174
+ repositories.push({ name: basename(resolved), path: resolved });
175
+ }
176
+ }
177
+ }
178
+
179
+ // Ask for at least one path if none provided
180
+ if (repositories.length === 0) {
181
+ const pathMethod = await select({
182
+ message: `${emoji.donut} How to specify repository path?`,
183
+ choices: [
184
+ { name: 'Browse filesystem', value: 'browse', description: 'Navigate from home folder' },
185
+ { name: 'Use current directory', value: 'cwd', description: process.cwd() },
186
+ { name: 'Type path manually', value: 'manual' },
187
+ ],
188
+ });
189
+
190
+ let firstPath: string;
191
+
192
+ if (pathMethod === 'browse') {
193
+ const browsed = await browseDirectory('Select repository directory:');
194
+ if (!browsed) {
195
+ showError('No directory selected');
196
+ exitWithCode(EXIT_ERROR);
197
+ }
198
+ firstPath = browsed;
199
+ } else if (pathMethod === 'cwd') {
200
+ firstPath = process.cwd();
201
+ } else {
202
+ firstPath = await input({
203
+ message: 'Repository path:',
204
+ default: process.cwd(),
205
+ validate: async (v) => {
206
+ const result = await validateProjectPath(v.trim());
207
+ return result;
208
+ },
209
+ });
210
+ firstPath = firstPath.trim();
211
+ }
212
+
213
+ const resolved = resolve(firstPath);
214
+ const validation = await validateProjectPath(resolved);
215
+ if (validation !== true) {
216
+ showError(`Invalid path: ${validation}`);
217
+ exitWithCode(EXIT_ERROR);
218
+ }
219
+ repositories.push({ name: basename(resolved), path: resolved });
220
+ }
221
+
222
+ // Process first repository with scripts
223
+ const firstRepo = repositories[0];
224
+ if (firstRepo) {
225
+ // Check for git repo
226
+ if (!isGitRepo(firstRepo.path)) {
227
+ showWarning('Path is not a git repository');
228
+ }
229
+
230
+ // Check for AI instructions file
231
+ if (!hasAiInstructions(firstRepo.path)) {
232
+ showTip('Add CLAUDE.md or .github/copilot-instructions.md for better AI assistance');
233
+ }
234
+
235
+ // Add scripts to first repository
236
+ log.info(`\nConfiguring: ${firstRepo.name}`);
237
+ repositories[0] = await addCheckScriptToRepository(firstRepo);
238
+ }
239
+
240
+ // Ask for additional paths
241
+ let addMore = true;
242
+ while (addMore) {
243
+ const addAction = await select({
244
+ message: `${emoji.donut} Add another repository?`,
245
+ choices: [
246
+ { name: 'No, done adding repositories', value: 'done' },
247
+ { name: 'Browse filesystem', value: 'browse' },
248
+ { name: 'Type path manually', value: 'manual' },
249
+ ],
250
+ });
251
+
252
+ if (addAction === 'done') {
253
+ addMore = false;
254
+ } else if (addAction === 'browse') {
255
+ const browsed = await browseDirectory('Select repository directory:');
256
+ if (browsed) {
257
+ const resolved = resolve(browsed);
258
+ const validation = await validateProjectPath(resolved);
259
+ if (validation === true) {
260
+ const newRepo = { name: basename(resolved), path: resolved };
261
+ log.success(`Added: ${newRepo.name}`);
262
+ // Add scripts for this repository
263
+ const repoWithScripts = await addCheckScriptToRepository(newRepo);
264
+ repositories.push(repoWithScripts);
265
+ } else {
266
+ log.error(`Invalid path: ${validation}`);
267
+ }
268
+ }
269
+ } else {
270
+ const additionalPath = await input({
271
+ message: 'Repository path:',
272
+ });
273
+
274
+ if (additionalPath.trim() === '') {
275
+ addMore = false;
276
+ } else {
277
+ const resolved = resolve(additionalPath.trim());
278
+ const validation = await validateProjectPath(resolved);
279
+ if (validation === true) {
280
+ const newRepo = { name: basename(resolved), path: resolved };
281
+ log.success(`Added: ${newRepo.name}`);
282
+ // Add scripts for this repository
283
+ const repoWithScripts = await addCheckScriptToRepository(newRepo);
284
+ repositories.push(repoWithScripts);
285
+ } else {
286
+ log.error(`Invalid path: ${validation}`);
287
+ }
288
+ }
289
+ }
290
+ }
291
+
292
+ description = await input({
293
+ message: 'Description (optional):',
294
+ default: options.description?.trim(),
295
+ });
296
+ const trimmedDescInteractive = description.trim();
297
+ description = trimmedDescInteractive === '' ? undefined : trimmedDescInteractive;
298
+ }
299
+
300
+ try {
301
+ const project: Project = {
302
+ name,
303
+ displayName,
304
+ repositories,
305
+ description,
306
+ };
307
+
308
+ const created = await createProject(project);
309
+
310
+ showSuccess('Project added!', [
311
+ ['Name', created.name],
312
+ ['Display Name', created.displayName],
313
+ ]);
314
+ if (created.description) {
315
+ console.log(field('Description', created.description));
316
+ }
317
+ console.log(field('Repositories', ''));
318
+ for (const repo of created.repositories) {
319
+ log.item(`${repo.name} → ${repo.path}`);
320
+ if (repo.checkScript) {
321
+ console.log(` Check: ${repo.checkScript}`);
322
+ } else {
323
+ console.log(` Check: ${muted('(not configured)')}`);
324
+ }
325
+ }
326
+ console.log('');
327
+ } catch (err) {
328
+ if (err instanceof ProjectExistsError) {
329
+ showError(`Project "${name}" already exists.`);
330
+ showNextStep(`ralphctl project remove ${name}`, 'remove existing project first');
331
+ log.newline();
332
+ } else {
333
+ throw err;
334
+ }
335
+ }
336
+ }
@@ -0,0 +1,104 @@
1
+ import type { Command } from 'commander';
2
+ import { projectAddCommand } from '@src/commands/project/add.ts';
3
+ import { projectListCommand } from '@src/commands/project/list.ts';
4
+ import { projectRepoAddCommand, projectRepoRemoveCommand } from '@src/commands/project/repo.ts';
5
+ import { projectShowCommand } from '@src/commands/project/show.ts';
6
+ import { projectRemoveCommand } from '@src/commands/project/remove.ts';
7
+
8
+ export function registerProjectCommands(program: Command): void {
9
+ const project = program.command('project').description('Manage projects');
10
+
11
+ project.addHelpText(
12
+ 'after',
13
+ `
14
+ Examples:
15
+ $ ralphctl project add --name api --display-name "API Server" --path ~/code/api
16
+ $ ralphctl project list
17
+ $ ralphctl project show api
18
+ $ ralphctl project repo add api ~/code/api-v2
19
+ `
20
+ );
21
+
22
+ project
23
+ .command('add')
24
+ .description('Add/update project')
25
+ .option('--name <name>', 'Slug (lowercase, numbers, hyphens)')
26
+ .option('--display-name <name>', 'Human-readable name')
27
+ .option('--path <path...>', 'Repository path (repeatable)')
28
+ .option('--description <desc>', 'Optional description')
29
+ .option('--check-script <cmd>', 'Check command (install + verify)')
30
+ .option('-n, --no-interactive', 'Non-interactive mode (error on missing params)')
31
+ .action(
32
+ async (opts: {
33
+ name?: string;
34
+ displayName?: string;
35
+ path?: string[];
36
+ description?: string;
37
+ checkScript?: string;
38
+ interactive?: boolean;
39
+ }) => {
40
+ await projectAddCommand({
41
+ name: opts.name,
42
+ displayName: opts.displayName,
43
+ paths: opts.path,
44
+ description: opts.description,
45
+ checkScript: opts.checkScript,
46
+ // --no-interactive sets interactive=false, otherwise true (prompt for missing)
47
+ interactive: opts.interactive !== false,
48
+ });
49
+ }
50
+ );
51
+
52
+ project.command('list').description('List all projects').action(projectListCommand);
53
+
54
+ project
55
+ .command('show [name]')
56
+ .description('Show project details')
57
+ .action(async (name?: string) => {
58
+ await projectShowCommand(name ? [name] : []);
59
+ });
60
+
61
+ project
62
+ .command('remove [name]')
63
+ .description('Remove a project')
64
+ .option('-y, --yes', 'Skip confirmation')
65
+ .action(async (name?: string, opts?: { yes?: boolean }) => {
66
+ const args: string[] = [];
67
+ if (name) args.push(name);
68
+ if (opts?.yes) args.push('-y');
69
+ await projectRemoveCommand(args);
70
+ });
71
+
72
+ const repo = project.command('repo').description('Manage project repositories');
73
+
74
+ repo.addHelpText(
75
+ 'after',
76
+ `
77
+ Examples:
78
+ $ ralphctl project repo add my-app ~/code/new-service
79
+ $ ralphctl project repo remove my-app ~/code/old-service
80
+ `
81
+ );
82
+
83
+ repo
84
+ .command('add [name] [path]')
85
+ .description('Add repository to project')
86
+ .action(async (name?: string, pathArg?: string) => {
87
+ const args: string[] = [];
88
+ if (name) args.push(name);
89
+ if (pathArg) args.push(pathArg);
90
+ await projectRepoAddCommand(args);
91
+ });
92
+
93
+ repo
94
+ .command('remove [name] [path]')
95
+ .description('Remove repository from project')
96
+ .option('-y, --yes', 'Skip confirmation')
97
+ .action(async (name?: string, pathArg?: string, opts?: { yes?: boolean }) => {
98
+ const args: string[] = [];
99
+ if (name) args.push(name);
100
+ if (pathArg) args.push(pathArg);
101
+ if (opts?.yes) args.push('-y');
102
+ await projectRepoRemoveCommand(args);
103
+ });
104
+ }
@@ -0,0 +1,31 @@
1
+ import { colors, muted } from '@src/theme/index.ts';
2
+ import { listProjects } from '@src/store/project.ts';
3
+ import { icons, log, printHeader, showEmpty } from '@src/theme/ui.ts';
4
+
5
+ export async function projectListCommand(): Promise<void> {
6
+ const projects = await listProjects();
7
+
8
+ if (projects.length === 0) {
9
+ showEmpty('projects', 'Add one with: ralphctl project add');
10
+ return;
11
+ }
12
+
13
+ printHeader('Projects', icons.project);
14
+
15
+ for (const project of projects) {
16
+ const repoCount = muted(
17
+ `(${String(project.repositories.length)} repo${project.repositories.length !== 1 ? 's' : ''})`
18
+ );
19
+ log.raw(`${colors.highlight(project.name)} ${project.displayName} ${repoCount}`);
20
+ for (const repo of project.repositories) {
21
+ log.item(`${repo.name} ${muted('→')} ${muted(repo.path)}`);
22
+ }
23
+ if (project.description) {
24
+ log.dim(` ${project.description}`);
25
+ }
26
+ log.newline();
27
+ }
28
+
29
+ log.dim(`Showing ${String(projects.length)} project(s)`);
30
+ log.newline();
31
+ }
@@ -0,0 +1,43 @@
1
+ import { confirm } from '@inquirer/prompts';
2
+ import { muted } from '@src/theme/index.ts';
3
+ import { getProject, ProjectNotFoundError, removeProject } from '@src/store/project.ts';
4
+ import { selectProject } from '@src/interactive/selectors.ts';
5
+ import { showError, showSuccess } from '@src/theme/ui.ts';
6
+
7
+ export async function projectRemoveCommand(args: string[]): Promise<void> {
8
+ const skipConfirm = args.includes('-y') || args.includes('--yes');
9
+ let projectName = args.find((a) => !a.startsWith('-'));
10
+
11
+ if (!projectName) {
12
+ const selected = await selectProject('Select project to remove:');
13
+ if (!selected) return;
14
+ projectName = selected;
15
+ }
16
+
17
+ try {
18
+ const project = await getProject(projectName);
19
+
20
+ if (!skipConfirm) {
21
+ const confirmed = await confirm({
22
+ message: `Remove project "${project.displayName}" (${project.name})?`,
23
+ default: false,
24
+ });
25
+
26
+ if (!confirmed) {
27
+ console.log(muted('\nProject removal cancelled.\n'));
28
+ return;
29
+ }
30
+ }
31
+
32
+ await removeProject(projectName);
33
+ showSuccess('Project removed', [['Name', projectName]]);
34
+ console.log('');
35
+ } catch (err) {
36
+ if (err instanceof ProjectNotFoundError) {
37
+ showError(`Project not found: ${projectName}`);
38
+ console.log('');
39
+ } else {
40
+ throw err;
41
+ }
42
+ }
43
+ }
@@ -0,0 +1,118 @@
1
+ import { basename, resolve } from 'node:path';
2
+ import { confirm, input, select } from '@inquirer/prompts';
3
+ import { muted } from '@src/theme/index.ts';
4
+ import { addProjectRepo, getProject, ProjectNotFoundError, removeProjectRepo } from '@src/store/project.ts';
5
+ import { selectProject } from '@src/interactive/selectors.ts';
6
+ import { emoji, log, showError, showSuccess } from '@src/theme/ui.ts';
7
+ import { addCheckScriptToRepository } from '@src/commands/project/add.ts';
8
+
9
+ export async function projectRepoAddCommand(args: string[]): Promise<void> {
10
+ let projectName = args[0];
11
+ let path = args[1];
12
+
13
+ // Interactive: select project if not provided
14
+ if (!projectName) {
15
+ const selected = await selectProject('Select project to add repository to:');
16
+ if (!selected) return;
17
+ projectName = selected;
18
+ }
19
+
20
+ // Interactive: ask for path if not provided
21
+ path ??= await input({
22
+ message: `${emoji.donut} Repository path to add:`,
23
+ validate: (v) => (v.trim().length > 0 ? true : 'Path is required'),
24
+ });
25
+
26
+ try {
27
+ const resolvedPath = resolve(path);
28
+ const bareRepo = { name: basename(resolvedPath), path: resolvedPath };
29
+
30
+ // Prompt for setup/verify scripts (with heuristic suggestions)
31
+ log.info(`\nConfiguring: ${bareRepo.name}`);
32
+ const repoWithScripts = await addCheckScriptToRepository(bareRepo);
33
+
34
+ const project = await addProjectRepo(projectName, repoWithScripts);
35
+ showSuccess('Repository added', [['Project', projectName]]);
36
+ log.newline();
37
+ log.info('Current repositories:');
38
+ for (const repo of project.repositories) {
39
+ log.item(`${repo.name} → ${repo.path}`);
40
+ }
41
+ log.newline();
42
+ } catch (err) {
43
+ if (err instanceof ProjectNotFoundError) {
44
+ showError(`Project not found: ${projectName}`);
45
+ log.newline();
46
+ } else if (err instanceof Error) {
47
+ showError(err.message);
48
+ log.newline();
49
+ } else {
50
+ throw err;
51
+ }
52
+ }
53
+ }
54
+
55
+ export async function projectRepoRemoveCommand(args: string[]): Promise<void> {
56
+ const skipConfirm = args.includes('-y') || args.includes('--yes');
57
+ const filteredArgs = args.filter((a) => !a.startsWith('-'));
58
+ let projectName = filteredArgs[0];
59
+ let path = filteredArgs[1];
60
+
61
+ // Interactive: select project if not provided
62
+ if (!projectName) {
63
+ const selected = await selectProject('Select project to remove repository from:');
64
+ if (!selected) return;
65
+ projectName = selected;
66
+ }
67
+
68
+ try {
69
+ const project = await getProject(projectName);
70
+
71
+ // Interactive: select repository if not provided
72
+ if (!path) {
73
+ if (project.repositories.length === 0) {
74
+ console.log(muted('\nNo repositories to remove.\n'));
75
+ return;
76
+ }
77
+
78
+ path = await select({
79
+ message: `${emoji.donut} Select repository to remove:`,
80
+ choices: project.repositories.map((r) => ({
81
+ name: `${r.name} (${r.path})`,
82
+ value: r.path,
83
+ })),
84
+ });
85
+ }
86
+
87
+ if (!skipConfirm) {
88
+ const confirmed = await confirm({
89
+ message: `Remove repository "${path}" from project "${project.displayName}"?`,
90
+ default: false,
91
+ });
92
+
93
+ if (!confirmed) {
94
+ console.log(muted('\nRepository removal cancelled.\n'));
95
+ return;
96
+ }
97
+ }
98
+
99
+ const updatedProject = await removeProjectRepo(projectName, path);
100
+ showSuccess('Repository removed', [['Project', projectName]]);
101
+ log.newline();
102
+ log.info('Remaining repositories:');
103
+ for (const repo of updatedProject.repositories) {
104
+ log.item(`${repo.name} → ${repo.path}`);
105
+ }
106
+ log.newline();
107
+ } catch (err) {
108
+ if (err instanceof ProjectNotFoundError) {
109
+ showError(`Project not found: ${projectName}`);
110
+ log.newline();
111
+ } else if (err instanceof Error) {
112
+ showError(err.message);
113
+ log.newline();
114
+ } else {
115
+ throw err;
116
+ }
117
+ }
118
+ }
@@ -0,0 +1,49 @@
1
+ import { colors, muted } from '@src/theme/index.ts';
2
+ import { getProject, ProjectNotFoundError } from '@src/store/project.ts';
3
+ import { selectProject } from '@src/interactive/selectors.ts';
4
+ import { icons, labelValue, log, renderCard, showError } from '@src/theme/ui.ts';
5
+
6
+ export async function projectShowCommand(args: string[]): Promise<void> {
7
+ let projectName = args[0];
8
+
9
+ if (!projectName) {
10
+ const selected = await selectProject('Select project to show:');
11
+ if (!selected) return;
12
+ projectName = selected;
13
+ }
14
+
15
+ try {
16
+ const project = await getProject(projectName);
17
+
18
+ // Project info card
19
+ const infoLines: string[] = [labelValue('Name', project.name), labelValue('Display Name', project.displayName)];
20
+ if (project.description) {
21
+ infoLines.push(labelValue('Description', project.description));
22
+ }
23
+ infoLines.push(labelValue('Repositories', String(project.repositories.length)));
24
+
25
+ log.newline();
26
+ console.log(renderCard(`${icons.project} ${project.displayName}`, infoLines));
27
+
28
+ // Repository cards
29
+ for (const repo of project.repositories) {
30
+ log.newline();
31
+ const repoLines: string[] = [labelValue('Path', repo.path)];
32
+ if (repo.checkScript) {
33
+ repoLines.push(labelValue('Check', colors.info(repo.checkScript)));
34
+ } else {
35
+ repoLines.push(muted('No check script configured'));
36
+ }
37
+ console.log(renderCard(` ${repo.name}`, repoLines));
38
+ }
39
+
40
+ log.newline();
41
+ } catch (err) {
42
+ if (err instanceof ProjectNotFoundError) {
43
+ showError(`Project not found: ${projectName}`);
44
+ log.newline();
45
+ } else {
46
+ throw err;
47
+ }
48
+ }
49
+ }