@slahon/lazykit 1.0.8 ā 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/git.js +10 -1
- package/index.js +32 -4
- package/init.js +141 -80
- package/issue-template.js +24 -0
- package/package.json +1 -1
- package/remove.js +104 -0
- package/status.js +113 -0
- package/update.js +89 -0
- package/workflow.js +20 -7
package/git.js
CHANGED
|
@@ -91,4 +91,13 @@ function detectStack() {
|
|
|
91
91
|
return stack.length > 0 ? stack.join(' + ') : null;
|
|
92
92
|
}
|
|
93
93
|
|
|
94
|
-
|
|
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
|
|
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
|
|
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
|
|
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,49 +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
|
-
|
|
78
|
+
const autoTrigger = await confirm('Trigger Claude on every new issue automatically? (no = only when you apply the label)', true);
|
|
77
79
|
const wantClaudeMd = await confirm('Generate CLAUDE.md project guide?', true);
|
|
78
80
|
|
|
79
81
|
log.blank();
|
|
80
82
|
|
|
81
|
-
// āāā Step 4: Create workflow file
|
|
82
|
-
const spinner = ora({ text: 'Creating workflow file...', color: 'cyan' }).start();
|
|
83
|
-
|
|
83
|
+
// āāā Step 4: Create workflow file āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
84
84
|
const workflowDir = path.join(process.cwd(), '.github', 'workflows');
|
|
85
|
-
fs.mkdirSync(workflowDir, { recursive: true });
|
|
86
|
-
|
|
87
85
|
const workflowPath = path.join(workflowDir, 'lazykit.yml');
|
|
88
|
-
const workflowContent = generateWorkflow({ label });
|
|
89
|
-
fs.writeFileSync(workflowPath, workflowContent);
|
|
90
86
|
|
|
91
|
-
|
|
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');
|
|
92
99
|
|
|
93
|
-
|
|
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 āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
94
110
|
if (wantClaudeMd) {
|
|
95
111
|
const claudeMdPath = path.join(process.cwd(), 'CLAUDE.md');
|
|
96
|
-
const claudeMdContent = generateClaudeMd({ stack });
|
|
97
112
|
|
|
98
|
-
if (
|
|
113
|
+
if (dryRun) {
|
|
114
|
+
log.info('[dry-run] Would create: CLAUDE.md');
|
|
115
|
+
} else if (fs.existsSync(claudeMdPath)) {
|
|
99
116
|
const overwrite = await confirm('CLAUDE.md already exists. Overwrite?', false);
|
|
100
117
|
if (overwrite) {
|
|
101
|
-
fs.writeFileSync(claudeMdPath,
|
|
118
|
+
fs.writeFileSync(claudeMdPath, generateClaudeMd({ stack }));
|
|
102
119
|
log.success('Updated CLAUDE.md');
|
|
103
120
|
} else {
|
|
104
121
|
log.warn('Skipped CLAUDE.md ā keeping existing file.');
|
|
105
122
|
}
|
|
106
123
|
} else {
|
|
107
|
-
fs.writeFileSync(claudeMdPath,
|
|
124
|
+
fs.writeFileSync(claudeMdPath, generateClaudeMd({ stack }));
|
|
108
125
|
log.success('Created CLAUDE.md');
|
|
109
126
|
}
|
|
110
127
|
}
|
|
111
128
|
|
|
112
|
-
// āāā Step
|
|
113
|
-
if (
|
|
129
|
+
// āāā Step 7: Create GitHub label āāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāāā
|
|
130
|
+
if (dryRun) {
|
|
131
|
+
log.info(`[dry-run] Would create GitHub label: '${label}'`);
|
|
132
|
+
} else if (ghReady) {
|
|
114
133
|
const labelSpinner = ora({ text: `Creating '${label}' label on GitHub...`, color: 'cyan' }).start();
|
|
115
134
|
try {
|
|
116
135
|
execSync(
|
|
@@ -123,15 +142,17 @@ async function init() {
|
|
|
123
142
|
if (msg.includes('already exists')) {
|
|
124
143
|
labelSpinner.succeed(chalk.green(`Label '${label}' already exists ā skipping`));
|
|
125
144
|
} else {
|
|
126
|
-
labelSpinner.warn(chalk.yellow(`Could not create label
|
|
145
|
+
labelSpinner.warn(chalk.yellow(`Could not create label ā create it manually at https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`));
|
|
127
146
|
}
|
|
128
147
|
}
|
|
129
148
|
} else {
|
|
130
149
|
log.warn(`Create the '${label}' label manually at: ${chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/labels`)}`);
|
|
131
150
|
}
|
|
132
151
|
|
|
133
|
-
// āāā Step
|
|
134
|
-
if (
|
|
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) {
|
|
135
156
|
const prSpinner = ora({ text: 'Enabling Actions PR creation permission...', color: 'cyan' }).start();
|
|
136
157
|
try {
|
|
137
158
|
execSync(
|
|
@@ -152,69 +173,109 @@ async function init() {
|
|
|
152
173
|
}
|
|
153
174
|
}
|
|
154
175
|
|
|
155
|
-
// āāā Step
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
try {
|
|
161
|
-
log.info('Running claude setup-token (a browser window may open to authenticate)...');
|
|
162
|
-
console.log();
|
|
163
|
-
|
|
164
|
-
const result = spawnSync('claude', ['setup-token'], {
|
|
165
|
-
stdio: ['inherit', 'pipe', 'pipe'],
|
|
166
|
-
encoding: 'utf8',
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
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
|
+
}
|
|
170
181
|
|
|
171
|
-
|
|
172
|
-
|
|
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'));
|
|
173
187
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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'));
|
|
198
229
|
}
|
|
199
|
-
console.log(chalk.gray('\n Add it manually:'));
|
|
200
|
-
console.log(chalk.gray(' 1. Run: ') + chalk.cyan('claude setup-token'));
|
|
201
|
-
console.log(chalk.gray(' 2. Go to: ') + chalk.cyan(`https://github.com/${repoInfo.owner}/${repoInfo.repo}/settings/secrets/actions`));
|
|
202
|
-
console.log(chalk.gray(' 3. Add secret:') + chalk.cyan(' CLAUDE_CODE_OAUTH_TOKEN') + chalk.gray(' = the token you copied\n'));
|
|
203
230
|
}
|
|
204
231
|
|
|
205
232
|
log.divider();
|
|
206
233
|
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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' : ''}`));
|
|
263
|
+
console.log(chalk.cyan(' git commit -m "Add LazyKit automation"'));
|
|
264
|
+
console.log(chalk.cyan(' git push'));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
211
267
|
|
|
212
268
|
log.divider();
|
|
213
269
|
|
|
214
270
|
console.log(chalk.bold('\n Done! Here is how to use it:\n'));
|
|
215
|
-
console.log(` ${chalk.gray('1.')} Open a GitHub issue
|
|
216
|
-
|
|
217
|
-
|
|
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`);
|
|
218
279
|
|
|
219
280
|
console.log(chalk.bold.white(' 𦄠LazyKit is ready. Go be lazy.\n'));
|
|
220
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
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 };
|
package/workflow.js
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
function generateWorkflow({ label }) {
|
|
3
|
+
function generateWorkflow({ label, autoTrigger }) {
|
|
4
4
|
const checkList = ` 1. Review your changes and make sure no existing functionality is broken.
|
|
5
5
|
2. If you encounter errors you cannot fix, post a comment on the issue explaining what went wrong instead of committing broken code.`;
|
|
6
6
|
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
7
|
+
const trigger = autoTrigger
|
|
8
|
+
? `on:
|
|
9
|
+
issues:
|
|
10
|
+
types: [opened]
|
|
11
|
+
issue_comment:
|
|
12
|
+
types: [created]`
|
|
13
|
+
: `on:
|
|
10
14
|
issues:
|
|
11
15
|
types: [opened, labeled]
|
|
12
16
|
issue_comment:
|
|
13
|
-
types: [created]
|
|
17
|
+
types: [created]`;
|
|
18
|
+
|
|
19
|
+
const condition = autoTrigger
|
|
20
|
+
? `github.event.action == 'opened' ||
|
|
21
|
+
contains(github.event.comment.body, '@claude')`
|
|
22
|
+
: `contains(github.event.issue.labels.*.name, '${label}') ||
|
|
23
|
+
contains(github.event.comment.body, '@claude')`;
|
|
24
|
+
|
|
25
|
+
return `name: LazyKit
|
|
26
|
+
|
|
27
|
+
${trigger}
|
|
14
28
|
|
|
15
29
|
concurrency:
|
|
16
30
|
group: lazykit-\${{ github.event.issue.number }}
|
|
@@ -19,8 +33,7 @@ concurrency:
|
|
|
19
33
|
jobs:
|
|
20
34
|
lazykit:
|
|
21
35
|
if: >
|
|
22
|
-
|
|
23
|
-
contains(github.event.comment.body, '@claude')
|
|
36
|
+
${condition}
|
|
24
37
|
runs-on: ubuntu-latest
|
|
25
38
|
timeout-minutes: 30
|
|
26
39
|
permissions:
|