@slahon/lazykit 1.1.1 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,72 +1,132 @@
1
- # 🦄 LazyKit
2
-
3
- **Drop an issue, get a PR.**
4
-
5
- LazyKit wires Claude AI into your GitHub repo so that when you label an issue, Claude reads it, writes the code, and opens a pull request — while you do something else.
6
-
7
- ## Quickstart
8
-
9
- ```bash
10
- npx @slahon/lazykit@latest init
11
- ```
12
-
13
- Run this inside your project folder. It will:
14
- - Create `.github/workflows/lazykit.yml`
15
- - Create `CLAUDE.md` with project context for Claude
16
- - Create the trigger label on GitHub
17
- - Print the one manual step (adding your Claude token)
18
-
19
- ## Requirements
20
-
21
- - Node.js 18+
22
- - A GitHub repository
23
- - A Claude Pro or Max subscription ([claude.ai](https://claude.ai))
24
- - Claude Code installed locally (`npm install -g @anthropic-ai/claude-code`)
25
- - GitHub CLI (`gh`) — optional but recommended
26
-
27
- ## How it works
28
-
29
- 1. You open a GitHub issue describing what you want
30
- 2. You apply the `lazykit` label (or whatever you named it)
31
- 3. A GitHub Actions workflow fires and runs Claude Code
32
- 4. Claude reads the issue, explores your codebase, writes the changes
33
- 5. Claude opens a pull request with a title and description
34
- 6. You review and merge
35
-
36
- ## Authentication
37
-
38
- LazyKit uses your Claude Pro/Max subscription via an OAuth token — no pay-per-token API billing.
39
-
40
- After running `npx lazykit init`, you need to:
41
-
42
- 1. Generate your token:
43
- ```bash
44
- claude setup-token
45
- ```
46
-
47
- 2. Add it to your repo as a secret named `CLAUDE_CODE_OAUTH_TOKEN`
48
-
49
- ## CLAUDE.md
50
-
51
- LazyKit creates a `CLAUDE.md` file at your repo root. This is Claude's project guide — it tells Claude about your stack, conventions, and what commands to run before opening a PR. Edit it to match your actual project.
52
-
53
- ## Options
54
-
55
- During `npx lazykit init` you will be asked:
56
-
57
- | Option | Default | Description |
58
- |--------|---------|-------------|
59
- | Label name | `lazykit` | The GitHub label that triggers Claude |
60
- | Stack | — | Your tech stack (goes into CLAUDE.md) |
61
- | Lint command | `npm run lint` | Run before every PR |
62
- | Test command | `npm test` | Run before every PR |
63
-
64
- ## Tips
65
-
66
- - **Keep issues small and specific.** "Add a /health endpoint that returns `{ status: 'ok' }`" works great. "Rewrite the auth system" does not.
67
- - **Edit CLAUDE.md** to describe your folder structure, naming conventions, and any rules Claude must follow.
68
- - The `lazykit` label is your control switch — Claude only runs when you apply it, so you stay in charge.
69
-
70
- ## License
71
-
72
- MIT
1
+ # 🦄 LazyKit
2
+
3
+ **Drop an issue, get a PR.**
4
+
5
+ LazyKit wires Claude AI into your GitHub repo so that when you open an issue, Claude reads it, writes the code, and opens a pull request — while you do something else.
6
+
7
+ ## Quickstart
8
+
9
+ ```bash
10
+ npx @slahon/lazykit@latest init
11
+ ```
12
+
13
+ Run this inside your project folder. That's it. LazyKit handles everything else automatically.
14
+
15
+ ## Requirements
16
+
17
+ - Node.js 18+
18
+ - A GitHub repository with a remote set up
19
+ - A Claude Pro or Max subscription ([claude.ai](https://claude.ai))
20
+ - Claude Code installed locally (`npm install -g @anthropic-ai/claude-code`)
21
+ - GitHub CLI (`gh`) — required for full automation (`brew install gh` / [cli.github.com](https://cli.github.com))
22
+
23
+ ## How it works
24
+
25
+ 1. You open a GitHub issue using the **LazyKit Task** template
26
+ 2. Claude starts working on it automatically (or when you apply the label — your choice)
27
+ 3. Claude reads the issue, explores your codebase, writes the changes
28
+ 4. Claude opens a pull request with a title and description
29
+ 5. You review and merge
30
+
31
+ You can also mention `@claude` in any issue comment to give follow-up instructions or re-trigger Claude mid-thread.
32
+
33
+ ## What `init` does
34
+
35
+ Running `npx @slahon/lazykit@latest init` fully sets up your repo — no manual steps required:
36
+
37
+ | Step | What happens |
38
+ |------|-------------|
39
+ | Detects repo | Reads your `git remote` to find your GitHub repo |
40
+ | Detects stack | Auto-detects your tech stack from `package.json`, `go.mod`, `Cargo.toml`, etc. |
41
+ | Creates workflow | `.github/workflows/lazykit.yml` — the GitHub Actions automation |
42
+ | Creates issue template | `.github/ISSUE_TEMPLATE/lazykit.md` — auto-applies the trigger label |
43
+ | Creates CLAUDE.md | Project guide so Claude understands your codebase |
44
+ | Creates label | Creates the trigger label on GitHub |
45
+ | Enables PR creation | Grants Actions permission to open pull requests |
46
+ | Sets token | Runs `claude setup-token` and stores it as `CLAUDE_CODE_OAUTH_TOKEN` in your repo secrets |
47
+ | Commits and pushes | Commits all generated files and pushes to GitHub |
48
+
49
+ ## Commands
50
+
51
+ ```bash
52
+ npx @slahon/lazykit@latest init # Set up LazyKit in your repo
53
+ npx @slahon/lazykit@latest status # Check if everything is wired up correctly
54
+ npx @slahon/lazykit@latest update # Regenerate workflow and CLAUDE.md
55
+ npx @slahon/lazykit@latest remove # Remove LazyKit from your repo
56
+ ```
57
+
58
+ ### Flags
59
+
60
+ ```bash
61
+ npx @slahon/lazykit@latest init --dry-run # Preview what would happen without writing files
62
+ npx @slahon/lazykit@latest update --dry-run # Preview changes without applying them
63
+ ```
64
+
65
+ ### `lazykit status`
66
+
67
+ Runs a health check and reports:
68
+
69
+ - Workflow file present
70
+ - Issue template present
71
+ - CLAUDE.md present
72
+ - `CLAUDE_CODE_OAUTH_TOKEN` secret exists (with age warning if over 6 months old)
73
+ - Trigger label exists on GitHub
74
+ - Actions PR creation permission is enabled
75
+ - Branch protection status on `main`
76
+
77
+ ### `lazykit update`
78
+
79
+ Re-generates `.github/workflows/lazykit.yml` (and optionally `CLAUDE.md`) without re-doing the full setup. Useful when you want to pull in changes to the workflow template. Reads your existing label name and trigger mode from the current workflow file.
80
+
81
+ ### `lazykit remove`
82
+
83
+ Cleanly removes LazyKit from your repo:
84
+ - Deletes the workflow file, issue template, and optionally CLAUDE.md
85
+ - Deletes the trigger label from GitHub
86
+ - Deletes the `CLAUDE_CODE_OAUTH_TOKEN` secret
87
+ - Commits and pushes the removals
88
+
89
+ ## Init options
90
+
91
+ During `npx @slahon/lazykit@latest init` you will be asked:
92
+
93
+ | Option | Default | Description |
94
+ |--------|---------|-------------|
95
+ | Label name | `lazykit` | The GitHub label that triggers Claude |
96
+ | Auto-trigger | Yes | Trigger Claude on every new issue, or only when you apply the label |
97
+ | Generate CLAUDE.md | Yes | Create a project guide for Claude |
98
+
99
+ ## Trigger modes
100
+
101
+ **Auto (default)** — Claude fires the moment a new issue is opened. No label needed.
102
+
103
+ **Label-controlled** — Claude only runs when you apply the trigger label. Use this when you want to review issues before handing them to Claude.
104
+
105
+ ## Authentication
106
+
107
+ LazyKit uses your Claude Pro/Max subscription via an OAuth token — no pay-per-token API billing.
108
+
109
+ `init` handles this automatically: it runs `claude setup-token`, captures the token, and stores it as `CLAUDE_CODE_OAUTH_TOKEN` in your repo secrets via `gh secret set`.
110
+
111
+ If the token can't be set automatically (Claude Code not installed or `gh` not available), you'll see step-by-step instructions to do it manually.
112
+
113
+ **Token expiry:** OAuth tokens can expire. Run `lazykit status` to check the age of your token. If it's expired, re-run `npx @slahon/lazykit@latest init` to generate and store a fresh one.
114
+
115
+ ## CLAUDE.md
116
+
117
+ LazyKit creates a `CLAUDE.md` file at your repo root. This is Claude's project guide — it tells Claude about your stack, coding conventions, and rules to follow. Edit it to match your actual project for best results.
118
+
119
+ ## Branch protection
120
+
121
+ If your `main` branch has protection rules enabled, Claude's pull requests will be opened but **cannot be auto-merged** — they will require manual review and approval. LazyKit detects this during `init` and `status` and warns you.
122
+
123
+ ## Tips
124
+
125
+ - **Keep issues small and specific.** "Add a `/health` endpoint that returns `{ status: 'ok' }`" works great. "Rewrite the auth system" does not.
126
+ - **Edit CLAUDE.md** to describe your folder structure, naming conventions, and any rules Claude must follow.
127
+ - **Use `@claude` in comments** to give Claude follow-up instructions or corrections without opening a new issue.
128
+ - **Run `lazykit status`** if something stops working — it pinpoints exactly what's misconfigured.
129
+
130
+ ## License
131
+
132
+ MIT
package/git.js CHANGED
@@ -91,4 +91,13 @@ function detectStack() {
91
91
  return stack.length > 0 ? stack.join(' + ') : null;
92
92
  }
93
93
 
94
- module.exports = { getGitRemote, parseGitHubRepo, isGitRepo, isGhCliAvailable, isGhAuthenticated, detectStack };
94
+ function hasBranchProtection({ owner, repo }) {
95
+ try {
96
+ execSync(`gh api repos/${owner}/${repo}/branches/main/protection`, { stdio: 'pipe' });
97
+ return true;
98
+ } catch {
99
+ return false;
100
+ }
101
+ }
102
+
103
+ module.exports = { getGitRemote, parseGitHubRepo, isGitRepo, isGhCliAvailable, isGhAuthenticated, detectStack, hasBranchProtection };
package/index.js CHANGED
@@ -3,14 +3,36 @@
3
3
  'use strict';
4
4
 
5
5
  const { init } = require('./init');
6
+ const { update } = require('./update');
7
+ const { status } = require('./status');
8
+ const { remove } = require('./remove');
6
9
 
7
- const [,, command] = process.argv;
10
+ const [,, command, ...args] = process.argv;
11
+ const dryRun = args.includes('--dry-run');
8
12
 
9
13
  switch (command) {
10
14
  case 'init':
11
15
  case undefined:
12
- init().catch(err => {
13
- console.error('\nāŒ Setup failed:', err.message);
16
+ init({ dryRun }).catch(err => {
17
+ console.error('\n Setup failed:', err.message);
18
+ process.exit(1);
19
+ });
20
+ break;
21
+ case 'update':
22
+ update({ dryRun }).catch(err => {
23
+ console.error('\n Update failed:', err.message);
24
+ process.exit(1);
25
+ });
26
+ break;
27
+ case 'status':
28
+ status().catch(err => {
29
+ console.error('\n Status check failed:', err.message);
30
+ process.exit(1);
31
+ });
32
+ break;
33
+ case 'remove':
34
+ remove().catch(err => {
35
+ console.error('\n Remove failed:', err.message);
14
36
  process.exit(1);
15
37
  });
16
38
  break;
@@ -19,6 +41,12 @@ switch (command) {
19
41
  🦄 LazyKit — Drop an issue, get a PR.
20
42
 
21
43
  Usage:
22
- npx lazykit init Set up LazyKit in your current GitHub repo
44
+ npx @slahon/lazykit@latest init Set up LazyKit in your current GitHub repo
45
+ npx @slahon/lazykit@latest update Regenerate workflow and CLAUDE.md
46
+ npx @slahon/lazykit@latest status Check if everything is wired up correctly
47
+ npx @slahon/lazykit@latest remove Remove LazyKit from your repo
48
+
49
+ Flags:
50
+ --dry-run Preview what would happen without writing files (init, update)
23
51
  `);
24
52
  }
package/init.js CHANGED
@@ -7,28 +7,30 @@ const chalk = require('chalk');
7
7
  const ora = require('ora');
8
8
 
9
9
  const { log } = require('./logger');
10
- const { ask, confirm, select } = require('./prompt');
11
- const { isGitRepo, getGitRemote, parseGitHubRepo, isGhCliAvailable, isGhAuthenticated, detectStack } = require('./git');
10
+ const { ask, confirm } = require('./prompt');
11
+ const { isGitRepo, getGitRemote, parseGitHubRepo, isGhCliAvailable, isGhAuthenticated, detectStack, hasBranchProtection } = require('./git');
12
12
  const { generateWorkflow } = require('./workflow');
13
13
  const { generateClaudeMd } = require('./claude-md');
14
+ const { generateIssueTemplate } = require('./issue-template');
15
+
16
+ async function init({ dryRun = false } = {}) {
17
+ if (dryRun) console.log(chalk.yellow('\n [dry-run] Preview mode — no files will be written or API calls made.\n'));
14
18
 
15
- async function init() {
16
19
  console.log(chalk.bold.white('\n🦄 LazyKit — Drop an issue, get a PR.\n'));
17
20
  console.log(chalk.gray(' This will set up AI-powered issue-to-PR automation in your repo.\n'));
18
21
 
19
- // ─── Step 1: Verify git repo ─────────────────────────────────────────────
22
+ // ─── Step 1: Verify git repo ──────────────────────────────────────────────
20
23
  if (!isGitRepo()) {
21
24
  log.error('Not inside a git repository.');
22
25
  console.log(chalk.gray('\n LazyKit needs a git repo with a GitHub remote. To set one up:\n'));
23
26
  console.log(chalk.cyan(' git init'));
24
27
  console.log(chalk.cyan(' git remote add origin https://github.com/<you>/<repo>'));
25
- console.log(chalk.gray('\n Then re-run: ') + chalk.cyan('npx @slahon/lazykit init\n'));
28
+ console.log(chalk.gray('\n Then re-run: ') + chalk.cyan('npx @slahon/lazykit@latest init\n'));
26
29
  process.exit(1);
27
30
  }
28
31
 
29
32
  const remote = getGitRemote();
30
33
  const repoInfo = parseGitHubRepo(remote);
31
-
32
34
  const stack = detectStack();
33
35
 
34
36
  if (repoInfo) {
@@ -39,11 +41,11 @@ async function init() {
39
41
  console.log(chalk.gray('\n LazyKit requires a GitHub repository with a remote set up.\n'));
40
42
  console.log(chalk.gray(' To fix this, connect your repo to GitHub first:\n'));
41
43
  console.log(chalk.cyan(' git remote add origin https://github.com/<you>/<repo>'));
42
- console.log(chalk.gray('\n Then re-run: ') + chalk.cyan('npx @slahon/lazykit init\n'));
44
+ console.log(chalk.gray('\n Then re-run: ') + chalk.cyan('npx @slahon/lazykit@latest init\n'));
43
45
  process.exit(1);
44
46
  }
45
47
 
46
- // ─── Step 2: Check gh CLI and auth ──────────────────────────────────────
48
+ // ─── Step 2: Check gh CLI and auth ───────────────────────────────────────
47
49
  const ghAvailable = isGhCliAvailable();
48
50
  let ghReady = false;
49
51
 
@@ -68,51 +70,66 @@ async function init() {
68
70
 
69
71
  log.blank();
70
72
 
71
- // ─── Step 3: Ask questions ───────────────────────────────────────────────
73
+ // ─── Step 3: Ask questions ────────────────────────────────────────────────
72
74
  log.title('Configure LazyKit');
73
75
  log.blank();
74
76
 
75
77
  const label = await ask('Label name to trigger Claude', 'lazykit');
76
-
77
78
  const autoTrigger = await confirm('Trigger Claude on every new issue automatically? (no = only when you apply the label)', true);
78
-
79
79
  const wantClaudeMd = await confirm('Generate CLAUDE.md project guide?', true);
80
80
 
81
81
  log.blank();
82
82
 
83
- // ─── Step 4: Create workflow file ────────────────────────────────────────
84
- const spinner = ora({ text: 'Creating workflow file...', color: 'cyan' }).start();
85
-
83
+ // ─── Step 4: Create workflow file ─────────────────────────────────────────
86
84
  const workflowDir = path.join(process.cwd(), '.github', 'workflows');
87
- fs.mkdirSync(workflowDir, { recursive: true });
88
-
89
85
  const workflowPath = path.join(workflowDir, 'lazykit.yml');
90
- const workflowContent = generateWorkflow({ label, autoTrigger });
91
- fs.writeFileSync(workflowPath, workflowContent);
92
86
 
93
- spinner.succeed(chalk.green('Created .github/workflows/lazykit.yml'));
87
+ if (dryRun) {
88
+ log.info('[dry-run] Would create: .github/workflows/lazykit.yml');
89
+ } else {
90
+ const spinner = ora({ text: 'Creating workflow file...', color: 'cyan' }).start();
91
+ fs.mkdirSync(workflowDir, { recursive: true });
92
+ fs.writeFileSync(workflowPath, generateWorkflow({ label, autoTrigger }));
93
+ spinner.succeed(chalk.green('Created .github/workflows/lazykit.yml'));
94
+ }
95
+
96
+ // ─── Step 5: Create issue template ───────────────────────────────────────
97
+ const templateDir = path.join(process.cwd(), '.github', 'ISSUE_TEMPLATE');
98
+ const templatePath = path.join(templateDir, 'lazykit.md');
94
99
 
95
- // ─── Step 5: Create CLAUDE.md ────────────────────────────────────────────
100
+ if (dryRun) {
101
+ log.info('[dry-run] Would create: .github/ISSUE_TEMPLATE/lazykit.md');
102
+ } else {
103
+ const spinner = ora({ text: 'Creating issue template...', color: 'cyan' }).start();
104
+ fs.mkdirSync(templateDir, { recursive: true });
105
+ fs.writeFileSync(templatePath, generateIssueTemplate({ label }));
106
+ spinner.succeed(chalk.green('Created .github/ISSUE_TEMPLATE/lazykit.md'));
107
+ }
108
+
109
+ // ─── Step 6: Create CLAUDE.md ─────────────────────────────────────────────
96
110
  if (wantClaudeMd) {
97
111
  const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
98
- const claudeMdContent = generateClaudeMd({ stack });
99
112
 
100
- if (fs.existsSync(claudeMdPath)) {
113
+ if (dryRun) {
114
+ log.info('[dry-run] Would create: CLAUDE.md');
115
+ } else if (fs.existsSync(claudeMdPath)) {
101
116
  const overwrite = await confirm('CLAUDE.md already exists. Overwrite?', false);
102
117
  if (overwrite) {
103
- fs.writeFileSync(claudeMdPath, claudeMdContent);
118
+ fs.writeFileSync(claudeMdPath, generateClaudeMd({ stack }));
104
119
  log.success('Updated CLAUDE.md');
105
120
  } else {
106
121
  log.warn('Skipped CLAUDE.md — keeping existing file.');
107
122
  }
108
123
  } else {
109
- fs.writeFileSync(claudeMdPath, claudeMdContent);
124
+ fs.writeFileSync(claudeMdPath, generateClaudeMd({ stack }));
110
125
  log.success('Created CLAUDE.md');
111
126
  }
112
127
  }
113
128
 
114
- // ─── Step 6: Create GitHub label ─────────────────────────────────────────
115
- if (ghReady) {
129
+ // ─── Step 7: Create GitHub label ──────────────────────────────────────────
130
+ if (dryRun) {
131
+ log.info(`[dry-run] Would create GitHub label: '${label}'`);
132
+ } else if (ghReady) {
116
133
  const labelSpinner = ora({ text: `Creating '${label}' label on GitHub...`, color: 'cyan' }).start();
117
134
  try {
118
135
  execSync(
@@ -125,15 +142,17 @@ async function init() {
125
142
  if (msg.includes('already exists')) {
126
143
  labelSpinner.succeed(chalk.green(`Label '${label}' already exists — skipping`));
127
144
  } else {
128
- labelSpinner.warn(chalk.yellow(`Could not create label automatically — you'll need to create it manually`));
145
+ labelSpinner.warn(chalk.yellow(`Could not create label — create it manually at https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`));
129
146
  }
130
147
  }
131
148
  } else {
132
149
  log.warn(`Create the '${label}' label manually at: ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`)}`);
133
150
  }
134
151
 
135
- // ─── Step 7: Enable Actions to create PRs ────────────────────────────────
136
- if (ghReady) {
152
+ // ─── Step 8: Enable Actions PR creation ───────────────────────────────────
153
+ if (dryRun) {
154
+ log.info('[dry-run] Would enable Actions PR creation permission');
155
+ } else if (ghReady) {
137
156
  const prSpinner = ora({ text: 'Enabling Actions PR creation permission...', color: 'cyan' }).start();
138
157
  try {
139
158
  execSync(
@@ -154,89 +173,109 @@ async function init() {
154
173
  }
155
174
  }
156
175
 
157
- // ─── Step 8: Generate and set Claude token ───────────────────────────────
158
- console.log('\n' + chalk.bold.green(' ✨ Almost there — setting up your Claude token...\n'));
159
-
160
- let tokenSet = false;
161
-
162
- try {
163
- log.info('Running claude setup-token (a browser window may open to authenticate)...');
164
- console.log();
165
-
166
- const result = spawnSync('claude', ['setup-token'], {
167
- stdio: ['inherit', 'pipe', 'pipe'],
168
- encoding: 'utf8',
169
- });
170
-
171
- if (result.stderr) process.stderr.write(result.stderr);
176
+ // ─── Step 9: Branch protection warning ────────────────────────────────────
177
+ if (!dryRun && ghReady && hasBranchProtection(repoInfo)) {
178
+ log.warn('Branch protection is enabled on main.');
179
+ console.log(chalk.gray(" Claude's PRs will be opened but cannot be auto-merged — they require manual review.\n"));
180
+ }
172
181
 
173
- const output = (result.stdout || '') + (result.stderr || '');
174
- const tokenMatch = output.match(/sk-ant-oat[^\s]+/);
182
+ // ─── Step 10: Generate and set Claude token ────────────────────────────────
183
+ if (dryRun) {
184
+ log.info('[dry-run] Would run claude setup-token and set CLAUDE_CODE_OAUTH_TOKEN secret');
185
+ } else {
186
+ console.log('\n' + chalk.bold.green(' ✨ Almost there — setting up your Claude token...\n'));
175
187
 
176
- if (tokenMatch && ghReady) {
177
- const token = tokenMatch[0];
178
- const secretSpinner = ora({ text: 'Adding CLAUDE_CODE_OAUTH_TOKEN to GitHub secrets...', color: 'cyan' }).start();
179
- execSync(
180
- `gh secret set CLAUDE_CODE_OAUTH_TOKEN --body "${token}" --repo ${repoInfo.owner}/${repoInfo.repo}`,
181
- { stdio: 'pipe' }
182
- );
183
- secretSpinner.succeed(chalk.green('CLAUDE_CODE_OAUTH_TOKEN added to GitHub secrets'));
184
- tokenSet = true;
185
- } else if (tokenMatch && !ghReady) {
186
- log.warn('Token generated but gh CLI not found — add it manually:');
187
- console.log(chalk.gray(' Name: ') + chalk.cyan('CLAUDE_CODE_OAUTH_TOKEN'));
188
- console.log(chalk.gray(' Value: ') + chalk.cyan(tokenMatch[0]));
189
- console.log(chalk.cyan(` https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`));
190
- } else {
191
- throw new Error('Token not found in output');
192
- }
193
- } catch (err) {
194
- const isNotFound = err.status === 'ENOENT' || (err.message || '').includes('ENOENT');
195
- if (isNotFound) {
196
- log.warn('Claude Code CLI not found — install it first:');
197
- console.log(chalk.cyan('\n npm install -g @anthropic-ai/claude-code'));
198
- } else {
199
- log.warn('Could not set token automatically.');
188
+ try {
189
+ log.info('Running claude setup-token (a browser window may open to authenticate)...');
190
+ console.log();
191
+
192
+ const result = spawnSync('claude', ['setup-token'], {
193
+ stdio: ['inherit', 'pipe', 'pipe'],
194
+ encoding: 'utf8',
195
+ });
196
+
197
+ if (result.stderr) process.stderr.write(result.stderr);
198
+
199
+ const output = (result.stdout || '') + (result.stderr || '');
200
+ const tokenMatch = output.match(/sk-ant-oat[^\s]+/);
201
+
202
+ if (tokenMatch && ghReady) {
203
+ const secretSpinner = ora({ text: 'Adding CLAUDE_CODE_OAUTH_TOKEN to GitHub secrets...', color: 'cyan' }).start();
204
+ execSync(
205
+ `gh secret set CLAUDE_CODE_OAUTH_TOKEN --body "${tokenMatch[0]}" --repo ${repoInfo.owner}/${repoInfo.repo}`,
206
+ { stdio: 'pipe' }
207
+ );
208
+ secretSpinner.succeed(chalk.green('CLAUDE_CODE_OAUTH_TOKEN added to GitHub secrets'));
209
+ } else if (tokenMatch && !ghReady) {
210
+ log.warn('Token generated but gh CLI not available — add it manually:');
211
+ console.log(chalk.gray(' Name: ') + chalk.cyan('CLAUDE_CODE_OAUTH_TOKEN'));
212
+ console.log(chalk.gray(' Value: ') + chalk.cyan(tokenMatch[0]));
213
+ console.log(chalk.cyan(` https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`));
214
+ } else {
215
+ throw new Error('Token not found in output');
216
+ }
217
+ } catch (err) {
218
+ const isNotFound = (err.message || '').includes('ENOENT');
219
+ if (isNotFound) {
220
+ log.warn('Claude Code CLI not found — install it first:');
221
+ console.log(chalk.cyan('\n npm install -g @anthropic-ai/claude-code'));
222
+ } else {
223
+ log.warn('Could not set token automatically.');
224
+ }
225
+ console.log(chalk.gray('\n Add it manually:'));
226
+ console.log(chalk.gray(' 1. Run: ') + chalk.cyan('claude setup-token'));
227
+ console.log(chalk.gray(' 2. Go to: ') + chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`));
228
+ console.log(chalk.gray(' 3. Add secret: ') + chalk.cyan('CLAUDE_CODE_OAUTH_TOKEN') + chalk.gray(' = the token you copied\n'));
200
229
  }
201
- console.log(chalk.gray('\n Add it manually:'));
202
- console.log(chalk.gray(' 1. Run: ') + chalk.cyan('claude setup-token'));
203
- console.log(chalk.gray(' 2. Go to: ') + chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`));
204
- console.log(chalk.gray(' 3. Add secret:') + chalk.cyan(' CLAUDE_CODE_OAUTH_TOKEN') + chalk.gray(' = the token you copied\n'));
205
230
  }
206
231
 
207
232
  log.divider();
208
233
 
209
- // ─── Step 9: Commit and push ──────────────────────────────────────────────
210
- const autoPush = await confirm('Commit and push the generated files now?', true);
211
-
212
- if (autoPush) {
213
- const pushSpinner = ora({ text: 'Committing and pushing...', color: 'cyan' }).start();
214
- try {
215
- const files = ['.github/workflows/lazykit.yml', ...(wantClaudeMd ? ['CLAUDE.md'] : [])].join(' ');
216
- execSync(`git add ${files}`, { stdio: 'pipe' });
217
- execSync(`git commit -m "Add LazyKit automation"`, { stdio: 'pipe' });
218
- execSync(`git push`, { stdio: 'pipe' });
219
- pushSpinner.succeed(chalk.green('Committed and pushed — workflow is live'));
220
- } catch (err) {
221
- pushSpinner.fail(chalk.red('Push failed.'));
222
- console.log(chalk.gray('\n Push it manually:\n'));
223
- console.log(chalk.cyan(' git add .github/workflows/lazykit.yml' + (wantClaudeMd ? ' CLAUDE.md' : '')));
234
+ // ─── Step 11: Commit and push ──────────────────────────────────────────────
235
+ if (dryRun) {
236
+ log.info('[dry-run] Would commit and push generated files');
237
+ log.blank();
238
+ } else {
239
+ const autoPush = await confirm('Commit and push the generated files now?', true);
240
+
241
+ if (autoPush) {
242
+ const pushSpinner = ora({ text: 'Committing and pushing...', color: 'cyan' }).start();
243
+ try {
244
+ const files = [
245
+ '.github/workflows/lazykit.yml',
246
+ '.github/ISSUE_TEMPLATE/lazykit.md',
247
+ ...(wantClaudeMd ? ['CLAUDE.md'] : []),
248
+ ].join(' ');
249
+ execSync(`git add ${files}`, { stdio: 'pipe' });
250
+ execSync(`git commit -m "Add LazyKit automation"`, { stdio: 'pipe' });
251
+ execSync(`git push`, { stdio: 'pipe' });
252
+ pushSpinner.succeed(chalk.green('Committed and pushed — workflow is live'));
253
+ } catch {
254
+ pushSpinner.fail(chalk.red('Push failed.'));
255
+ console.log(chalk.gray('\n Push it manually:\n'));
256
+ console.log(chalk.cyan(` git add .github/workflows/lazykit.yml .github/ISSUE_TEMPLATE/lazykit.md${wantClaudeMd ? ' CLAUDE.md' : ''}`));
257
+ console.log(chalk.cyan(' git commit -m "Add LazyKit automation"'));
258
+ console.log(chalk.cyan(' git push\n'));
259
+ }
260
+ } else {
261
+ console.log(chalk.bold('\n Push the files yourself when ready:\n'));
262
+ console.log(chalk.cyan(` git add .github/workflows/lazykit.yml .github/ISSUE_TEMPLATE/lazykit.md${wantClaudeMd ? ' CLAUDE.md' : ''}`));
224
263
  console.log(chalk.cyan(' git commit -m "Add LazyKit automation"'));
225
- console.log(chalk.cyan(' git push\n'));
264
+ console.log(chalk.cyan(' git push'));
226
265
  }
227
- } else {
228
- console.log(chalk.bold('\n Push the files yourself when ready:\n'));
229
- console.log(chalk.cyan(' git add .github/workflows/lazykit.yml' + (wantClaudeMd ? ' CLAUDE.md' : '')));
230
- console.log(chalk.cyan(' git commit -m "Add LazyKit automation"'));
231
- console.log(chalk.cyan(' git push'));
232
266
  }
233
267
 
234
268
  log.divider();
235
269
 
236
270
  console.log(chalk.bold('\n Done! Here is how to use it:\n'));
237
- console.log(` ${chalk.gray('1.')} Open a GitHub issue describing what you want`);
238
- console.log(` ${chalk.gray('2.')} Apply the ${chalk.bold.magenta(label)} label`);
239
- console.log(` ${chalk.gray('3.')} Watch Claude open a PR with the changes\n`);
271
+ console.log(` ${chalk.gray('1.')} Open a GitHub issue using the ${chalk.bold.magenta('LazyKit Task')} template`);
272
+ if (autoTrigger) {
273
+ console.log(` ${chalk.gray('2.')} Claude starts working on it automatically`);
274
+ } else {
275
+ console.log(` ${chalk.gray('2.')} Apply the ${chalk.bold.magenta(label)} label to trigger Claude`);
276
+ }
277
+ console.log(` ${chalk.gray('3.')} Review and merge the pull request`);
278
+ console.log(`\n ${chalk.gray('Tip:')} Mention ${chalk.bold.cyan('@claude')} in any issue comment to give follow-up instructions.\n`);
240
279
 
241
280
  console.log(chalk.bold.white(' 🦄 LazyKit is ready. Go be lazy.\n'));
242
281
  }
@@ -0,0 +1,24 @@
1
+ 'use strict';
2
+
3
+ function generateIssueTemplate({ label }) {
4
+ return `---
5
+ name: LazyKit Task
6
+ about: Describe a task for Claude to implement
7
+ labels: ${label}
8
+ ---
9
+
10
+ ## What to build
11
+
12
+ <!-- Describe clearly and specifically what you want Claude to implement. Keep it small and focused. -->
13
+
14
+ ## Acceptance criteria
15
+
16
+ <!-- Optional: what does "done" look like? -->
17
+
18
+ ## Notes
19
+
20
+ <!-- Files to touch, things to avoid, constraints, or extra context for Claude. -->
21
+ `;
22
+ }
23
+
24
+ module.exports = { generateIssueTemplate };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@slahon/lazykit",
3
- "version": "1.1.1",
3
+ "version": "1.2.1",
4
4
  "description": "Drop an issue, get a PR. AI-powered issue-to-PR automation using Claude.",
5
5
  "bin": {
6
6
  "lazykit": "./index.js"
package/remove.js ADDED
@@ -0,0 +1,104 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+
9
+ const { log } = require('./logger');
10
+ const { confirm } = require('./prompt');
11
+ const { isGitRepo, getGitRemote, parseGitHubRepo, isGhCliAvailable, isGhAuthenticated } = require('./git');
12
+
13
+ async function remove() {
14
+ console.log(chalk.bold.white('\n🦄 LazyKit Remove\n'));
15
+
16
+ if (!isGitRepo()) {
17
+ log.error('Not inside a git repository.');
18
+ process.exit(1);
19
+ }
20
+
21
+ const repoInfo = parseGitHubRepo(getGitRemote());
22
+ if (!repoInfo) {
23
+ log.error('No GitHub remote detected.');
24
+ process.exit(1);
25
+ }
26
+
27
+ const confirmed = await confirm('This will remove LazyKit from your repo. Continue?', false);
28
+ if (!confirmed) {
29
+ console.log(chalk.gray('\n Aborted.\n'));
30
+ process.exit(0);
31
+ }
32
+
33
+ const ghReady = isGhCliAvailable() && isGhAuthenticated();
34
+ const deleted = [];
35
+
36
+ // Read label name before deleting workflow
37
+ let labelName = 'lazykit';
38
+ const workflowPath = path.join(process.cwd(), '.github', 'workflows', 'lazykit.yml');
39
+ if (fs.existsSync(workflowPath)) {
40
+ const wf = fs.readFileSync(workflowPath, 'utf8');
41
+ const m = wf.match(/label_trigger:\s*(\S+)/);
42
+ if (m) labelName = m[1];
43
+ fs.unlinkSync(workflowPath);
44
+ deleted.push('.github/workflows/lazykit.yml');
45
+ log.success('Removed .github/workflows/lazykit.yml');
46
+ }
47
+
48
+ const templatePath = path.join(process.cwd(), '.github', 'ISSUE_TEMPLATE', 'lazykit.md');
49
+ if (fs.existsSync(templatePath)) {
50
+ fs.unlinkSync(templatePath);
51
+ deleted.push('.github/ISSUE_TEMPLATE/lazykit.md');
52
+ log.success('Removed .github/ISSUE_TEMPLATE/lazykit.md');
53
+ }
54
+
55
+ const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
56
+ if (fs.existsSync(claudeMdPath)) {
57
+ const removeClaude = await confirm('Remove CLAUDE.md too?', false);
58
+ if (removeClaude) {
59
+ fs.unlinkSync(claudeMdPath);
60
+ deleted.push('CLAUDE.md');
61
+ log.success('Removed CLAUDE.md');
62
+ }
63
+ }
64
+
65
+ if (ghReady) {
66
+ const labelSpinner = ora({ text: `Deleting '${labelName}' label from GitHub...`, color: 'cyan' }).start();
67
+ try {
68
+ execSync(`gh label delete "${labelName}" --repo ${repoInfo.owner}/${repoInfo.repo} --yes`, { stdio: 'pipe' });
69
+ labelSpinner.succeed(chalk.green(`Deleted '${labelName}' label from GitHub`));
70
+ } catch {
71
+ labelSpinner.warn(`Could not delete label — remove manually at ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`)}`);
72
+ }
73
+
74
+ const secretSpinner = ora({ text: 'Removing CLAUDE_CODE_OAUTH_TOKEN secret...', color: 'cyan' }).start();
75
+ try {
76
+ execSync(`gh secret delete CLAUDE_CODE_OAUTH_TOKEN --repo ${repoInfo.owner}/${repoInfo.repo}`, { stdio: 'pipe' });
77
+ secretSpinner.succeed(chalk.green('Removed CLAUDE_CODE_OAUTH_TOKEN secret'));
78
+ } catch {
79
+ secretSpinner.warn(`Could not remove secret — delete manually in repo Settings → Secrets`);
80
+ }
81
+ } else {
82
+ log.warn(`Remove the '${labelName}' label manually: ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`)}`);
83
+ log.warn(`Remove the CLAUDE_CODE_OAUTH_TOKEN secret manually: ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`)}`);
84
+ }
85
+
86
+ if (deleted.length > 0) {
87
+ const autoPush = await confirm('Commit and push the removals?', true);
88
+ if (autoPush) {
89
+ const spinner = ora({ text: 'Committing and pushing...', color: 'cyan' }).start();
90
+ try {
91
+ execSync(`git add ${deleted.map(f => `"${f}"`).join(' ')}`, { stdio: 'pipe' });
92
+ execSync(`git commit -m "Remove LazyKit automation"`, { stdio: 'pipe' });
93
+ execSync(`git push`, { stdio: 'pipe' });
94
+ spinner.succeed(chalk.green('Committed and pushed'));
95
+ } catch {
96
+ spinner.fail('Push failed — commit and push manually');
97
+ }
98
+ }
99
+ }
100
+
101
+ console.log(chalk.bold.white('\n LazyKit has been removed.\n'));
102
+ }
103
+
104
+ module.exports = { remove };
package/status.js ADDED
@@ -0,0 +1,113 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+
8
+ const { log } = require('./logger');
9
+ const { isGitRepo, getGitRemote, parseGitHubRepo, isGhCliAvailable, isGhAuthenticated } = require('./git');
10
+
11
+ async function status() {
12
+ console.log(chalk.bold.white('\n🦄 LazyKit Status\n'));
13
+
14
+ if (!isGitRepo()) {
15
+ log.error('Not inside a git repository.');
16
+ return;
17
+ }
18
+
19
+ const repoInfo = parseGitHubRepo(getGitRemote());
20
+ if (!repoInfo) {
21
+ log.error('No GitHub remote detected.');
22
+ return;
23
+ }
24
+
25
+ log.info(`Repo: ${chalk.cyan(`${repoInfo.owner}/${repoInfo.repo}`)}`);
26
+ log.blank();
27
+
28
+ // Local files
29
+ const workflowPath = path.join(process.cwd(), '.github', 'workflows', 'lazykit.yml');
30
+ const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
31
+ const templatePath = path.join(process.cwd(), '.github', 'ISSUE_TEMPLATE', 'lazykit.md');
32
+
33
+ fs.existsSync(workflowPath)
34
+ ? log.success('Workflow file found')
35
+ : log.error('Workflow file missing — run npx @slahon/lazykit@latest init');
36
+
37
+ fs.existsSync(templatePath)
38
+ ? log.success('Issue template found')
39
+ : log.warn('Issue template missing — run npx @slahon/lazykit@latest init');
40
+
41
+ fs.existsSync(claudeMdPath)
42
+ ? log.success('CLAUDE.md found')
43
+ : log.warn('CLAUDE.md not found — Claude has no project context');
44
+
45
+ // Remote checks require gh
46
+ const ghReady = isGhCliAvailable() && isGhAuthenticated();
47
+ if (!ghReady) {
48
+ log.blank();
49
+ log.warn('gh CLI not available or not authenticated — skipping remote checks');
50
+ log.warn('Run: ' + chalk.cyan('gh auth login'));
51
+ console.log();
52
+ return;
53
+ }
54
+
55
+ log.blank();
56
+
57
+ // Secret
58
+ try {
59
+ const secrets = execSync(`gh secret list --repo ${repoInfo.owner}/${repoInfo.repo}`, { stdio: 'pipe' }).toString();
60
+ const match = secrets.match(/CLAUDE_CODE_OAUTH_TOKEN\s+(\S+)/);
61
+ if (match) {
62
+ const updated = new Date(match[1]);
63
+ const monthsOld = (Date.now() - updated.getTime()) / (1000 * 60 * 60 * 24 * 30);
64
+ log.success(`CLAUDE_CODE_OAUTH_TOKEN secret exists ${chalk.gray(`(updated ${match[1]})`)}`);
65
+ if (monthsOld > 6) log.warn('Token is over 6 months old — consider refreshing it with npx @slahon/lazykit@latest init');
66
+ } else {
67
+ log.error('CLAUDE_CODE_OAUTH_TOKEN secret not found — run npx @slahon/lazykit@latest init');
68
+ }
69
+ } catch {
70
+ log.warn('Could not check secrets — insufficient permissions');
71
+ }
72
+
73
+ // Label
74
+ try {
75
+ let labelName = 'lazykit';
76
+ if (fs.existsSync(workflowPath)) {
77
+ const wf = fs.readFileSync(workflowPath, 'utf8');
78
+ const m = wf.match(/label_trigger:\s*(\S+)/);
79
+ if (m) labelName = m[1];
80
+ }
81
+ const labels = execSync(`gh label list --repo ${repoInfo.owner}/${repoInfo.repo}`, { stdio: 'pipe' }).toString();
82
+ labels.includes(labelName)
83
+ ? log.success(`Label '${labelName}' exists on GitHub`)
84
+ : log.error(`Label '${labelName}' not found — run npx @slahon/lazykit@latest init`);
85
+ } catch {
86
+ log.warn('Could not check labels');
87
+ }
88
+
89
+ // PR creation permission
90
+ try {
91
+ const perms = JSON.parse(execSync(
92
+ `gh api repos/${repoInfo.owner}/${repoInfo.repo}/actions/permissions/workflow`,
93
+ { stdio: 'pipe' }
94
+ ).toString());
95
+ perms.can_approve_pull_request_reviews
96
+ ? log.success('Actions can create and approve pull requests')
97
+ : log.warn(`PR creation not enabled — ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/actions`)}`);
98
+ } catch {
99
+ log.warn('Could not check Actions permissions');
100
+ }
101
+
102
+ // Branch protection
103
+ try {
104
+ execSync(`gh api repos/${repoInfo.owner}/${repoInfo.repo}/branches/main/protection`, { stdio: 'pipe' });
105
+ log.warn("Branch protection is on — Claude's PRs require manual review before merge");
106
+ } catch {
107
+ log.success("No branch protection on main — Claude's PRs can be merged directly");
108
+ }
109
+
110
+ console.log();
111
+ }
112
+
113
+ module.exports = { status };
package/update.js ADDED
@@ -0,0 +1,89 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { execSync } = require('child_process');
6
+ const chalk = require('chalk');
7
+ const ora = require('ora');
8
+
9
+ const { log } = require('./logger');
10
+ const { confirm } = require('./prompt');
11
+ const { isGitRepo, getGitRemote, parseGitHubRepo, detectStack } = require('./git');
12
+ const { generateWorkflow } = require('./workflow');
13
+ const { generateClaudeMd } = require('./claude-md');
14
+
15
+ async function update({ dryRun = false } = {}) {
16
+ if (dryRun) console.log(chalk.yellow('\n [dry-run] Preview mode — no files will be written.\n'));
17
+ console.log(chalk.bold.white('\n🦄 LazyKit Update\n'));
18
+
19
+ if (!isGitRepo()) {
20
+ log.error('Not inside a git repository.');
21
+ process.exit(1);
22
+ }
23
+
24
+ const repoInfo = parseGitHubRepo(getGitRemote());
25
+ if (!repoInfo) {
26
+ log.error('No GitHub remote detected.');
27
+ process.exit(1);
28
+ }
29
+
30
+ const workflowPath = path.join(process.cwd(), '.github', 'workflows', 'lazykit.yml');
31
+ const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
32
+
33
+ // Read existing config from workflow file
34
+ let label = 'lazykit';
35
+ let autoTrigger = true;
36
+ if (fs.existsSync(workflowPath)) {
37
+ const wf = fs.readFileSync(workflowPath, 'utf8');
38
+ const m = wf.match(/label_trigger:\s*(\S+)/);
39
+ if (m) label = m[1];
40
+ autoTrigger = wf.includes("github.event.action == 'opened'");
41
+ }
42
+
43
+ const stack = detectStack();
44
+ const filesToPush = [];
45
+
46
+ // Regenerate workflow
47
+ if (dryRun) {
48
+ log.info('[dry-run] Would update: .github/workflows/lazykit.yml');
49
+ } else {
50
+ const spinner = ora({ text: 'Updating workflow file...', color: 'cyan' }).start();
51
+ fs.mkdirSync(path.dirname(workflowPath), { recursive: true });
52
+ fs.writeFileSync(workflowPath, generateWorkflow({ label, autoTrigger }));
53
+ spinner.succeed(chalk.green('Updated .github/workflows/lazykit.yml'));
54
+ filesToPush.push('.github/workflows/lazykit.yml');
55
+ }
56
+
57
+ // Regenerate CLAUDE.md if it exists
58
+ if (fs.existsSync(claudeMdPath)) {
59
+ const regen = await confirm('Regenerate CLAUDE.md?', true);
60
+ if (regen) {
61
+ if (dryRun) {
62
+ log.info('[dry-run] Would update: CLAUDE.md');
63
+ } else {
64
+ fs.writeFileSync(claudeMdPath, generateClaudeMd({ stack }));
65
+ log.success('Updated CLAUDE.md');
66
+ filesToPush.push('CLAUDE.md');
67
+ }
68
+ }
69
+ }
70
+
71
+ if (!dryRun && filesToPush.length > 0) {
72
+ const autoPush = await confirm('Commit and push?', true);
73
+ if (autoPush) {
74
+ const spinner = ora({ text: 'Committing and pushing...', color: 'cyan' }).start();
75
+ try {
76
+ execSync(`git add ${filesToPush.join(' ')}`, { stdio: 'pipe' });
77
+ execSync(`git commit -m "Update LazyKit configuration"`, { stdio: 'pipe' });
78
+ execSync(`git push`, { stdio: 'pipe' });
79
+ spinner.succeed(chalk.green('Committed and pushed'));
80
+ } catch {
81
+ spinner.fail('Push failed — commit and push manually');
82
+ }
83
+ }
84
+ }
85
+
86
+ console.log();
87
+ }
88
+
89
+ module.exports = { update };