@metmirr/prlen 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +82 -0
- package/dist/commands/install-skill.js +74 -0
- package/dist/commands/login.js +120 -0
- package/dist/commands/logout.js +11 -0
- package/dist/commands/post.js +82 -0
- package/dist/commands/uninstall-skill.js +34 -0
- package/dist/commands/whoami.js +30 -0
- package/dist/index.js +20 -0
- package/dist/lib/api.js +75 -0
- package/dist/lib/auth.js +47 -0
- package/dist/lib/github.js +137 -0
- package/dist/lib/skills.js +50 -0
- package/dist/utils/ui.js +52 -0
- package/package.json +31 -0
- package/skills/prlen/SKILL.md +113 -0
- package/skills/prlen/reference.md +53 -0
package/README.md
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
# prlen-cli
|
|
2
|
+
|
|
3
|
+
Local TypeScript CLI for PR Lens.
|
|
4
|
+
|
|
5
|
+
## Commands
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install
|
|
9
|
+
npm run build
|
|
10
|
+
node dist/index.js --help
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
### Login
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
prlen login
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
Optional:
|
|
20
|
+
- `PRLEN_BASE_URL=http://127.0.0.1:3000`
|
|
21
|
+
|
|
22
|
+
`prlen login` now opens a browser approval page on your PR Lens site.
|
|
23
|
+
If you're already signed in on the web app, just approve the request there — no GitHub token needed in the CLI.
|
|
24
|
+
|
|
25
|
+
### Install Claude/Codex skill
|
|
26
|
+
|
|
27
|
+
```bash
|
|
28
|
+
prlen install-skill
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
Options:
|
|
32
|
+
- `--target claude`
|
|
33
|
+
- `--target codex`
|
|
34
|
+
- `--target both`
|
|
35
|
+
- `--force`
|
|
36
|
+
- `--check`
|
|
37
|
+
|
|
38
|
+
This installs the bundled PR Lens skill into:
|
|
39
|
+
- Claude Code: `~/.claude/skills/prlen`
|
|
40
|
+
- Codex: `~/.codex/skills/prlen`
|
|
41
|
+
|
|
42
|
+
Check whether it is already installed:
|
|
43
|
+
|
|
44
|
+
```bash
|
|
45
|
+
prlen install-skill --check
|
|
46
|
+
```
|
|
47
|
+
|
|
48
|
+
Remove it again:
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
prlen uninstall-skill
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### Who am I?
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
prlen whoami
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
### Post with your own prompt
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
prlen post 12 --prompt "Add API endpoints for CLI publishing" --yes
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
### Agent mode: pipe the drafted post through stdin
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
printf '%s' "Add API endpoints for CLI publishing with bearer auth, whoami, and a public prompt-template endpoint." \
|
|
70
|
+
| prlen post 12 --prompt-stdin --yes
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
`prlen post` also auto-reads piped stdin when `--prompt` is omitted:
|
|
74
|
+
|
|
75
|
+
```bash
|
|
76
|
+
printf '%s' "Add API endpoints for CLI publishing with bearer auth and PR metadata validation." \
|
|
77
|
+
| prlen post 12 --yes
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Notes:
|
|
81
|
+
- `post` currently supports manual prompt input first: `--prompt`, `--prompt-stdin`, or piped stdin.
|
|
82
|
+
- Full LLM-provider generation is the next slice.
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { cp, rm } from 'node:fs/promises';
|
|
2
|
+
import { ensureTargetRoot, getSkillStatus, normalizeTargets, packageSkillDir, } from '../lib/skills.js';
|
|
3
|
+
import { printError, printMuted, printSuccess } from '../utils/ui.js';
|
|
4
|
+
async function installTarget(target, force) {
|
|
5
|
+
const sourceDir = packageSkillDir();
|
|
6
|
+
const status = await getSkillStatus(target);
|
|
7
|
+
if (status.installed && !force) {
|
|
8
|
+
throw new Error(`${target} skill already exists at ${status.destination}. Re-run with --force to overwrite it.`);
|
|
9
|
+
}
|
|
10
|
+
await ensureTargetRoot(target);
|
|
11
|
+
if (status.installed) {
|
|
12
|
+
await rm(status.destination, { recursive: true, force: true });
|
|
13
|
+
}
|
|
14
|
+
await cp(sourceDir, status.destination, { recursive: true });
|
|
15
|
+
return {
|
|
16
|
+
target,
|
|
17
|
+
destination: status.destination,
|
|
18
|
+
action: status.installed ? 'updated' : 'installed',
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
async function checkTargets(target) {
|
|
22
|
+
let allInstalled = true;
|
|
23
|
+
for (const item of normalizeTargets(target)) {
|
|
24
|
+
const status = await getSkillStatus(item);
|
|
25
|
+
if (status.installed) {
|
|
26
|
+
printSuccess(`Installed for ${item} → ${status.destination}`);
|
|
27
|
+
}
|
|
28
|
+
else {
|
|
29
|
+
printMuted(`Missing for ${item} → ${status.destination}`);
|
|
30
|
+
allInstalled = false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return allInstalled;
|
|
34
|
+
}
|
|
35
|
+
export function registerInstallSkillCommand(program) {
|
|
36
|
+
program
|
|
37
|
+
.command('install-skill')
|
|
38
|
+
.description('Install the PR Lens agent skill for Claude Code and/or Codex')
|
|
39
|
+
.option('--target <target>', 'claude, codex, or both', 'both')
|
|
40
|
+
.option('--force', 'Overwrite an existing installed skill')
|
|
41
|
+
.option('--check', 'Only check whether the skill is already installed')
|
|
42
|
+
.action(async (options) => {
|
|
43
|
+
const target = (options.target || 'both');
|
|
44
|
+
if (!['claude', 'codex', 'both'].includes(target)) {
|
|
45
|
+
printError('Invalid --target value. Use claude, codex, or both.');
|
|
46
|
+
process.exitCode = 1;
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
try {
|
|
50
|
+
if (options.check) {
|
|
51
|
+
const ok = await checkTargets(target);
|
|
52
|
+
process.exitCode = ok ? 0 : 1;
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const results = [];
|
|
56
|
+
for (const item of normalizeTargets(target)) {
|
|
57
|
+
results.push(await installTarget(item, Boolean(options.force)));
|
|
58
|
+
}
|
|
59
|
+
for (const result of results) {
|
|
60
|
+
printSuccess(`${result.action === 'updated' ? 'Updated' : 'Installed'} ${result.target} skill → ${result.destination}`);
|
|
61
|
+
}
|
|
62
|
+
if (results.some((result) => result.target === 'claude')) {
|
|
63
|
+
printMuted('Claude Code loads user skills from ~/.claude/skills and usually hot-reloads them.');
|
|
64
|
+
}
|
|
65
|
+
if (results.some((result) => result.target === 'codex')) {
|
|
66
|
+
printMuted('Restart Codex to pick up the new skill.');
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
catch (error) {
|
|
70
|
+
printError(error instanceof Error ? error.message : 'Failed to install skill');
|
|
71
|
+
process.exitCode = 1;
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import ora from 'ora';
|
|
3
|
+
import { ApiError, exchangeGithubToken, getDefaultBaseUrl, pollCliLogin, startCliLogin, } from '../lib/api.js';
|
|
4
|
+
import { getStoredBaseUrl, saveAuth } from '../lib/auth.js';
|
|
5
|
+
import { printError, printMuted, printSuccess } from '../utils/ui.js';
|
|
6
|
+
class LoginCancelledError extends Error {
|
|
7
|
+
constructor() {
|
|
8
|
+
super('Login cancelled');
|
|
9
|
+
this.name = 'LoginCancelledError';
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
function sleep(ms) {
|
|
13
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
14
|
+
}
|
|
15
|
+
function openBrowser(url) {
|
|
16
|
+
try {
|
|
17
|
+
const child = process.platform === 'darwin'
|
|
18
|
+
? spawn('open', [url], { detached: true, stdio: 'ignore' })
|
|
19
|
+
: process.platform === 'win32'
|
|
20
|
+
? spawn('cmd', ['/c', 'start', '', url], { detached: true, stdio: 'ignore' })
|
|
21
|
+
: spawn('xdg-open', [url], { detached: true, stdio: 'ignore' });
|
|
22
|
+
child.unref();
|
|
23
|
+
return true;
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return false;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
async function loginWithBrowser(baseUrl) {
|
|
30
|
+
const start = await startCliLogin(baseUrl);
|
|
31
|
+
console.log(`Confirmation code: ${start.code}`);
|
|
32
|
+
console.log('Open this URL in your browser:');
|
|
33
|
+
console.log(` ${start.verification_url}`);
|
|
34
|
+
console.log('');
|
|
35
|
+
if (openBrowser(start.verification_url)) {
|
|
36
|
+
printMuted('Opened browser for approval');
|
|
37
|
+
}
|
|
38
|
+
else {
|
|
39
|
+
printMuted('Could not open a browser automatically; open the URL above');
|
|
40
|
+
}
|
|
41
|
+
const spinner = ora(`Waiting for approval (${start.code})...`).start();
|
|
42
|
+
let cancelled = false;
|
|
43
|
+
const handleSignal = () => {
|
|
44
|
+
cancelled = true;
|
|
45
|
+
spinner.stop();
|
|
46
|
+
printMuted('Cancelled');
|
|
47
|
+
};
|
|
48
|
+
process.once('SIGINT', handleSignal);
|
|
49
|
+
process.once('SIGTERM', handleSignal);
|
|
50
|
+
try {
|
|
51
|
+
while (true) {
|
|
52
|
+
if (cancelled) {
|
|
53
|
+
throw new LoginCancelledError();
|
|
54
|
+
}
|
|
55
|
+
const poll = await pollCliLogin(baseUrl, start.challenge_id, start.poll_token);
|
|
56
|
+
if (poll.status === 'approved') {
|
|
57
|
+
spinner.stop();
|
|
58
|
+
await saveAuth({
|
|
59
|
+
sessionToken: poll.session_token,
|
|
60
|
+
username: poll.username,
|
|
61
|
+
baseUrl,
|
|
62
|
+
});
|
|
63
|
+
printSuccess(`Logged in as @${poll.username}`);
|
|
64
|
+
printMuted(`Session expires: ${poll.expires_at}`);
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
await sleep(start.interval_seconds * 1000);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch (error) {
|
|
71
|
+
if (error instanceof LoginCancelledError) {
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
spinner.fail('CLI login failed');
|
|
75
|
+
throw error;
|
|
76
|
+
}
|
|
77
|
+
finally {
|
|
78
|
+
process.removeListener('SIGINT', handleSignal);
|
|
79
|
+
process.removeListener('SIGTERM', handleSignal);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
export function registerLoginCommand(program) {
|
|
83
|
+
program
|
|
84
|
+
.command('login')
|
|
85
|
+
.description('Authenticate by approving this CLI in your PR Lens browser session')
|
|
86
|
+
.option('--base-url <url>', 'PR Lens API base URL')
|
|
87
|
+
.option('--github-token <token>', 'Advanced fallback: exchange an existing GitHub token for a PR Lens session')
|
|
88
|
+
.action(async (options) => {
|
|
89
|
+
try {
|
|
90
|
+
const storedBaseUrl = await getStoredBaseUrl();
|
|
91
|
+
const baseUrl = getDefaultBaseUrl(options.baseUrl || storedBaseUrl);
|
|
92
|
+
if (options.githubToken) {
|
|
93
|
+
const session = await exchangeGithubToken(baseUrl, options.githubToken);
|
|
94
|
+
await saveAuth({
|
|
95
|
+
githubToken: options.githubToken,
|
|
96
|
+
sessionToken: session.session_token,
|
|
97
|
+
username: session.username,
|
|
98
|
+
baseUrl,
|
|
99
|
+
});
|
|
100
|
+
printSuccess(`Logged in as @${session.username}`);
|
|
101
|
+
printMuted(`Session expires: ${session.expires_at}`);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
await loginWithBrowser(baseUrl);
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
if (error instanceof LoginCancelledError) {
|
|
108
|
+
process.exitCode = 130;
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
if (error instanceof ApiError) {
|
|
112
|
+
printError(`${error.message} (${error.status})`);
|
|
113
|
+
process.exitCode = 1;
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
printError(error instanceof Error ? error.message : 'Unknown error');
|
|
117
|
+
process.exitCode = 1;
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { clearAuth } from '../lib/auth.js';
|
|
2
|
+
import { printSuccess } from '../utils/ui.js';
|
|
3
|
+
export function registerLogoutCommand(program) {
|
|
4
|
+
program
|
|
5
|
+
.command('logout')
|
|
6
|
+
.description('Clear stored PR Lens credentials')
|
|
7
|
+
.action(async () => {
|
|
8
|
+
await clearAuth();
|
|
9
|
+
printSuccess('Logged out');
|
|
10
|
+
});
|
|
11
|
+
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import ora from 'ora';
|
|
2
|
+
import { ApiError, createPost, getDefaultBaseUrl, getPullRequestMetadata } from '../lib/api.js';
|
|
3
|
+
import { getStoredBaseUrl, getStoredSessionToken } from '../lib/auth.js';
|
|
4
|
+
import { resolvePullRequestReference } from '../lib/github.js';
|
|
5
|
+
import { confirm, printError, printMuted, printPreviewBox, printSuccess } from '../utils/ui.js';
|
|
6
|
+
async function readPromptFromStdin() {
|
|
7
|
+
if (process.stdin.isTTY) {
|
|
8
|
+
return undefined;
|
|
9
|
+
}
|
|
10
|
+
const chunks = [];
|
|
11
|
+
for await (const chunk of process.stdin) {
|
|
12
|
+
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
13
|
+
}
|
|
14
|
+
const text = Buffer.concat(chunks).toString('utf8').trim();
|
|
15
|
+
return text || undefined;
|
|
16
|
+
}
|
|
17
|
+
export function registerPostCommand(program) {
|
|
18
|
+
program
|
|
19
|
+
.command('post <pr-ref>')
|
|
20
|
+
.description('Publish a PR Lens post from a GitHub PR')
|
|
21
|
+
.option('--prompt <text>', 'Use your own prompt text instead of generating one')
|
|
22
|
+
.option('--prompt-stdin', 'Read the prompt text from stdin (useful for agents)')
|
|
23
|
+
.option('--model <model>', 'Record the model that generated the prompt')
|
|
24
|
+
.option('--yes', 'Skip confirmation')
|
|
25
|
+
.option('--base-url <url>', 'PR Lens API base URL')
|
|
26
|
+
.action(async (prRef, options) => {
|
|
27
|
+
try {
|
|
28
|
+
const storedBaseUrl = await getStoredBaseUrl();
|
|
29
|
+
const baseUrl = getDefaultBaseUrl(options.baseUrl || storedBaseUrl);
|
|
30
|
+
const sessionToken = await getStoredSessionToken();
|
|
31
|
+
if (!sessionToken) {
|
|
32
|
+
throw new Error('Not logged in. Run `prlen login` first.');
|
|
33
|
+
}
|
|
34
|
+
const stdinPrompt = options.promptStdin ? await readPromptFromStdin() : undefined;
|
|
35
|
+
const autoStdinPrompt = !options.prompt && !options.promptStdin ? await readPromptFromStdin() : undefined;
|
|
36
|
+
const prompt = options.prompt?.trim() || stdinPrompt || autoStdinPrompt;
|
|
37
|
+
if (!prompt) {
|
|
38
|
+
throw new Error('Provide prompt text with `--prompt`, `--prompt-stdin`, or piped stdin. LLM generation is next.');
|
|
39
|
+
}
|
|
40
|
+
const resolveSpinner = ora('Resolving pull request...').start();
|
|
41
|
+
const pr = await resolvePullRequestReference(prRef);
|
|
42
|
+
resolveSpinner.succeed(`Resolved ${pr.repoFullName}#${pr.prNumber}`);
|
|
43
|
+
const metadataSpinner = ora('Fetching PR metadata from PR Lens...').start();
|
|
44
|
+
const metadata = await getPullRequestMetadata(baseUrl, sessionToken, pr.repoFullName, pr.prNumber);
|
|
45
|
+
metadataSpinner.stop();
|
|
46
|
+
console.log('Preview:');
|
|
47
|
+
printPreviewBox(prompt);
|
|
48
|
+
printMuted(`PR: ${metadata.repo_full_name}#${metadata.pr_number} — ${metadata.pr_title}`);
|
|
49
|
+
if (!options.yes) {
|
|
50
|
+
const ok = await confirm('Post this? [Y/n]');
|
|
51
|
+
if (!ok) {
|
|
52
|
+
printMuted('Cancelled');
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const publishSpinner = ora('Publishing to PR Lens...').start();
|
|
57
|
+
const response = await createPost(baseUrl, sessionToken, {
|
|
58
|
+
pr_url: metadata.pr_url,
|
|
59
|
+
pr_number: metadata.pr_number,
|
|
60
|
+
pr_title: metadata.pr_title,
|
|
61
|
+
repo_full_name: metadata.repo_full_name,
|
|
62
|
+
prompt,
|
|
63
|
+
languages: metadata.languages,
|
|
64
|
+
files_changed: metadata.files_changed,
|
|
65
|
+
additions: metadata.additions,
|
|
66
|
+
deletions: metadata.deletions,
|
|
67
|
+
model_used: options.model,
|
|
68
|
+
});
|
|
69
|
+
publishSpinner.stop();
|
|
70
|
+
printSuccess(`Published → ${response.url}`);
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
if (error instanceof ApiError) {
|
|
74
|
+
printError(`${error.message} (${error.status})`);
|
|
75
|
+
process.exitCode = 1;
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
printError(error instanceof Error ? error.message : 'Unknown error');
|
|
79
|
+
process.exitCode = 1;
|
|
80
|
+
}
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { printError, printMuted, printSuccess } from '../utils/ui.js';
|
|
2
|
+
import { normalizeTargets, removeInstalledSkill, targetDir } from '../lib/skills.js';
|
|
3
|
+
export function registerUninstallSkillCommand(program) {
|
|
4
|
+
program
|
|
5
|
+
.command('uninstall-skill')
|
|
6
|
+
.description('Remove the PR Lens agent skill from Claude Code and/or Codex')
|
|
7
|
+
.option('--target <target>', 'claude, codex, or both', 'both')
|
|
8
|
+
.action(async (options) => {
|
|
9
|
+
const target = (options.target || 'both');
|
|
10
|
+
if (!['claude', 'codex', 'both'].includes(target)) {
|
|
11
|
+
printError('Invalid --target value. Use claude, codex, or both.');
|
|
12
|
+
process.exitCode = 1;
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
for (const item of normalizeTargets(target)) {
|
|
17
|
+
const removed = await removeInstalledSkill(item);
|
|
18
|
+
if (removed) {
|
|
19
|
+
printSuccess(`Removed ${item} skill → ${targetDir(item)}`);
|
|
20
|
+
}
|
|
21
|
+
else {
|
|
22
|
+
printMuted(`${item} skill was not installed → ${targetDir(item)}`);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
if (target === 'codex' || target === 'both') {
|
|
26
|
+
printMuted('Restart Codex if it is currently running.');
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
catch (error) {
|
|
30
|
+
printError(error instanceof Error ? error.message : 'Failed to uninstall skill');
|
|
31
|
+
process.exitCode = 1;
|
|
32
|
+
}
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { ApiError, getDefaultBaseUrl, getWhoAmI } from '../lib/api.js';
|
|
2
|
+
import { getStoredBaseUrl, getStoredSessionToken } from '../lib/auth.js';
|
|
3
|
+
import { printError } from '../utils/ui.js';
|
|
4
|
+
export function registerWhoamiCommand(program) {
|
|
5
|
+
program
|
|
6
|
+
.command('whoami')
|
|
7
|
+
.description('Show the authenticated PR Lens user')
|
|
8
|
+
.option('--base-url <url>', 'PR Lens API base URL')
|
|
9
|
+
.action(async (options) => {
|
|
10
|
+
try {
|
|
11
|
+
const storedBaseUrl = await getStoredBaseUrl();
|
|
12
|
+
const baseUrl = getDefaultBaseUrl(options.baseUrl || storedBaseUrl);
|
|
13
|
+
const sessionToken = await getStoredSessionToken();
|
|
14
|
+
if (!sessionToken) {
|
|
15
|
+
throw new Error('Not logged in. Run `prlen login` first.');
|
|
16
|
+
}
|
|
17
|
+
const whoami = await getWhoAmI(baseUrl, sessionToken);
|
|
18
|
+
console.log(`@${whoami.username} — ${whoami.connected_repos} connected repos, ${whoami.posts_today} posts today`);
|
|
19
|
+
}
|
|
20
|
+
catch (error) {
|
|
21
|
+
if (error instanceof ApiError) {
|
|
22
|
+
printError(`${error.message} (${error.status})`);
|
|
23
|
+
process.exitCode = 1;
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
printError(error instanceof Error ? error.message : 'Unknown error');
|
|
27
|
+
process.exitCode = 1;
|
|
28
|
+
}
|
|
29
|
+
});
|
|
30
|
+
}
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { Command } from 'commander';
|
|
3
|
+
import { registerInstallSkillCommand } from './commands/install-skill.js';
|
|
4
|
+
import { registerLoginCommand } from './commands/login.js';
|
|
5
|
+
import { registerLogoutCommand } from './commands/logout.js';
|
|
6
|
+
import { registerPostCommand } from './commands/post.js';
|
|
7
|
+
import { registerUninstallSkillCommand } from './commands/uninstall-skill.js';
|
|
8
|
+
import { registerWhoamiCommand } from './commands/whoami.js';
|
|
9
|
+
const program = new Command();
|
|
10
|
+
program
|
|
11
|
+
.name('prlen')
|
|
12
|
+
.description('Publish pull requests to PR Lens from your terminal or coding agent')
|
|
13
|
+
.version('0.1.0');
|
|
14
|
+
registerLoginCommand(program);
|
|
15
|
+
registerWhoamiCommand(program);
|
|
16
|
+
registerLogoutCommand(program);
|
|
17
|
+
registerPostCommand(program);
|
|
18
|
+
registerInstallSkillCommand(program);
|
|
19
|
+
registerUninstallSkillCommand(program);
|
|
20
|
+
program.parseAsync(process.argv);
|
package/dist/lib/api.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
export class ApiError extends Error {
|
|
2
|
+
status;
|
|
3
|
+
code;
|
|
4
|
+
constructor(message, status, code) {
|
|
5
|
+
super(message);
|
|
6
|
+
this.name = 'ApiError';
|
|
7
|
+
this.status = status;
|
|
8
|
+
this.code = code;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
export function normalizeBaseUrl(baseUrl) {
|
|
12
|
+
return baseUrl.replace(/\/$/, '');
|
|
13
|
+
}
|
|
14
|
+
async function request(baseUrl, pathname, init = {}, sessionToken) {
|
|
15
|
+
const headers = new Headers(init.headers);
|
|
16
|
+
headers.set('Accept', 'application/json');
|
|
17
|
+
if (init.body && !headers.has('Content-Type')) {
|
|
18
|
+
headers.set('Content-Type', 'application/json');
|
|
19
|
+
}
|
|
20
|
+
if (sessionToken) {
|
|
21
|
+
headers.set('Authorization', `Bearer ${sessionToken}`);
|
|
22
|
+
}
|
|
23
|
+
const response = await fetch(`${normalizeBaseUrl(baseUrl)}${pathname}`, {
|
|
24
|
+
...init,
|
|
25
|
+
headers,
|
|
26
|
+
});
|
|
27
|
+
const text = await response.text();
|
|
28
|
+
const payload = text ? JSON.parse(text) : {};
|
|
29
|
+
if (!response.ok) {
|
|
30
|
+
throw new ApiError(payload.error || `Request failed with ${response.status}`, response.status, payload.code);
|
|
31
|
+
}
|
|
32
|
+
return payload;
|
|
33
|
+
}
|
|
34
|
+
export function getDefaultBaseUrl(storedBaseUrl) {
|
|
35
|
+
return process.env.PRLEN_BASE_URL || storedBaseUrl || 'https://prlen.dev';
|
|
36
|
+
}
|
|
37
|
+
export async function getClientConfig(baseUrl) {
|
|
38
|
+
return request(baseUrl, '/api/v1/client-config');
|
|
39
|
+
}
|
|
40
|
+
export async function startCliLogin(baseUrl) {
|
|
41
|
+
return request(baseUrl, '/api/v1/auth/cli/start', {
|
|
42
|
+
method: 'POST',
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
export async function pollCliLogin(baseUrl, challengeId, pollToken) {
|
|
46
|
+
return request(baseUrl, '/api/v1/auth/cli/poll', {
|
|
47
|
+
method: 'POST',
|
|
48
|
+
body: JSON.stringify({
|
|
49
|
+
challenge_id: challengeId,
|
|
50
|
+
poll_token: pollToken,
|
|
51
|
+
}),
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
export async function exchangeGithubToken(baseUrl, githubToken) {
|
|
55
|
+
return request(baseUrl, '/api/v1/auth/github', {
|
|
56
|
+
method: 'POST',
|
|
57
|
+
body: JSON.stringify({ github_token: githubToken }),
|
|
58
|
+
});
|
|
59
|
+
}
|
|
60
|
+
export async function getWhoAmI(baseUrl, sessionToken) {
|
|
61
|
+
return request(baseUrl, '/api/v1/whoami', {}, sessionToken);
|
|
62
|
+
}
|
|
63
|
+
export async function getPullRequestMetadata(baseUrl, sessionToken, repoFullName, prNumber) {
|
|
64
|
+
const params = new URLSearchParams({
|
|
65
|
+
repo_full_name: repoFullName,
|
|
66
|
+
pr_number: String(prNumber),
|
|
67
|
+
});
|
|
68
|
+
return request(baseUrl, `/api/v1/pr-metadata?${params.toString()}`, {}, sessionToken);
|
|
69
|
+
}
|
|
70
|
+
export async function createPost(baseUrl, sessionToken, payload) {
|
|
71
|
+
return request(baseUrl, '/api/v1/posts', {
|
|
72
|
+
method: 'POST',
|
|
73
|
+
body: JSON.stringify(payload),
|
|
74
|
+
}, sessionToken);
|
|
75
|
+
}
|
package/dist/lib/auth.js
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
function configDir() {
|
|
5
|
+
const xdgConfigHome = process.env.XDG_CONFIG_HOME;
|
|
6
|
+
return xdgConfigHome ? path.join(xdgConfigHome, 'prlen') : path.join(homedir(), '.config', 'prlen');
|
|
7
|
+
}
|
|
8
|
+
export function getAuthFilePath() {
|
|
9
|
+
return path.join(configDir(), 'auth.json');
|
|
10
|
+
}
|
|
11
|
+
export async function loadAuth() {
|
|
12
|
+
try {
|
|
13
|
+
const raw = await readFile(getAuthFilePath(), 'utf8');
|
|
14
|
+
return JSON.parse(raw);
|
|
15
|
+
}
|
|
16
|
+
catch (error) {
|
|
17
|
+
return {};
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
export async function saveAuth(state) {
|
|
21
|
+
await mkdir(configDir(), { recursive: true });
|
|
22
|
+
await writeFile(getAuthFilePath(), `${JSON.stringify(state, null, 2)}\n`, 'utf8');
|
|
23
|
+
}
|
|
24
|
+
export async function clearAuth() {
|
|
25
|
+
await rm(getAuthFilePath(), { force: true });
|
|
26
|
+
}
|
|
27
|
+
export async function getStoredSessionToken() {
|
|
28
|
+
if (process.env.PRLEN_TOKEN) {
|
|
29
|
+
return process.env.PRLEN_TOKEN;
|
|
30
|
+
}
|
|
31
|
+
const auth = await loadAuth();
|
|
32
|
+
return auth.sessionToken;
|
|
33
|
+
}
|
|
34
|
+
export async function getStoredGithubToken() {
|
|
35
|
+
if (process.env.GITHUB_TOKEN) {
|
|
36
|
+
return process.env.GITHUB_TOKEN;
|
|
37
|
+
}
|
|
38
|
+
const auth = await loadAuth();
|
|
39
|
+
return auth.githubToken;
|
|
40
|
+
}
|
|
41
|
+
export async function getStoredBaseUrl() {
|
|
42
|
+
if (process.env.PRLEN_BASE_URL) {
|
|
43
|
+
return process.env.PRLEN_BASE_URL;
|
|
44
|
+
}
|
|
45
|
+
const auth = await loadAuth();
|
|
46
|
+
return auth.baseUrl;
|
|
47
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
const execFileAsync = promisify(execFile);
|
|
4
|
+
const LANGUAGE_MAP = {
|
|
5
|
+
'.py': 'Python',
|
|
6
|
+
'.js': 'JavaScript',
|
|
7
|
+
'.ts': 'TypeScript',
|
|
8
|
+
'.tsx': 'TypeScript',
|
|
9
|
+
'.jsx': 'JavaScript',
|
|
10
|
+
'.rs': 'Rust',
|
|
11
|
+
'.go': 'Go',
|
|
12
|
+
'.rb': 'Ruby',
|
|
13
|
+
'.java': 'Java',
|
|
14
|
+
'.kt': 'Kotlin',
|
|
15
|
+
'.swift': 'Swift',
|
|
16
|
+
'.c': 'C',
|
|
17
|
+
'.cpp': 'C++',
|
|
18
|
+
'.cs': 'C#',
|
|
19
|
+
'.php': 'PHP',
|
|
20
|
+
'.html': 'HTML',
|
|
21
|
+
'.css': 'CSS',
|
|
22
|
+
'.scss': 'SCSS',
|
|
23
|
+
'.vue': 'Vue',
|
|
24
|
+
'.svelte': 'Svelte',
|
|
25
|
+
'.sql': 'SQL',
|
|
26
|
+
'.sh': 'Shell',
|
|
27
|
+
'.ex': 'Elixir',
|
|
28
|
+
'.exs': 'Elixir',
|
|
29
|
+
'.zig': 'Zig',
|
|
30
|
+
};
|
|
31
|
+
function parseGitHubUrl(url) {
|
|
32
|
+
const match = url.match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:\/.*)?$/i);
|
|
33
|
+
if (!match) {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const [, owner, repo, prNumber] = match;
|
|
37
|
+
return {
|
|
38
|
+
owner,
|
|
39
|
+
repo,
|
|
40
|
+
prNumber: Number.parseInt(prNumber, 10),
|
|
41
|
+
prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
|
|
42
|
+
repoFullName: `${owner}/${repo}`,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
function parseGitRemote(remoteUrl) {
|
|
46
|
+
const httpsMatch = remoteUrl.trim().match(/^https?:\/\/github\.com\/([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
47
|
+
if (httpsMatch) {
|
|
48
|
+
return { owner: httpsMatch[1], repo: httpsMatch[2] };
|
|
49
|
+
}
|
|
50
|
+
const sshMatch = remoteUrl.trim().match(/^git@github\.com:([^/]+)\/([^/]+?)(?:\.git)?$/i);
|
|
51
|
+
if (sshMatch) {
|
|
52
|
+
return { owner: sshMatch[1], repo: sshMatch[2] };
|
|
53
|
+
}
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
async function getOriginRemote() {
|
|
57
|
+
const { stdout } = await execFileAsync('git', ['remote', 'get-url', 'origin']);
|
|
58
|
+
const parsed = parseGitRemote(stdout);
|
|
59
|
+
if (!parsed) {
|
|
60
|
+
throw new Error('Could not resolve a GitHub repo from git remote origin');
|
|
61
|
+
}
|
|
62
|
+
return parsed;
|
|
63
|
+
}
|
|
64
|
+
export async function resolvePullRequestReference(prRef) {
|
|
65
|
+
const normalized = prRef.trim();
|
|
66
|
+
const fromUrl = parseGitHubUrl(normalized);
|
|
67
|
+
if (fromUrl) {
|
|
68
|
+
return fromUrl;
|
|
69
|
+
}
|
|
70
|
+
const numberMatch = normalized.match(/^#?(\d+)$/);
|
|
71
|
+
if (!numberMatch) {
|
|
72
|
+
throw new Error('Expected a GitHub PR URL, #123, or 123');
|
|
73
|
+
}
|
|
74
|
+
const { owner, repo } = await getOriginRemote();
|
|
75
|
+
const prNumber = Number.parseInt(numberMatch[1], 10);
|
|
76
|
+
return {
|
|
77
|
+
owner,
|
|
78
|
+
repo,
|
|
79
|
+
prNumber,
|
|
80
|
+
prUrl: `https://github.com/${owner}/${repo}/pull/${prNumber}`,
|
|
81
|
+
repoFullName: `${owner}/${repo}`,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
function inferLanguages(files) {
|
|
85
|
+
const languages = new Set();
|
|
86
|
+
for (const file of files) {
|
|
87
|
+
const extensionMatch = file.filename.match(/(\.[^.\/]+)$/);
|
|
88
|
+
if (!extensionMatch) {
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const language = LANGUAGE_MAP[extensionMatch[1].toLowerCase()];
|
|
92
|
+
if (language) {
|
|
93
|
+
languages.add(language);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
return [...languages].sort();
|
|
97
|
+
}
|
|
98
|
+
export async function fetchPullRequestMetadata(pr, githubToken) {
|
|
99
|
+
const headers = new Headers({
|
|
100
|
+
Accept: 'application/vnd.github+json',
|
|
101
|
+
'X-GitHub-Api-Version': '2022-11-28',
|
|
102
|
+
Authorization: `Bearer ${githubToken}`,
|
|
103
|
+
});
|
|
104
|
+
const prResponse = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}`, { headers });
|
|
105
|
+
if (!prResponse.ok) {
|
|
106
|
+
throw new Error(`Failed to fetch pull request: ${prResponse.status}`);
|
|
107
|
+
}
|
|
108
|
+
const prJson = await prResponse.json();
|
|
109
|
+
const files = [];
|
|
110
|
+
let page = 1;
|
|
111
|
+
while (true) {
|
|
112
|
+
const filesResponse = await fetch(`https://api.github.com/repos/${pr.owner}/${pr.repo}/pulls/${pr.prNumber}/files?per_page=100&page=${page}`, { headers });
|
|
113
|
+
if (!filesResponse.ok) {
|
|
114
|
+
throw new Error(`Failed to fetch pull request files: ${filesResponse.status}`);
|
|
115
|
+
}
|
|
116
|
+
const pageFiles = await filesResponse.json();
|
|
117
|
+
if (pageFiles.length === 0) {
|
|
118
|
+
break;
|
|
119
|
+
}
|
|
120
|
+
files.push(...pageFiles);
|
|
121
|
+
if (pageFiles.length < 100) {
|
|
122
|
+
break;
|
|
123
|
+
}
|
|
124
|
+
page += 1;
|
|
125
|
+
}
|
|
126
|
+
return {
|
|
127
|
+
pr_title: prJson.title,
|
|
128
|
+
pr_url: prJson.html_url,
|
|
129
|
+
pr_number: prJson.number,
|
|
130
|
+
repo_full_name: pr.repoFullName,
|
|
131
|
+
pr_body: prJson.body ?? '',
|
|
132
|
+
files_changed: prJson.changed_files,
|
|
133
|
+
additions: prJson.additions,
|
|
134
|
+
deletions: prJson.deletions,
|
|
135
|
+
languages: inferLanguages(files),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { mkdir, rm, stat } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
export function packageSkillDir() {
|
|
6
|
+
return fileURLToPath(new URL('../../skills/prlen/', import.meta.url));
|
|
7
|
+
}
|
|
8
|
+
export function normalizeTargets(target) {
|
|
9
|
+
if (target === 'both') {
|
|
10
|
+
return ['claude', 'codex'];
|
|
11
|
+
}
|
|
12
|
+
return [target];
|
|
13
|
+
}
|
|
14
|
+
export function targetRoot(target) {
|
|
15
|
+
if (target === 'claude') {
|
|
16
|
+
return path.join(process.env.CLAUDE_HOME || path.join(homedir(), '.claude'), 'skills');
|
|
17
|
+
}
|
|
18
|
+
return path.join(process.env.CODEX_HOME || path.join(homedir(), '.codex'), 'skills');
|
|
19
|
+
}
|
|
20
|
+
export function targetDir(target) {
|
|
21
|
+
return path.join(targetRoot(target), 'prlen');
|
|
22
|
+
}
|
|
23
|
+
export async function pathExists(filePath) {
|
|
24
|
+
try {
|
|
25
|
+
await stat(filePath);
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export async function getSkillStatus(target) {
|
|
33
|
+
const destination = targetDir(target);
|
|
34
|
+
return {
|
|
35
|
+
target,
|
|
36
|
+
destination,
|
|
37
|
+
installed: await pathExists(destination),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
export async function ensureTargetRoot(target) {
|
|
41
|
+
await mkdir(targetRoot(target), { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
export async function removeInstalledSkill(target) {
|
|
44
|
+
const destination = targetDir(target);
|
|
45
|
+
const exists = await pathExists(destination);
|
|
46
|
+
if (exists) {
|
|
47
|
+
await rm(destination, { recursive: true, force: true });
|
|
48
|
+
}
|
|
49
|
+
return exists;
|
|
50
|
+
}
|
package/dist/utils/ui.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import chalk from 'chalk';
|
|
2
|
+
import { createInterface } from 'node:readline/promises';
|
|
3
|
+
import { stdin as input, stdout as output } from 'node:process';
|
|
4
|
+
function wrapText(text, width) {
|
|
5
|
+
const words = text.split(/\s+/).filter(Boolean);
|
|
6
|
+
const lines = [];
|
|
7
|
+
let current = '';
|
|
8
|
+
for (const word of words) {
|
|
9
|
+
const candidate = current ? `${current} ${word}` : word;
|
|
10
|
+
if (candidate.length > width && current) {
|
|
11
|
+
lines.push(current);
|
|
12
|
+
current = word;
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
current = candidate;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
if (current) {
|
|
19
|
+
lines.push(current);
|
|
20
|
+
}
|
|
21
|
+
return lines.length ? lines : [''];
|
|
22
|
+
}
|
|
23
|
+
export function printPreviewBox(text, width = 58) {
|
|
24
|
+
const lines = wrapText(text, width);
|
|
25
|
+
const top = `┌${'─'.repeat(width + 2)}┐`;
|
|
26
|
+
const bottom = `└${'─'.repeat(width + 2)}┘`;
|
|
27
|
+
console.log(chalk.dim(top));
|
|
28
|
+
for (const line of lines) {
|
|
29
|
+
const padded = line.padEnd(width, ' ');
|
|
30
|
+
console.log(chalk.dim('│ ') + padded + chalk.dim(' │'));
|
|
31
|
+
}
|
|
32
|
+
console.log(chalk.dim(bottom));
|
|
33
|
+
}
|
|
34
|
+
export async function confirm(message) {
|
|
35
|
+
const rl = createInterface({ input, output });
|
|
36
|
+
try {
|
|
37
|
+
const answer = (await rl.question(`${message} `)).trim().toLowerCase();
|
|
38
|
+
return answer === '' || answer === 'y' || answer === 'yes';
|
|
39
|
+
}
|
|
40
|
+
finally {
|
|
41
|
+
rl.close();
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
export function printMuted(text) {
|
|
45
|
+
console.log(chalk.dim(text));
|
|
46
|
+
}
|
|
47
|
+
export function printSuccess(text) {
|
|
48
|
+
console.log(chalk.green(`✓ ${text}`));
|
|
49
|
+
}
|
|
50
|
+
export function printError(text) {
|
|
51
|
+
console.error(chalk.red(text));
|
|
52
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@metmirr/prlen",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for publishing PR Lens posts from pull requests",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"files": [
|
|
7
|
+
"dist",
|
|
8
|
+
"skills"
|
|
9
|
+
],
|
|
10
|
+
"bin": {
|
|
11
|
+
"prlen": "dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"engines": {
|
|
14
|
+
"node": ">=18"
|
|
15
|
+
},
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.json",
|
|
18
|
+
"typecheck": "tsc -p tsconfig.json --noEmit",
|
|
19
|
+
"dev": "tsx src/index.ts"
|
|
20
|
+
},
|
|
21
|
+
"dependencies": {
|
|
22
|
+
"chalk": "^5.6.2",
|
|
23
|
+
"commander": "^14.0.1",
|
|
24
|
+
"ora": "^9.0.0"
|
|
25
|
+
},
|
|
26
|
+
"devDependencies": {
|
|
27
|
+
"@types/node": "^24.6.1",
|
|
28
|
+
"tsx": "^4.20.6",
|
|
29
|
+
"typescript": "^5.9.3"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: prlen
|
|
3
|
+
description: Publish or create PR Lens posts for a GitHub pull request. Use when the user asks to post to PR Lens, create a PR Lens post, or publish PR #123 to their PR Lens feed from a Claude or Codex session.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# PR Lens
|
|
7
|
+
|
|
8
|
+
Use this skill when the user wants an agent to publish a PR Lens post for a pull request.
|
|
9
|
+
|
|
10
|
+
## Goal
|
|
11
|
+
|
|
12
|
+
Use the fastest reliable workflow:
|
|
13
|
+
|
|
14
|
+
1. Resolve the PR reference (`#12`, `12`, or a full GitHub PR URL)
|
|
15
|
+
2. Read enough PR context to draft a good short post
|
|
16
|
+
3. Draft the PR Lens post text yourself
|
|
17
|
+
4. Publish non-interactively via the CLI using stdin
|
|
18
|
+
|
|
19
|
+
The agent is already an LLM, so do **not** wait for the CLI to generate the text.
|
|
20
|
+
|
|
21
|
+
## Preferred publish command
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
printf '%s' "<draft post text>" | prlen post 12 --prompt-stdin --yes
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
## PR Lens post style
|
|
28
|
+
|
|
29
|
+
Write like a real developer talking to Claude/Cursor/Copilot:
|
|
30
|
+
|
|
31
|
+
- casual and direct
|
|
32
|
+
- 2–3 sentences max
|
|
33
|
+
- specific technologies, file paths, and decisions when visible
|
|
34
|
+
- under 500 chars
|
|
35
|
+
- instruction-style wording, not release notes
|
|
36
|
+
- no quotes, no labels
|
|
37
|
+
|
|
38
|
+
Good:
|
|
39
|
+
|
|
40
|
+
```text
|
|
41
|
+
Add CLI publishing for PR Lens with browser-approved login, backend-fetched PR metadata, and non-interactive posting for agents. Keep it simple: login in the browser once, then let the CLI publish directly from the terminal.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
Bad:
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
This PR adds several new features to improve the PR Lens platform.
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
## Workflow
|
|
51
|
+
|
|
52
|
+
### 1. Resolve the PR
|
|
53
|
+
|
|
54
|
+
Prefer the user's PR reference directly.
|
|
55
|
+
|
|
56
|
+
- `#12`
|
|
57
|
+
- `12`
|
|
58
|
+
- `https://github.com/org/repo/pull/12`
|
|
59
|
+
|
|
60
|
+
### 2. Inspect the PR
|
|
61
|
+
|
|
62
|
+
Read enough context to write a high-quality short post:
|
|
63
|
+
|
|
64
|
+
- PR title
|
|
65
|
+
- PR description/body
|
|
66
|
+
- changed files
|
|
67
|
+
- important parts of the diff
|
|
68
|
+
- notable implementation constraints or design choices
|
|
69
|
+
|
|
70
|
+
### 3. Optionally fetch the PR Lens prompt template
|
|
71
|
+
|
|
72
|
+
If you want style guidance, fetch:
|
|
73
|
+
|
|
74
|
+
```text
|
|
75
|
+
GET /api/v1/prompt-template
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Do not block on this if you already have enough context to write the post.
|
|
79
|
+
|
|
80
|
+
### 4. Publish via CLI
|
|
81
|
+
|
|
82
|
+
Use stdin to avoid shell-escaping problems.
|
|
83
|
+
|
|
84
|
+
```bash
|
|
85
|
+
printf '%s' "<draft post text>" | prlen post 12 --prompt-stdin --yes
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
### 5. Report the result
|
|
89
|
+
|
|
90
|
+
Tell the user the post was published and include the returned URL.
|
|
91
|
+
|
|
92
|
+
## Auth expectations
|
|
93
|
+
|
|
94
|
+
PR Lens CLI auth usually comes from:
|
|
95
|
+
|
|
96
|
+
- `~/.config/prlen/auth.json`
|
|
97
|
+
- `PRLEN_TOKEN`
|
|
98
|
+
|
|
99
|
+
If auth is missing, tell the user to run:
|
|
100
|
+
|
|
101
|
+
```bash
|
|
102
|
+
prlen login
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
`prlen login` opens a browser approval page on PR Lens. If the user is already signed in on the website, they can approve the CLI login there without manually handling GitHub tokens.
|
|
106
|
+
|
|
107
|
+
## Notes
|
|
108
|
+
|
|
109
|
+
- `prlen post` fetches PR metadata through PR Lens using the user's connected GitHub account.
|
|
110
|
+
- The target repository must already be connected in PR Lens.
|
|
111
|
+
- The pull request must not already have a PR Lens post.
|
|
112
|
+
|
|
113
|
+
See [reference.md](reference.md) for examples and fallback behavior.
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# PR Lens Skill Reference
|
|
2
|
+
|
|
3
|
+
## Common commands
|
|
4
|
+
|
|
5
|
+
### Log in once
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
prlen login
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
This opens a browser approval page on PR Lens.
|
|
12
|
+
|
|
13
|
+
### Publish PR #12 with agent-authored text
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
printf '%s' "Add API-backed PR Lens publishing for pull requests with browser-approved login and non-interactive agent mode." | prlen post 12 --prompt-stdin --yes
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
### Full PR URL instead of number
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
printf '%s' "Add API-backed PR Lens publishing for pull requests with browser-approved login and non-interactive agent mode." | prlen post https://github.com/org/repo/pull/12 --prompt-stdin --yes
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
## Fallbacks
|
|
26
|
+
|
|
27
|
+
### If prompt text is already available as a string
|
|
28
|
+
|
|
29
|
+
```bash
|
|
30
|
+
prlen post 12 --prompt "<draft post text>" --yes
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
`--prompt-stdin` is still safer for agents.
|
|
34
|
+
|
|
35
|
+
## What to write
|
|
36
|
+
|
|
37
|
+
Aim for:
|
|
38
|
+
|
|
39
|
+
- the instruction that produced the code
|
|
40
|
+
- constraints or design choices
|
|
41
|
+
- names of files/tech when relevant
|
|
42
|
+
- no markdown formatting
|
|
43
|
+
- no surrounding quotes
|
|
44
|
+
|
|
45
|
+
## If publishing fails
|
|
46
|
+
|
|
47
|
+
Check:
|
|
48
|
+
|
|
49
|
+
1. PR Lens auth exists (`prlen login`)
|
|
50
|
+
2. the repo is connected in PR Lens
|
|
51
|
+
3. the PR does not already have a post
|
|
52
|
+
4. the daily post limit has not been reached
|
|
53
|
+
5. the PR Lens server URL is correct if using a local instance (`PRLEN_BASE_URL`)
|