Skip to content

Commit 58ff71e

Browse files
committed
feat: add issue planner - generate plans from GitHub Issues
New `skillkit issue plan` and `skillkit issue list` commands that fetch GitHub Issues via `gh` CLI and generate StructuredPlans. Parses checkboxes, file mentions, and labels from issue body. 29 new tests, all passing.
1 parent f655113 commit 58ff71e

File tree

10 files changed

+906
-0
lines changed

10 files changed

+906
-0
lines changed

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,14 @@ skillkit find <query> # Quick search
246246
skillkit scan <path> # Security scan for skills
247247
```
248248

249+
### Issue Planner
250+
251+
```bash
252+
skillkit issue plan "#42" # Plan from GitHub Issue
253+
skillkit issue plan owner/repo#42 # Cross-repo plan
254+
skillkit issue list # List open issues
255+
```
256+
249257
### Advanced
250258

251259
```bash

apps/skillkit/src/cli.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,8 @@ import {
116116
AgentsMdInitCommand,
117117
AgentsMdSyncCommand,
118118
AgentsMdShowCommand,
119+
IssuePlanCommand,
120+
IssueListCommand,
119121
} from '@skillkit/cli';
120122

121123
const __filename = fileURLToPath(import.meta.url);
@@ -258,4 +260,7 @@ cli.register(AgentsMdInitCommand);
258260
cli.register(AgentsMdSyncCommand);
259261
cli.register(AgentsMdShowCommand);
260262

263+
cli.register(IssuePlanCommand);
264+
cli.register(IssueListCommand);
265+
261266
cli.runExit(process.argv.slice(2));

docs/fumadocs/content/docs/commands.mdx

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,41 @@ skillkit serve --cache-ttl 3600000
343343
| GET | `/health` | Server health |
344344
| GET | `/cache/stats` | Cache statistics |
345345

346+
## Issue Planner
347+
348+
Generate structured plans from GitHub Issues. Requires the `gh` CLI.
349+
350+
```bash
351+
skillkit issue plan "#42" # Plan from current repo
352+
skillkit issue plan rohitg00/skillkit#42 # Cross-repo
353+
skillkit issue plan "#42" --agent cursor # Target specific agent
354+
skillkit issue plan "#42" --no-tests # Skip test steps
355+
skillkit issue plan "#42" --json # JSON output
356+
skillkit issue plan "#42" --tech-stack react,ts # Override tech stack
357+
```
358+
359+
### Issue Plan Options
360+
361+
| Option | Short | Default | Purpose |
362+
|--------|-------|---------|---------|
363+
| `--agent` | `-a` | `claude-code` | Target agent |
364+
| `--output` | `-o` | `.skillkit/plans/issue-<n>.md` | Output file path |
365+
| `--no-tests` || `false` | Skip test steps |
366+
| `--json` || `false` | Output as JSON |
367+
| `--tech-stack` ||| Comma-separated tech stack |
368+
369+
### Issue List
370+
371+
```bash
372+
skillkit issue list # List open issues
373+
skillkit issue list --repo owner/repo # Specific repo
374+
skillkit issue list --label bug # Filter by label
375+
skillkit issue list --limit 20 # More results
376+
skillkit issue list --json # JSON output
377+
```
378+
379+
The planner extracts task lists (checkboxes) from the issue body, infers file mentions from backtick-quoted paths, and maps labels to tags (e.g., `bug``fix`, `enhancement``feature`). Generated plans work with `skillkit plan validate` and `skillkit plan execute`.
380+
346381
## Utility Commands
347382

348383
```bash

docs/skillkit/components/Commands.tsx

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,17 @@ const COMMAND_GROUPS: CommandGroup[] = [
9393
{ cmd: 'validate', desc: 'Validate format' },
9494
],
9595
},
96+
{
97+
name: 'Issues',
98+
commands: [
99+
{ cmd: 'issue plan "#42"', desc: 'Plan from issue' },
100+
{ cmd: 'issue plan owner/repo#42', desc: 'Cross-repo plan' },
101+
{ cmd: 'issue plan "#42" --agent cursor', desc: 'Target agent' },
102+
{ cmd: 'issue plan "#42" --json', desc: 'JSON output' },
103+
{ cmd: 'issue list', desc: 'List open issues' },
104+
{ cmd: 'issue list --label bug', desc: 'Filter by label' },
105+
],
106+
},
96107
{
97108
name: 'Advanced',
98109
commands: [

packages/cli/src/commands/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,7 @@ export { SkillMdValidateCommand, SkillMdInitCommand, SkillMdCheckCommand } from
125125
// API server
126126
export { ServeCommand } from './serve.js';
127127
export { ScanCommand } from './scan.js';
128+
export { IssuePlanCommand, IssueListCommand } from './issue.js';
128129
export { DoctorCommand } from './doctor.js';
129130
export { SaveCommand } from './save.js';
130131
export {

packages/cli/src/commands/issue.ts

Lines changed: 278 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,278 @@
1+
import { Command, Option } from 'clipanion';
2+
import { execFileSync } from 'node:child_process';
3+
import { writeFile, mkdir } from 'node:fs/promises';
4+
import { resolve, dirname } from 'node:path';
5+
import {
6+
createIssuePlanner,
7+
createPlanGenerator,
8+
createPlanValidator,
9+
} from '@skillkit/core';
10+
11+
export class IssuePlanCommand extends Command {
12+
static paths = [['issue', 'plan']];
13+
14+
static usage = Command.Usage({
15+
category: 'Development',
16+
description: 'Generate a structured plan from a GitHub Issue',
17+
details: `
18+
Fetches a GitHub Issue and generates a StructuredPlan that can be
19+
validated and executed with existing plan commands.
20+
21+
Requires the \`gh\` CLI to be installed and authenticated.
22+
23+
Examples:
24+
$ skillkit issue plan "#42"
25+
$ skillkit issue plan rohitg00/skillkit#42
26+
$ skillkit issue plan "#42" --agent cursor --no-tests
27+
$ skillkit issue plan "#42" --json
28+
`,
29+
examples: [
30+
['Plan from current repo issue', '$0 issue plan "#42"'],
31+
['Plan from specific repo', '$0 issue plan rohitg00/skillkit#42'],
32+
['Plan with JSON output', '$0 issue plan "#42" --json'],
33+
],
34+
});
35+
36+
ref = Option.String({ required: true });
37+
agent = Option.String('--agent,-a', 'claude-code', {
38+
description: 'Target agent',
39+
});
40+
output = Option.String('--output,-o', {
41+
description: 'Output file path (default: .skillkit/plans/issue-<n>.md)',
42+
});
43+
noTests = Option.Boolean('--no-tests', false, {
44+
description: 'Skip adding test steps',
45+
});
46+
json = Option.Boolean('--json', false, {
47+
description: 'Output as JSON',
48+
});
49+
techStack = Option.String('--tech-stack', {
50+
description: 'Comma-separated tech stack',
51+
});
52+
53+
async execute(): Promise<number> {
54+
try {
55+
execFileSync('gh', ['--version'], { encoding: 'utf-8', timeout: 5_000 });
56+
} catch {
57+
this.context.stderr.write(
58+
'Error: GitHub CLI (gh) is not installed or not in PATH.\n' +
59+
'Install it from https://cli.github.com/\n'
60+
);
61+
return 1;
62+
}
63+
64+
const planner = createIssuePlanner();
65+
66+
let issue;
67+
try {
68+
issue = planner.fetchIssue(this.ref);
69+
} catch (err) {
70+
const message = err instanceof Error ? err.message : String(err);
71+
this.context.stderr.write(`Error fetching issue: ${message}\n`);
72+
return 1;
73+
}
74+
75+
const techStackArr = this.techStack
76+
? this.techStack.split(',').map((s) => s.trim())
77+
: undefined;
78+
79+
const plan = planner.generatePlan(issue, {
80+
agent: this.agent,
81+
techStack: techStackArr,
82+
includeTests: !this.noTests,
83+
});
84+
85+
const validator = createPlanValidator();
86+
const validation = validator.validate(plan);
87+
88+
const generator = createPlanGenerator();
89+
const markdown = generator.toMarkdown(plan);
90+
91+
const outputPath =
92+
this.output || resolve(`.skillkit/plans/issue-${issue.number}.md`);
93+
94+
await mkdir(dirname(outputPath), { recursive: true });
95+
await writeFile(outputPath, markdown, 'utf-8');
96+
97+
if (this.json) {
98+
this.context.stdout.write(
99+
JSON.stringify(
100+
{
101+
issue: {
102+
number: issue.number,
103+
title: issue.title,
104+
url: issue.url,
105+
state: issue.state,
106+
labels: issue.labels,
107+
},
108+
plan: {
109+
name: plan.name,
110+
goal: plan.goal,
111+
tasks: plan.tasks.map((t) => ({
112+
id: t.id,
113+
name: t.name,
114+
files: t.files,
115+
steps: t.steps.length,
116+
status: t.status,
117+
})),
118+
metadata: plan.metadata,
119+
},
120+
validation: {
121+
valid: validation.valid,
122+
issues: validation.issues.length,
123+
stats: validation.stats,
124+
},
125+
outputPath,
126+
},
127+
null,
128+
2
129+
) + '\n'
130+
);
131+
} else {
132+
this.context.stdout.write(`\n Issue #${issue.number}: ${issue.title}\n\n`);
133+
this.context.stdout.write(` Generated Plan: ${outputPath}\n\n`);
134+
this.context.stdout.write(` Tasks (${plan.tasks.length})\n`);
135+
for (const task of plan.tasks) {
136+
const fileList: string[] = [];
137+
if (task.files.create) {
138+
fileList.push(...task.files.create.map((f) => `${f} (create)`));
139+
}
140+
if (task.files.modify) {
141+
fileList.push(...task.files.modify.map((f) => `${f} (modify)`));
142+
}
143+
this.context.stdout.write(` ${task.id}. ${task.name}\n`);
144+
if (fileList.length > 0) {
145+
this.context.stdout.write(
146+
` Files: ${fileList.join(', ')}\n`
147+
);
148+
}
149+
}
150+
151+
if (plan.tags && plan.tags.length > 0) {
152+
this.context.stdout.write(
153+
`\n Labels: ${issue.labels.join(', ')} -> ${plan.tags.join(', ')}\n`
154+
);
155+
}
156+
this.context.stdout.write(` Agent: ${this.agent}\n\n`);
157+
158+
if (!validation.valid) {
159+
this.context.stdout.write(
160+
` Warnings: ${validation.issues.length}\n`
161+
);
162+
}
163+
164+
this.context.stdout.write(
165+
` Next: skillkit plan validate -f ${outputPath}\n` +
166+
` skillkit plan execute -f ${outputPath} --dry-run\n\n`
167+
);
168+
}
169+
170+
return 0;
171+
}
172+
}
173+
174+
export class IssueListCommand extends Command {
175+
static paths = [['issue', 'list']];
176+
177+
static usage = Command.Usage({
178+
category: 'Development',
179+
description: 'List open GitHub Issues',
180+
details: `
181+
Lists open issues from a GitHub repository. Useful for picking
182+
which issue to plan.
183+
184+
Examples:
185+
$ skillkit issue list
186+
$ skillkit issue list --repo rohitg00/skillkit --label bug
187+
$ skillkit issue list --limit 20 --json
188+
`,
189+
examples: [
190+
['List issues in current repo', '$0 issue list'],
191+
['Filter by label', '$0 issue list --label enhancement'],
192+
],
193+
});
194+
195+
repo = Option.String('--repo,-r', {
196+
description: 'Repository (owner/repo)',
197+
});
198+
label = Option.String('--label,-l', {
199+
description: 'Filter by label',
200+
});
201+
limit = Option.String('--limit', '10', {
202+
description: 'Maximum issues to list',
203+
});
204+
json = Option.Boolean('--json', false, {
205+
description: 'Output as JSON',
206+
});
207+
208+
async execute(): Promise<number> {
209+
try {
210+
execFileSync('gh', ['--version'], { encoding: 'utf-8', timeout: 5_000 });
211+
} catch {
212+
this.context.stderr.write(
213+
'Error: GitHub CLI (gh) is not installed or not in PATH.\n'
214+
);
215+
return 1;
216+
}
217+
218+
const args = [
219+
'issue',
220+
'list',
221+
'--limit',
222+
this.limit,
223+
'--json',
224+
'number,title,labels,assignees',
225+
'--state',
226+
'open',
227+
];
228+
229+
if (this.repo) {
230+
args.push('--repo', this.repo);
231+
}
232+
if (this.label) {
233+
args.push('--label', this.label);
234+
}
235+
236+
let result: string;
237+
try {
238+
result = execFileSync('gh', args, {
239+
encoding: 'utf-8',
240+
timeout: 15_000,
241+
});
242+
} catch (err) {
243+
const message = err instanceof Error ? err.message : String(err);
244+
this.context.stderr.write(`Error listing issues: ${message}\n`);
245+
return 1;
246+
}
247+
248+
const issues = JSON.parse(result) as Array<{
249+
number: number;
250+
title: string;
251+
labels: Array<{ name: string }>;
252+
assignees: Array<{ login: string }>;
253+
}>;
254+
255+
if (this.json) {
256+
this.context.stdout.write(JSON.stringify(issues, null, 2) + '\n');
257+
} else {
258+
if (issues.length === 0) {
259+
this.context.stdout.write(' No open issues found.\n');
260+
return 0;
261+
}
262+
263+
this.context.stdout.write('\n Open Issues\n\n');
264+
for (const issue of issues) {
265+
const labels = issue.labels.map((l) => l.name).join(', ');
266+
const labelStr = labels ? ` [${labels}]` : '';
267+
this.context.stdout.write(
268+
` #${issue.number} ${issue.title}${labelStr}\n`
269+
);
270+
}
271+
this.context.stdout.write(
272+
`\n Use: skillkit issue plan "#<number>" to generate a plan\n\n`
273+
);
274+
}
275+
276+
return 0;
277+
}
278+
}

0 commit comments

Comments
 (0)