plankit-cli 1.0.0 → 1.2.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.
package/src/cli.js CHANGED
@@ -1,235 +1,655 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import os from 'node:os';
4
+ import { spawnSync } from 'node:child_process';
4
5
  import {
5
- AGENTS_MD_TEMPLATE,
6
- CURSORRULES_TEMPLATE,
7
- GEMINI_INSTRUCTIONS_TEMPLATE,
8
- OPENCODE_COMMAND_PLAN,
9
- OPENCODE_COMMAND_CLARIFY,
10
- OPENCODE_COMMAND_IMPLEMENT,
11
- OPENCODE_COMMAND_REVIEW,
12
- OPENCODE_COMMAND_MASTER,
13
- PLANKIT_SPEC_TEMPLATE
14
- } from './templates.js';
15
-
16
- export function runCli(args) {
17
- const command = args[0];
18
- const target = args[1];
19
-
20
- switch (command) {
21
- case 'init':
22
- initProject();
23
- break;
24
- case 'plan':
25
- if (!target) {
26
- console.error('Error: Please specify a feature name. Example: npx plankit plan my-feature');
27
- process.exit(1);
28
- }
29
- planFeature(target);
30
- break;
31
- case 'status':
32
- showStatus();
33
- break;
34
- case 'archive':
35
- if (!target) {
36
- console.error('Error: Please specify a feature name. Example: npx plankit archive my-feature');
37
- process.exit(1);
38
- }
39
- archiveFeature(target);
40
- break;
41
- case 'help':
42
- case '--help':
43
- case '-h':
44
- default:
45
- showHelp();
46
- break;
6
+ assertSafeName,
7
+ createDefaultConfigFile,
8
+ ensureDir,
9
+ listDirectories,
10
+ loadConfig,
11
+ normalizePhases,
12
+ parsePhaseFlag,
13
+ resolveConfiguredAgent,
14
+ safeJoin,
15
+ writeManagedFile
16
+ } from './config.js';
17
+ import { renderList, renderTemplate, packageRoot } from './templates.js';
18
+ import { DEFAULT_AGENT, GEMINI_SKILL_ENTRIES, getAgent, listAgents, validAgentNames } from './agents.js';
19
+ import { bundleEntry, bundleSourceExists } from './commandBundles.js';
20
+
21
+ export async function runCli(args, context = {}) {
22
+ const io = createIo(context);
23
+
24
+ try {
25
+ const parsed = parseArgs(args);
26
+ const command = parsed.command;
27
+
28
+ switch (command) {
29
+ case 'init':
30
+ await initProject(parsed, io);
31
+ break;
32
+ case 'plan':
33
+ planFeature(parsed, io);
34
+ break;
35
+ case 'clarify':
36
+ clarifyFeature(parsed, io);
37
+ break;
38
+ case 'implement':
39
+ prepareImplementation(parsed, io);
40
+ break;
41
+ case 'review':
42
+ reviewFeature(parsed, io);
43
+ break;
44
+ case 'status':
45
+ showStatus(parsed, io);
46
+ break;
47
+ case 'archive':
48
+ archiveFeature(parsed, io);
49
+ break;
50
+ case 'help':
51
+ case '--help':
52
+ case '-h':
53
+ case undefined:
54
+ showHelp(io);
55
+ break;
56
+ default:
57
+ throw new Error(`Unknown command "${command}". Run "plankit help" for usage.`);
58
+ }
59
+ } catch (error) {
60
+ io.stderr(`Error: ${error.message}`);
61
+ io.exit(1);
47
62
  }
48
63
  }
49
64
 
50
- function ensureDir(dirPath) {
51
- if (!fs.existsSync(dirPath)) {
52
- fs.mkdirSync(dirPath, { recursive: true });
65
+ async function initProject(parsed, io) {
66
+ const { config, path: configPath } = loadConfig(io.cwd);
67
+ const options = writeOptions(parsed, config);
68
+ const agent = await resolveAgent(parsed, config, io, configPath);
69
+ const results = [];
70
+ const artifactsDir = safeJoin(io.cwd, config.artifactsDir);
71
+
72
+ ensureDir(path.join(artifactsDir, 'current'), options);
73
+ ensureDir(path.join(artifactsDir, 'archived'), options);
74
+
75
+ results.push(writeManagedFile(
76
+ path.join(artifactsDir, 'PLANKIT.md'),
77
+ renderTemplate(config, 'PLANKIT.md', { artifactsDir: config.artifactsDir }, io.cwd),
78
+ options
79
+ ));
80
+ results.push(writeManagedFile(path.join(artifactsDir, 'current', '.gitkeep'), '', options));
81
+ results.push(writeManagedFile(path.join(artifactsDir, 'archived', '.gitkeep'), '', options));
82
+ results.push(createDefaultConfigFile(io.cwd, options));
83
+
84
+ for (const binding of agent.bindings) {
85
+ results.push(writeManagedFile(
86
+ safeJoin(io.cwd, ...binding.dest),
87
+ renderTemplate(config, binding.template, { artifactsDir: config.artifactsDir, agent: agent.id }, io.cwd),
88
+ options
89
+ ));
90
+ }
91
+
92
+ for (const bundleName of agent.commandBundles) {
93
+ results.push(installCommandBundle(agent, bundleName, config, options, io));
53
94
  }
95
+
96
+ if (parsed.flags['global-gemini-skills'] && agent.id === 'gemini') {
97
+ results.push(...installGlobalGeminiSkills(config, options));
98
+ }
99
+
100
+ io.stdout(`PlanKit initialized for agent "${agent.id}".`);
101
+ printResults(results, io);
54
102
  }
55
103
 
56
- function writeFile(filePath, content) {
57
- ensureDir(path.dirname(filePath));
58
- fs.writeFileSync(filePath, content, 'utf8');
104
+ function installCommandBundle(agent, bundleName, config, options, io) {
105
+ const entry = bundleEntry(bundleName);
106
+ if (!entry) {
107
+ return { action: 'info', path: bundleName, message: `unknown bundle for ${agent.id}` };
108
+ }
109
+ if (!bundleSourceExists(bundleName, io.cwd, config)) {
110
+ return {
111
+ action: 'info',
112
+ path: bundleName,
113
+ message: `${entry.label} for ${agent.id} not shipped yet`
114
+ };
115
+ }
116
+
117
+ if (entry.global && options.dryRun) {
118
+ return { action: 'info', path: bundleName, message: 'would install to user profile (dry run)' };
119
+ }
120
+
121
+ const targetRoot = entry.global
122
+ ? path.join(os.homedir(), ...entry.dest)
123
+ : safeJoin(io.cwd, ...entry.dest);
124
+
125
+ const sourceRoot = resolveBundleSource(entry.src, io.cwd, config);
126
+ ensureDir(targetRoot, options);
127
+ return copyBundleRecursive(sourceRoot, targetRoot, options);
59
128
  }
60
129
 
61
- function initProject() {
62
- console.log('🚀 Initializing PlanKit Command Suite in project...');
130
+ function resolveBundleSource(src, cwd, config) {
131
+ const customPath = config.templatesDir
132
+ ? path.resolve(cwd, config.templatesDir, src)
133
+ : null;
134
+ if (customPath && fs.existsSync(customPath)) {
135
+ return customPath;
136
+ }
137
+ return path.join(packageRoot, 'templates', src);
138
+ }
139
+
140
+ function copyBundleRecursive(sourceRoot, targetRoot, options) {
141
+ const copyResults = [];
142
+ const files = collectFiles(sourceRoot);
143
+ for (const rel of files) {
144
+ const srcFile = path.join(sourceRoot, rel);
145
+ const destFile = path.join(targetRoot, rel);
146
+ const rendered = renderTemplateFromFile(srcFile);
147
+ copyResults.push(writeManagedFile(destFile, rendered, options));
148
+ }
149
+ const wroteAny = copyResults.some((result) => result && result.action !== 'skipped' && result.action !== 'unchanged');
150
+ return {
151
+ action: wroteAny ? 'copied' : 'unchanged',
152
+ path: targetRoot,
153
+ copies: copyResults
154
+ };
155
+ }
63
156
 
64
- const cwd = process.cwd();
157
+ function collectFiles(dir) {
158
+ const results = [];
159
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
160
+ const full = path.join(dir, entry.name);
161
+ if (entry.isDirectory()) {
162
+ results.push(...collectFiles(full).map((rel) => path.join(entry.name, rel)));
163
+ } else {
164
+ results.push(entry.name);
165
+ }
166
+ }
167
+ return results;
168
+ }
169
+
170
+ function renderTemplateFromFile(filePath) {
171
+ return fs.readFileSync(filePath, 'utf8')
172
+ .replace(/\{\{([a-zA-Z0-9_.-]+)\}\}/g, (_, key) => {
173
+ const maps = { artifactsDir: 'artifacts' };
174
+ return key in maps ? maps[key] : '';
175
+ });
176
+ }
65
177
 
66
- // 1. Artifacts directories & spec files
67
- ensureDir(path.join(cwd, 'artifacts', 'current'));
68
- ensureDir(path.join(cwd, 'artifacts', 'archived'));
69
- writeFile(path.join(cwd, 'artifacts', 'PLANKIT.md'), PLANKIT_SPEC_TEMPLATE);
70
- writeFile(path.join(cwd, 'artifacts', 'current', '.gitkeep'), '');
71
- writeFile(path.join(cwd, 'artifacts', 'archived', '.gitkeep'), '');
178
+ function planFeature(parsed, io) {
179
+ const featureName = parsed.positionals[0];
180
+ if (!featureName) {
181
+ throw new Error('Please specify a feature name. Example: plankit plan my-feature');
182
+ }
183
+ assertSafeName(featureName, 'feature name');
72
184
 
73
- // 2. Gemini / Antigravity instructions
74
- writeFile(path.join(cwd, '.gemini', 'instructions.md'), GEMINI_INSTRUCTIONS_TEMPLATE);
185
+ const { config } = loadConfig(io.cwd);
186
+ const phases = parsePhaseFlag(parsed.flags.phases) || normalizePhases(config.defaultPhases);
187
+ const requirements = parsed.flags.requirements || parsed.positionals.slice(1).join(' ') || 'Detailed description of the feature goal and scope.';
188
+ const options = writeOptions(parsed, config);
189
+ const featureDir = safeJoin(io.cwd, config.artifactsDir, 'current', featureName);
75
190
 
76
- // 3. Cursor rules
77
- writeFile(path.join(cwd, '.cursorrules'), CURSORRULES_TEMPLATE);
191
+ if (fs.existsSync(featureDir)) {
192
+ throw new Error(`Feature "${featureName}" already exists at ${path.join(config.artifactsDir, 'current', featureName)}`);
193
+ }
78
194
 
79
- // 4. AGENTS.md
80
- writeFile(path.join(cwd, 'AGENTS.md'), AGENTS_MD_TEMPLATE);
195
+ ensureDir(path.join(featureDir, 'phases'), options);
196
+ ensureDir(path.join(featureDir, 'outputs'), options);
197
+
198
+ const phaseChecklist = renderList(phases, (phase, index) => (
199
+ `- [ ] Phase ${index + 1}: ${phase.title} — ([Spec](phases/${phaseFileName(index + 1, phase)}))`
200
+ ));
201
+ const phaseMatrix = renderList(phases, (phase, index) => {
202
+ const phaseNumber = index + 1;
203
+ const specPath = `phases/${phaseFileName(phaseNumber, phase)}`;
204
+ const outputPath = `outputs/phase-${phaseNumber}-output.md`;
205
+ return `| Phase ${phaseNumber} | ${phase.title} | \`${specPath}\` | \`${outputPath}\` | **PENDING** |`;
206
+ });
207
+
208
+ const results = [];
209
+ results.push(writeManagedFile(
210
+ path.join(featureDir, 'README.md'),
211
+ renderTemplate(config, 'feature-readme.md', {
212
+ featureName,
213
+ requirements,
214
+ phaseChecklist,
215
+ phaseMatrix
216
+ }, io.cwd),
217
+ options
218
+ ));
219
+
220
+ results.push(writeManagedFile(
221
+ path.join(featureDir, 'plankit.json'),
222
+ `${JSON.stringify(createFeatureState(featureName, phases), null, 2)}\n`,
223
+ options
224
+ ));
225
+
226
+ phases.forEach((phase, index) => {
227
+ const phaseNumber = index + 1;
228
+ const tasks = phase.tasks.map((task) => `- [ ] ${task}`).join('\n');
229
+ results.push(writeManagedFile(
230
+ path.join(featureDir, 'phases', phaseFileName(phaseNumber, phase)),
231
+ renderTemplate(config, 'phase-spec.md', {
232
+ phaseNumber,
233
+ previousPhaseNumber: Math.max(phaseNumber - 1, 0),
234
+ phaseTitle: phase.title,
235
+ objective: phase.objective,
236
+ tasks
237
+ }, io.cwd),
238
+ options
239
+ ));
240
+ });
241
+
242
+ results.push(writeManagedFile(path.join(featureDir, 'outputs', '.gitkeep'), '', options));
243
+
244
+ io.stdout(`Feature "${featureName}" planned with ${phases.length} phase(s).`);
245
+ printResults(results, io);
246
+ }
81
247
 
82
- // 5. OpenCode command markdown files
83
- const opencodeDir = path.join(cwd, '.opencode', 'commands');
84
- writeFile(path.join(opencodeDir, 'plankit-plan.md'), OPENCODE_COMMAND_PLAN);
85
- writeFile(path.join(opencodeDir, 'plankit-clarify.md'), OPENCODE_COMMAND_CLARIFY);
86
- writeFile(path.join(opencodeDir, 'plankit-implement.md'), OPENCODE_COMMAND_IMPLEMENT);
87
- writeFile(path.join(opencodeDir, 'plankit-review.md'), OPENCODE_COMMAND_REVIEW);
88
- writeFile(path.join(opencodeDir, 'plankit.md'), OPENCODE_COMMAND_MASTER);
248
+ function clarifyFeature(parsed, io) {
249
+ const { featureName, phaseNumber } = readFeatureAndPhaseArgs(parsed, { phaseOptional: true });
250
+ const { config } = loadConfig(io.cwd);
251
+ const options = writeOptions(parsed, config);
252
+ const featureDir = requireFeatureDir(io.cwd, config, featureName);
253
+ const phaseLabel = phaseNumber ? `Phase ${phaseNumber}` : 'Unspecified phase';
254
+
255
+ const result = writeManagedFile(
256
+ path.join(featureDir, 'clarifications.md'),
257
+ renderTemplate(config, 'clarifications.md', { featureName, phaseLabel }, io.cwd),
258
+ options
259
+ );
260
+
261
+ io.stdout(`Clarification artifact prepared for "${featureName}".`);
262
+ printResults([result], io);
263
+ }
89
264
 
90
- // 6. Global Gemini Skills setup
91
- try {
92
- const userHome = os.homedir();
93
- const globalSkillsDir = path.join(userHome, '.gemini', 'skills');
94
-
95
- const skillsToCreate = [
96
- { name: 'plankit', desc: 'PlanKit Command Suite master skill.' },
97
- { name: 'plankit-plan', desc: 'Initialize feature under artifacts/current/<feature-name>/.' },
98
- { name: 'plankit-clarify', desc: 'Analyze requirements and ask targeted clarifying questions.' },
99
- { name: 'plankit-implement', desc: 'Execute Phase N reading spec and previous outputs.' },
100
- { name: 'plankit-review', desc: 'Run test & build verification, validate DoD, and archive feature.' }
101
- ];
102
-
103
- for (const skill of skillsToCreate) {
104
- const skillPath = path.join(globalSkillsDir, skill.name, 'SKILL.md');
105
- const skillContent = `---\nname: ${skill.name}\ndescription: ${skill.desc}\n---\n\n# ${skill.name}\n${skill.desc}\n`;
106
- writeFile(skillPath, skillContent);
265
+ function prepareImplementation(parsed, io) {
266
+ const { featureName, phaseNumber } = readFeatureAndPhaseArgs(parsed);
267
+ const { config } = loadConfig(io.cwd);
268
+ const options = writeOptions(parsed, config);
269
+ const featureDir = requireFeatureDir(io.cwd, config, featureName);
270
+ const phaseSpec = findPhaseSpec(featureDir, phaseNumber);
271
+ const previousMissing = [];
272
+
273
+ for (let index = 1; index < phaseNumber; index += 1) {
274
+ const outputPath = path.join(featureDir, 'outputs', `phase-${index}-output.md`);
275
+ if (!fs.existsSync(outputPath)) {
276
+ previousMissing.push(path.relative(featureDir, outputPath));
107
277
  }
108
- console.log(' ✓ Installed global Gemini skills in ~/.gemini/skills/');
109
- } catch (e) {
110
- // Ignore global home dir write errors if restricted
111
278
  }
112
279
 
113
- console.log('\n✅ PlanKit successfully initialized!');
114
- console.log('\nCreated / Updated configuration files:');
115
- console.log(' - artifacts/PLANKIT.md');
116
- console.log(' - .gemini/instructions.md');
117
- console.log(' - .cursorrules');
118
- console.log(' - AGENTS.md');
119
- console.log(' - .opencode/commands/*.md');
120
- console.log('\nNext steps:');
121
- console.log(' Run "npx plankit plan <feature-name>" to start a new feature.');
280
+ if (previousMissing.length > 0) {
281
+ throw new Error(`Cannot prepare Phase ${phaseNumber}; missing previous output(s): ${previousMissing.join(', ')}`);
282
+ }
283
+
284
+ const phaseTitle = readPhaseTitle(phaseSpec, phaseNumber);
285
+ const outputPath = path.join(featureDir, 'outputs', `phase-${phaseNumber}-output.md`);
286
+ const result = writeManagedFile(
287
+ outputPath,
288
+ renderTemplate(config, 'output-report.md', { phaseNumber, phaseTitle }, io.cwd),
289
+ options
290
+ );
291
+
292
+ io.stdout(`Implementation context validated for "${featureName}" Phase ${phaseNumber}.`);
293
+ io.stdout(`Phase spec: ${path.relative(io.cwd, phaseSpec)}`);
294
+ printResults([result], io);
122
295
  }
123
296
 
124
- function planFeature(featureName) {
125
- const cwd = process.cwd();
126
- const featureDir = path.join(cwd, 'artifacts', 'current', featureName);
297
+ function reviewFeature(parsed, io) {
298
+ const featureName = parsed.positionals[0];
299
+ if (!featureName) {
300
+ throw new Error('Please specify a feature name. Example: plankit review my-feature');
301
+ }
302
+ assertSafeName(featureName, 'feature name');
303
+
304
+ const { config } = loadConfig(io.cwd);
305
+ const featureDir = requireFeatureDir(io.cwd, config, featureName);
306
+ const testCommand = parsed.flags['test-command'] ?? config.verification.testCommand;
307
+ const buildCommand = parsed.flags['build-command'] ?? config.verification.buildCommand;
127
308
 
128
- if (fs.existsSync(featureDir)) {
129
- console.log(`⚠️ Feature folder "${featureName}" already exists at artifacts/current/${featureName}`);
309
+ if (!parsed.flags['skip-test'] && testCommand) {
310
+ runVerificationCommand(testCommand, io);
311
+ }
312
+ if (!parsed.flags['skip-build'] && buildCommand) {
313
+ runVerificationCommand(buildCommand, io);
314
+ }
315
+
316
+ markReadmeArchived(path.join(featureDir, 'README.md'), parsed.dryRun);
317
+ moveFeatureToArchive(io.cwd, config, featureName, parsed.dryRun);
318
+ io.stdout(`Feature "${featureName}" reviewed and archived.`);
319
+ }
320
+
321
+ function showStatus(parsed, io) {
322
+ const { config } = loadConfig(io.cwd);
323
+ const currentDir = safeJoin(io.cwd, config.artifactsDir, 'current');
324
+ const archivedDir = safeJoin(io.cwd, config.artifactsDir, 'archived');
325
+ const status = {
326
+ active: listDirectories(currentDir),
327
+ archived: listDirectories(archivedDir)
328
+ };
329
+
330
+ if (parsed.flags.json) {
331
+ io.stdout(JSON.stringify(status, null, 2));
130
332
  return;
131
333
  }
132
334
 
133
- console.log(`🚀 Creating new feature workspace for "${featureName}"...`);
335
+ io.stdout('PlanKit Feature Status Summary:');
336
+ io.stdout('');
337
+ io.stdout(`ACTIVE FEATURES (${path.join(config.artifactsDir, 'current')}/):`);
338
+ io.stdout(status.active.length ? status.active.map((feature) => ` - ${feature}`).join('\n') : ' (none)');
339
+ io.stdout('');
340
+ io.stdout(`ARCHIVED FEATURES (${path.join(config.artifactsDir, 'archived')}/):`);
341
+ io.stdout(status.archived.length ? status.archived.map((feature) => ` - ${feature}`).join('\n') : ' (none)');
342
+ }
134
343
 
135
- ensureDir(path.join(featureDir, 'phases'));
136
- ensureDir(path.join(featureDir, 'outputs'));
344
+ function archiveFeature(parsed, io) {
345
+ const featureName = parsed.positionals[0];
346
+ if (!featureName) {
347
+ throw new Error('Please specify a feature name. Example: plankit archive my-feature');
348
+ }
349
+ assertSafeName(featureName, 'feature name');
137
350
 
138
- const readmeContent = `# Feature: ${featureName}
351
+ const { config } = loadConfig(io.cwd);
352
+ moveFeatureToArchive(io.cwd, config, featureName, parsed.dryRun);
353
+ io.stdout(`Feature "${featureName}" archived.`);
354
+ }
139
355
 
140
- ## Overview
141
- Detailed description of the feature goal and scope.
356
+ function moveFeatureToArchive(cwd, config, featureName, dryRun = false) {
357
+ const sourceDir = requireFeatureDir(cwd, config, featureName);
358
+ const targetDir = safeJoin(cwd, config.artifactsDir, 'archived', featureName);
142
359
 
143
- ## Phase Breakdown & Progress Status
360
+ if (fs.existsSync(targetDir)) {
361
+ throw new Error(`Archived feature "${featureName}" already exists.`);
362
+ }
144
363
 
145
- - [ ] Phase 1: Foundation & Setup — ([Spec](phases/phase-1-foundation.md))
146
- - [ ] Phase 2: Core Implementation
147
- - [ ] Phase 3: Testing & Verification
364
+ if (!dryRun) {
365
+ ensureDir(path.dirname(targetDir));
366
+ fs.renameSync(sourceDir, targetDir);
367
+ }
368
+ }
148
369
 
149
- ## Phase Execution Matrix
370
+ function requireFeatureDir(cwd, config, featureName) {
371
+ assertSafeName(featureName, 'feature name');
372
+ const featureDir = safeJoin(cwd, config.artifactsDir, 'current', featureName);
150
373
 
151
- | Phase # | Phase Title | Spec Path | Output Path | Status |
152
- |---|---|---|---|---|
153
- | Phase 1 | Foundation & Setup | \`phases/phase-1-foundation.md\` | \`outputs/phase-1-output.md\` | **PENDING** |
154
- `;
374
+ if (!fs.existsSync(featureDir)) {
375
+ throw new Error(`Active feature "${featureName}" not found in ${path.join(config.artifactsDir, 'current')}/`);
376
+ }
155
377
 
156
- const phase1Spec = `# Phase 1: Foundation & Setup
378
+ return featureDir;
379
+ }
157
380
 
158
- ## 1. Objective
159
- Establish base structure and foundational dependencies for ${featureName}.
381
+ function findPhaseSpec(featureDir, phaseNumber) {
382
+ const phasesDir = path.join(featureDir, 'phases');
383
+ const prefix = `phase-${phaseNumber}-`;
384
+ const file = fs.readdirSync(phasesDir).find((name) => name.startsWith(prefix) && name.endsWith('.md'));
160
385
 
161
- ## 2. Tasks
162
- - [ ] Initial setup
163
- - [ ] Basic implementation
164
- `;
386
+ if (!file) {
387
+ throw new Error(`Phase ${phaseNumber} spec not found under ${path.relative(process.cwd(), phasesDir)}`);
388
+ }
165
389
 
166
- writeFile(path.join(featureDir, 'README.md'), readmeContent);
167
- writeFile(path.join(featureDir, 'phases', 'phase-1-foundation.md'), phase1Spec);
168
- writeFile(path.join(featureDir, 'outputs', '.gitkeep'), '');
390
+ return path.join(phasesDir, file);
391
+ }
169
392
 
170
- console.log(`\n✅ Feature "${featureName}" initialized successfully!`);
171
- console.log(` - Spec: artifacts/current/${featureName}/phases/phase-1-foundation.md`);
172
- console.log(` - Overview: artifacts/current/${featureName}/README.md`);
393
+ function readPhaseTitle(phaseSpec, phaseNumber) {
394
+ const firstLine = fs.readFileSync(phaseSpec, 'utf8').split(/\r?\n/, 1)[0];
395
+ return firstLine.replace(/^#\s*Phase\s+\d+:\s*/i, '') || `Phase ${phaseNumber}`;
173
396
  }
174
397
 
175
- function showStatus() {
176
- const cwd = process.cwd();
177
- const currentDir = path.join(cwd, 'artifacts', 'current');
178
- const archivedDir = path.join(cwd, 'artifacts', 'archived');
398
+ function markReadmeArchived(readmePath, dryRun = false) {
399
+ if (!fs.existsSync(readmePath) || dryRun) {
400
+ return;
401
+ }
402
+
403
+ const content = fs.readFileSync(readmePath, 'utf8');
404
+ const updated = content.replace(/^# Feature: (.+?)(?: \[ARCHIVED\])?$/m, '# Feature: $1 [ARCHIVED]');
405
+ fs.writeFileSync(readmePath, updated, 'utf8');
406
+ }
179
407
 
180
- console.log('📊 PlanKit Feature Status Summary:\n');
408
+ function runVerificationCommand(command, io) {
409
+ io.stdout(`Running: ${command}`);
410
+ const result = spawnSync(command, {
411
+ cwd: io.cwd,
412
+ shell: true,
413
+ stdio: 'inherit'
414
+ });
181
415
 
182
- if (fs.existsSync(currentDir)) {
183
- const currentFeatures = fs.readdirSync(currentDir).filter(f => !f.startsWith('.'));
184
- console.log('📌 ACTIVE FEATURES (artifacts/current/):');
185
- if (currentFeatures.length === 0) {
186
- console.log(' (none)');
187
- } else {
188
- currentFeatures.forEach(f => console.log(` - 🟡 ${f}`));
416
+ if (result.status !== 0) {
417
+ throw new Error(`Verification command failed: ${command}`);
418
+ }
419
+ }
420
+
421
+ function installGlobalGeminiSkills(config, options) {
422
+ const globalSkillsDir = path.join(os.homedir(), '.gemini', 'skills');
423
+
424
+ return GEMINI_SKILL_ENTRIES.map(([name, description]) => writeManagedFile(
425
+ path.join(globalSkillsDir, name, 'SKILL.md'),
426
+ `---\nname: ${name}\ndescription: ${description}\n---\n\n# ${name}\n\n${description}\n\nSpecification: ${config.artifactsDir}/PLANKIT.md\n`,
427
+ options
428
+ ));
429
+ }
430
+
431
+ function createFeatureState(featureName, phases) {
432
+ return {
433
+ feature: featureName,
434
+ status: 'active',
435
+ createdAt: new Date().toISOString(),
436
+ phases: phases.map((phase, index) => ({
437
+ number: index + 1,
438
+ title: phase.title,
439
+ specPath: `phases/${phaseFileName(index + 1, phase)}`,
440
+ outputPath: `outputs/phase-${index + 1}-output.md`,
441
+ status: 'pending'
442
+ }))
443
+ };
444
+ }
445
+
446
+ function phaseFileName(phaseNumber, phase) {
447
+ return `phase-${phaseNumber}-${phase.slug}.md`;
448
+ }
449
+
450
+ function readFeatureAndPhaseArgs(parsed, options = {}) {
451
+ const featureName = parsed.positionals[0];
452
+ const rawPhase = parsed.positionals[1] || parsed.flags.phase;
453
+
454
+ if (!featureName) {
455
+ throw new Error('Please specify a feature name.');
456
+ }
457
+ assertSafeName(featureName, 'feature name');
458
+
459
+ if (!rawPhase && options.phaseOptional) {
460
+ return { featureName, phaseNumber: null };
461
+ }
462
+
463
+ const phaseNumber = Number.parseInt(rawPhase, 10);
464
+ if (!Number.isInteger(phaseNumber) || phaseNumber <= 0) {
465
+ throw new Error('Please specify a positive phase number.');
466
+ }
467
+
468
+ return { featureName, phaseNumber };
469
+ }
470
+
471
+ async function resolveAgent(parsed, config, io, configPath = null) {
472
+ const fromFlag = parsed.flags.agent || parsed.flags.agents;
473
+ if (fromFlag) {
474
+ const parts = String(fromFlag).split(',').map((item) => item.trim()).filter(Boolean);
475
+ if (parts.length > 1) {
476
+ throw new Error(`Select a single agent, got ${parts.length}. Use --agent <name>. Valid agents: ${validAgentNames()}.`);
189
477
  }
478
+ const agent = getAgent(parts[0]);
479
+ io.stdout(`Using agent from --agent flag: "${agent.id}".`);
480
+ return agent;
190
481
  }
191
482
 
192
- console.log('');
483
+ if (configPath) {
484
+ const configured = resolveConfiguredAgent(config);
485
+ if (configured) {
486
+ io.stdout(`Using configured agent "${configured.id}" from plan config.`);
487
+ return configured;
488
+ }
489
+ }
193
490
 
194
- if (fs.existsSync(archivedDir)) {
195
- const archivedFeatures = fs.readdirSync(archivedDir).filter(f => !f.startsWith('.'));
196
- console.log('📦 ARCHIVED FEATURES (artifacts/archived/):');
197
- if (archivedFeatures.length === 0) {
198
- console.log(' (none)');
199
- } else {
200
- archivedFeatures.forEach(f => console.log(` - 🟢 ${f}`));
491
+ if (io.isTTY && io.promptChoice) {
492
+ const agent = await promptAgent(io);
493
+ if (agent) {
494
+ io.stdout(`Selected agent: "${agent.id}".`);
495
+ return agent;
201
496
  }
202
497
  }
498
+
499
+ const agent = getAgent(DEFAULT_AGENT);
500
+ io.stdout(`No agent selected — using default agent "${agent.id}". Pass --agent <name> to change it.`);
501
+ return agent;
203
502
  }
204
503
 
205
- function archiveFeature(featureName) {
206
- const cwd = process.cwd();
207
- const sourceDir = path.join(cwd, 'artifacts', 'current', featureName);
208
- const targetDir = path.join(cwd, 'artifacts', 'archived', featureName);
504
+ async function promptAgent(io) {
505
+ const agents = listAgents();
506
+ const header = agents.map((agent, index) => (
507
+ ` ${index + 1}. ${agent.label} ${agent.description}`
508
+ )).join('\n');
509
+ const answer = await io.promptChoice(
510
+ `Select your coding agent:\n${header}\n\nEnter a number 1-${agents.length} or a name`,
511
+ agents.map((agent) => agent.id)
512
+ );
513
+ if (!answer) {
514
+ return null;
515
+ }
516
+ const trimmed = answer.trim().toLowerCase();
517
+ const num = Number.parseInt(trimmed, 10);
518
+ if (Number.isInteger(num) && num >= 1 && num <= agents.length) {
519
+ return agents[num - 1];
520
+ }
521
+ return getAgent(trimmed) || null;
522
+ }
523
+
524
+ function writeOptions(parsed, config) {
525
+ return {
526
+ force: Boolean(parsed.flags.force) || config.overwrite === 'force',
527
+ dryRun: Boolean(parsed.flags['dry-run'])
528
+ };
529
+ }
530
+
531
+ function printResults(results, io) {
532
+ for (const result of results.filter(Boolean)) {
533
+ if (result.action === 'info') {
534
+ io.stdout(` ${'info'.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path} (${result.message || ''})`);
535
+ continue;
536
+ }
537
+ if (result.action === 'copied' && Array.isArray(result.copies)) {
538
+ io.stdout(` ${'copied'.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path}/`);
539
+ for (const copy of result.copies.filter(Boolean)) {
540
+ io.stdout(` ${' '.padEnd(9)} ${path.relative(io.cwd, copy.path) || copy.path}`);
541
+ }
542
+ continue;
543
+ }
544
+ io.stdout(` ${result.action.padEnd(9)} ${path.relative(io.cwd, result.path) || result.path}`);
545
+ }
546
+ }
547
+
548
+ function parseArgs(args) {
549
+ const [command, ...rest] = args;
550
+ const flags = {};
551
+ const positionals = [];
552
+
553
+ for (let index = 0; index < rest.length; index += 1) {
554
+ const arg = rest[index];
209
555
 
210
- if (!fs.existsSync(sourceDir)) {
211
- console.error(`Error: Active feature "${featureName}" not found in artifacts/current/`);
212
- process.exit(1);
556
+ if (!arg.startsWith('--')) {
557
+ positionals.push(arg);
558
+ continue;
559
+ }
560
+
561
+ const flag = arg.slice(2);
562
+ const [inlineName, inlineValue] = flag.split('=', 2);
563
+ if (inlineValue !== undefined) {
564
+ flags[inlineName] = inlineValue;
565
+ continue;
566
+ }
567
+
568
+ const next = rest[index + 1];
569
+ if (next && !next.startsWith('--')) {
570
+ flags[inlineName] = next;
571
+ index += 1;
572
+ } else {
573
+ flags[inlineName] = true;
574
+ }
213
575
  }
214
576
 
215
- ensureDir(path.dirname(targetDir));
216
- fs.renameSync(sourceDir, targetDir);
577
+ return {
578
+ command,
579
+ flags,
580
+ positionals,
581
+ dryRun: Boolean(flags['dry-run'])
582
+ };
583
+ }
584
+
585
+ function createIo(context) {
586
+ const input = context.input || process.stdin;
587
+ const output = context.output || process.stdout;
588
+ const io = {
589
+ cwd: context.cwd || process.cwd(),
590
+ stdout: context.stdout || ((message) => console.log(message)),
591
+ stderr: context.stderr || ((message) => console.error(message)),
592
+ exit: context.exit || ((code) => process.exit(code)),
593
+ isTTY: context.isTTY !== undefined ? context.isTTY : Boolean(output && output.isTTY)
594
+ };
595
+
596
+ if (context.promptResponses !== undefined || input && input.isTTY) {
597
+ io.promptChoice = makePromptChoice(input, io.stdout, context.promptResponses);
598
+ }
599
+
600
+ return io;
601
+ }
602
+
603
+ function makePromptChoice(input, write, promptResponses) {
604
+ if (Array.isArray(promptResponses)) {
605
+ let index = 0;
606
+ return (prompt) => {
607
+ write(prompt);
608
+ const answer = promptResponses[index];
609
+ index += 1;
610
+ return answer === undefined ? '' : String(answer);
611
+ };
612
+ }
217
613
 
218
- console.log(`✅ Archived feature "${featureName}" -> artifacts/archived/${featureName}`);
614
+ return async (prompt) => {
615
+ write(prompt);
616
+ const readline = (await import('node:readline/promises')).default;
617
+ const rl = readline.createInterface({ input, output: process.stdout });
618
+ const answer = await rl.question('');
619
+ rl.close();
620
+ return answer;
621
+ };
219
622
  }
220
623
 
221
- function showHelp() {
222
- console.log(`
624
+ function showHelp(io) {
625
+ io.stdout(`
223
626
  PlanKit CLI — Command Suite for AI Coding Assistants
224
627
 
225
628
  Usage:
226
- npx plankit <command> [options]
629
+ plankit <command> [options]
227
630
 
228
631
  Commands:
229
- init Initialize PlanKit rules & commands in the current project
230
- plan <feature-name> Create a new feature workspace under artifacts/current/<feature-name>/
231
- status Show status of all active and archived features
232
- archive <feature-name> Move a completed feature to artifacts/archived/<feature-name>/
233
- help Show this help message
632
+ init Initialize PlanKit rules, config, templates, and artifact folders
633
+ plan <feature> [requirements] Create a feature workspace under artifacts/current/<feature>/
634
+ clarify <feature> [phase] Create a clarification artifact for a feature
635
+ implement <feature> <phase> Validate phase context and prepare phase output artifact
636
+ review <feature> Run verification and archive a completed feature
637
+ status [--json] Show active and archived features
638
+ archive <feature> Move a feature to archived without running verification
639
+ help Show this help message
640
+
641
+ Options:
642
+ --force Overwrite generated files where safe
643
+ --dry-run Show intended actions without writing
644
+ --agent <name> Select your coding agent during init
645
+ (opencode, gemini, codex, cursor; default: opencode)
646
+ --global-gemini-skills Also install Gemini skills under the user profile
647
+ --phases 3 Generate N default phases during plan
648
+ --phases "Design,Build,Test" Generate named phases during plan
649
+ --requirements "text" Set feature requirements during plan
650
+ --test-command "command" Override review test command
651
+ --build-command "command" Override review build command
652
+ --skip-test Skip review test command
653
+ --skip-build Skip review build command
234
654
  `);
235
655
  }