@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 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);
@@ -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
+ }
@@ -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
+ }
@@ -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`)