git-reviewer 2.15.0 → 2.16.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 +66 -5
- package/bin/reviewer.js +256 -1
- package/lib/agent.js +12 -2
- package/lib/answerer.js +191 -0
- package/lib/channel.js +286 -0
- package/lib/claude-background.js +316 -0
- package/lib/claude-command.js +90 -0
- package/lib/claude-sessions.js +229 -0
- package/lib/cli.js +147 -7
- package/lib/comments.js +175 -10
- package/lib/presence.js +223 -0
- package/lib/store.js +284 -9
- package/lib/threads.js +180 -0
- package/lib/watch.js +131 -0
- package/man/git-reviewer.1 +47 -0
- package/package.json +1 -1
- package/public/app.js +739 -107
- package/public/index.html +33 -0
- package/public/style.css +271 -0
- package/server.js +307 -34
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fsp = require('fs').promises;
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const { canonicalRepoPath } = require('./review');
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Finding the Claude Code sessions that worked in a repository.
|
|
11
|
+
*
|
|
12
|
+
* The session worth asking about a change is the one that made it: it read
|
|
13
|
+
* the code, chose the approach and remembers why. Claude Code keeps each
|
|
14
|
+
* session's transcript as a JSON-lines file under its config directory,
|
|
15
|
+
* filed by the working directory it ran in, and that is what is read here --
|
|
16
|
+
* only the first and last few kilobytes of each, for its working directory,
|
|
17
|
+
* its name and the first thing it was asked.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// Enough of the head for the first prompt and the working directory, and of
|
|
21
|
+
// the tail for a name given later in the session, without reading a
|
|
22
|
+
// transcript that can run to tens of megabytes.
|
|
23
|
+
const SLICE_BYTES = 64 * 1024;
|
|
24
|
+
const PROMPT_CHARS = 240;
|
|
25
|
+
|
|
26
|
+
/** Where Claude Code keeps its state: `CLAUDE_CONFIG_DIR`, or `~/.claude`. */
|
|
27
|
+
function claudeConfigDir(env = process.env) {
|
|
28
|
+
return env.CLAUDE_CONFIG_DIR ? path.resolve(env.CLAUDE_CONFIG_DIR) : path.join(os.homedir(), '.claude');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* The directory name Claude Code files a working directory's sessions under:
|
|
33
|
+
* the path with every character that is not a letter or a digit turned into
|
|
34
|
+
* `-`. `/home/you/work/api` becomes `-home-you-work-api`.
|
|
35
|
+
*
|
|
36
|
+
* @param {string} dir
|
|
37
|
+
*/
|
|
38
|
+
function projectDirName(dir) {
|
|
39
|
+
return dir.replace(/[^A-Za-z0-9]/g, '-');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A session id as Claude Code writes them: a UUID. */
|
|
43
|
+
const SESSION_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {string} file
|
|
47
|
+
* @param {number} start
|
|
48
|
+
* @param {number} length
|
|
49
|
+
* @returns {Promise<string>}
|
|
50
|
+
*/
|
|
51
|
+
async function readSlice(file, start, length) {
|
|
52
|
+
const handle = await fsp.open(file, 'r');
|
|
53
|
+
try {
|
|
54
|
+
const buffer = Buffer.alloc(length);
|
|
55
|
+
const { bytesRead } = await handle.read(buffer, 0, length, start);
|
|
56
|
+
return buffer.subarray(0, bytesRead).toString('utf-8');
|
|
57
|
+
} finally {
|
|
58
|
+
await handle.close();
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* The records in a slice of a transcript. A slice cut mid-line yields a
|
|
64
|
+
* fragment at either end, which does not parse and is skipped.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} text
|
|
67
|
+
* @returns {object[]}
|
|
68
|
+
*/
|
|
69
|
+
function records(text) {
|
|
70
|
+
const parsed = [];
|
|
71
|
+
for (const line of text.split('\n')) {
|
|
72
|
+
if (!line.startsWith('{')) continue;
|
|
73
|
+
try {
|
|
74
|
+
parsed.push(JSON.parse(line));
|
|
75
|
+
} catch {
|
|
76
|
+
/* a fragment */
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return parsed;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* What a person typed, out of a transcript's user message -- not a tool
|
|
84
|
+
* result, and not the wrappers Claude Code puts around slash commands and
|
|
85
|
+
* reminders, which all open with a tag.
|
|
86
|
+
*
|
|
87
|
+
* @param {unknown} content
|
|
88
|
+
* @returns {string|null}
|
|
89
|
+
*/
|
|
90
|
+
function promptText(content) {
|
|
91
|
+
let text = null;
|
|
92
|
+
if (typeof content === 'string') {
|
|
93
|
+
text = content;
|
|
94
|
+
} else if (Array.isArray(content)) {
|
|
95
|
+
text = content.find(block => block?.type === 'text' && typeof block.text === 'string')?.text ?? null;
|
|
96
|
+
}
|
|
97
|
+
if (!text) return null;
|
|
98
|
+
text = text.trim();
|
|
99
|
+
if (text === '' || text.startsWith('<') || text.startsWith('Caveat:')) return null;
|
|
100
|
+
const flat = text.replace(/\s+/g, ' ');
|
|
101
|
+
return flat.length > PROMPT_CHARS ? `${flat.slice(0, PROMPT_CHARS - 1)}…` : flat;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* @typedef {object} ClaudeSession
|
|
106
|
+
* @property {string} id
|
|
107
|
+
* @property {string} cwd the directory it ran in
|
|
108
|
+
* @property {string|null} title the name it was given, or Claude Code's summary of it
|
|
109
|
+
* @property {string|null} firstPrompt
|
|
110
|
+
* @property {string|null} branch git branch when it started
|
|
111
|
+
* @property {string} updatedAt when it last wrote anything
|
|
112
|
+
*/
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read what a transcript says about itself.
|
|
116
|
+
*
|
|
117
|
+
* @param {string} file
|
|
118
|
+
* @returns {Promise<ClaudeSession|null>} null for a file that is not a session transcript
|
|
119
|
+
*/
|
|
120
|
+
async function describeSession(file) {
|
|
121
|
+
const id = path.basename(file, '.jsonl');
|
|
122
|
+
if (!SESSION_ID_PATTERN.test(id)) return null;
|
|
123
|
+
|
|
124
|
+
const stat = await fsp.stat(file);
|
|
125
|
+
const head = await readSlice(file, 0, SLICE_BYTES);
|
|
126
|
+
const tail = stat.size > SLICE_BYTES ? await readSlice(file, stat.size - SLICE_BYTES, SLICE_BYTES) : '';
|
|
127
|
+
|
|
128
|
+
let cwd = null;
|
|
129
|
+
let branch = null;
|
|
130
|
+
let firstPrompt = null;
|
|
131
|
+
let title = null;
|
|
132
|
+
let summary = null;
|
|
133
|
+
|
|
134
|
+
for (const record of [...records(head), ...records(tail)]) {
|
|
135
|
+
if (typeof record.cwd === 'string') cwd ??= record.cwd;
|
|
136
|
+
if (typeof record.gitBranch === 'string' && record.gitBranch !== '') branch ??= record.gitBranch;
|
|
137
|
+
if (!firstPrompt && record.type === 'user' && !record.isMeta && !record.isSidechain) {
|
|
138
|
+
firstPrompt = promptText(record.message?.content);
|
|
139
|
+
}
|
|
140
|
+
// Later records win: a session renamed halfway through goes by its new name.
|
|
141
|
+
if (record.type === 'custom-title' && typeof record.customTitle === 'string') title = record.customTitle;
|
|
142
|
+
if (record.type === 'summary' && typeof record.summary === 'string') summary = record.summary;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if (!cwd) return null;
|
|
146
|
+
return { id, cwd, title: title ?? summary ?? null, firstPrompt, branch, updatedAt: stat.mtime.toISOString() };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** @param {string} child @param {string} parent */
|
|
150
|
+
function isInside(child, parent) {
|
|
151
|
+
const relative = path.relative(parent, child);
|
|
152
|
+
return relative === '' || (!relative.startsWith('..') && !path.isAbsolute(relative));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Claude Code sessions that ran in a repository or any directory inside it,
|
|
157
|
+
* most recently active first.
|
|
158
|
+
*
|
|
159
|
+
* @param {string} repoPath
|
|
160
|
+
* @param {object} [options]
|
|
161
|
+
* @param {string} [options.configDir]
|
|
162
|
+
* @param {number} [options.limit]
|
|
163
|
+
* @returns {Promise<ClaudeSession[]>}
|
|
164
|
+
*/
|
|
165
|
+
async function listClaudeSessions(repoPath, { configDir = claudeConfigDir(), limit = 20 } = {}) {
|
|
166
|
+
const projects = path.join(configDir, 'projects');
|
|
167
|
+
const root = canonicalRepoPath(repoPath);
|
|
168
|
+
// The encoding loses information -- `/a/b-c` and `/a/b/c` share a name --
|
|
169
|
+
// so a matching directory is only a candidate. Each transcript's own
|
|
170
|
+
// working directory is what decides.
|
|
171
|
+
const prefixes = [...new Set([projectDirName(root), projectDirName(path.resolve(repoPath))])];
|
|
172
|
+
|
|
173
|
+
let dirs;
|
|
174
|
+
try {
|
|
175
|
+
dirs = (await fsp.readdir(projects, { withFileTypes: true }))
|
|
176
|
+
.filter(entry => entry.isDirectory() && prefixes.some(prefix => entry.name.startsWith(prefix)))
|
|
177
|
+
.map(entry => path.join(projects, entry.name));
|
|
178
|
+
} catch (error) {
|
|
179
|
+
if (error.code === 'ENOENT') return [];
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const candidates = [];
|
|
184
|
+
for (const dir of dirs) {
|
|
185
|
+
for (const entry of await fsp.readdir(dir, { withFileTypes: true }).catch(() => [])) {
|
|
186
|
+
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
|
|
187
|
+
const file = path.join(dir, entry.name);
|
|
188
|
+
const stat = await fsp.stat(file).catch(() => null);
|
|
189
|
+
if (stat) candidates.push({ file, mtimeMs: stat.mtimeMs });
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
|
|
193
|
+
|
|
194
|
+
const sessions = [];
|
|
195
|
+
for (const { file } of candidates) {
|
|
196
|
+
if (sessions.length >= limit) break;
|
|
197
|
+
const session = await describeSession(file).catch(() => null);
|
|
198
|
+
if (!session || !(session.firstPrompt || session.title)) continue;
|
|
199
|
+
if (!isInside(canonicalRepoPath(session.cwd), root)) continue;
|
|
200
|
+
sessions.push(session);
|
|
201
|
+
}
|
|
202
|
+
return sessions;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* One session of a repository, by id -- or null, which is the answer for an
|
|
207
|
+
* id that is malformed, unknown, or belongs to another repository.
|
|
208
|
+
*
|
|
209
|
+
* @param {string} repoPath
|
|
210
|
+
* @param {string} id
|
|
211
|
+
* @param {object} [options]
|
|
212
|
+
* @param {string} [options.configDir]
|
|
213
|
+
* @returns {Promise<ClaudeSession|null>}
|
|
214
|
+
*/
|
|
215
|
+
async function findClaudeSession(repoPath, id, { configDir = claudeConfigDir() } = {}) {
|
|
216
|
+
if (typeof id !== 'string' || !SESSION_ID_PATTERN.test(id)) return null;
|
|
217
|
+
const sessions = await listClaudeSessions(repoPath, { configDir, limit: Infinity });
|
|
218
|
+
return sessions.find(session => session.id === id) ?? null;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
module.exports = {
|
|
222
|
+
listClaudeSessions,
|
|
223
|
+
findClaudeSession,
|
|
224
|
+
describeSession,
|
|
225
|
+
claudeConfigDir,
|
|
226
|
+
projectDirName,
|
|
227
|
+
promptText,
|
|
228
|
+
SESSION_ID_PATTERN
|
|
229
|
+
};
|
package/lib/cli.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const path = require('path');
|
|
4
4
|
|
|
5
|
+
const { KEY_PATTERN, AUTHOR_PATTERN } = require('./comments');
|
|
6
|
+
|
|
5
7
|
/**
|
|
6
8
|
* Command-line parsing for the `reviewer` command.
|
|
7
9
|
*
|
|
@@ -15,6 +17,10 @@ function usage(name = 'git reviewer') {
|
|
|
15
17
|
Usage:
|
|
16
18
|
${name} [repository] [options]
|
|
17
19
|
${name} export [repository] [--format json|prompt] [--file <path>]
|
|
20
|
+
${name} connect [repository] [--session <id>] [--fork] [--list]
|
|
21
|
+
${name} reply <key> <text | -> [--repo <path>] [--as <name>]
|
|
22
|
+
${name} watch [repository]
|
|
23
|
+
${name} channel [repository]
|
|
18
24
|
|
|
19
25
|
One program, two names. git reviewer is the name the package is installed as
|
|
20
26
|
-- git runs any git-<name> on your PATH as a subcommand -- and reviewer is the
|
|
@@ -38,6 +44,17 @@ Arguments:
|
|
|
38
44
|
Commands:
|
|
39
45
|
export Print the saved review of a repository to stdout, for a
|
|
40
46
|
coding agent or another tool to consume.
|
|
47
|
+
connect Resume the Claude Code session that worked in this
|
|
48
|
+
repository with reviewer attached: every comment you
|
|
49
|
+
write is sent to it, and it answers in the thread.
|
|
50
|
+
--list shows the sessions; --session picks one.
|
|
51
|
+
reply Post a reply in a comment thread, as an agent: the
|
|
52
|
+
page shows it under the comment straight away. Use -
|
|
53
|
+
as the text to read it from stdin.
|
|
54
|
+
watch Print each new comment or reply from the reviewer as
|
|
55
|
+
one JSON line, for an agent to follow and answer.
|
|
56
|
+
channel The MCP server Claude Code runs for connect. Not
|
|
57
|
+
something to run by hand.
|
|
41
58
|
|
|
42
59
|
Options:
|
|
43
60
|
-p, --port <n> Port to listen on (default 4500; falls back to a free
|
|
@@ -49,6 +66,12 @@ Options:
|
|
|
49
66
|
--json Print one JSON line on stderr at startup instead of the banner
|
|
50
67
|
-f, --format <f> Export format: json (default) or prompt
|
|
51
68
|
--file <path> Export comments for one repo-relative file
|
|
69
|
+
--session <id> connect: the Claude Code session to resume
|
|
70
|
+
--fork connect: answer in a copy of the session
|
|
71
|
+
--list connect: list this repository's sessions
|
|
72
|
+
--repo <path> reply: the repository (default: the current one)
|
|
73
|
+
--as <name> reply: who the reply is from (default: claude inside
|
|
74
|
+
Claude Code, agent elsewhere)
|
|
52
75
|
-h, --help Show this help
|
|
53
76
|
-v, --version Show the version
|
|
54
77
|
|
|
@@ -67,12 +90,17 @@ Examples:
|
|
|
67
90
|
only the comments on one file
|
|
68
91
|
${name} export . --format prompt | claude -p "apply this review"
|
|
69
92
|
hand over a review written earlier
|
|
93
|
+
${name} connect
|
|
94
|
+
have the session that wrote the code answer your comments
|
|
70
95
|
`;
|
|
71
96
|
}
|
|
72
97
|
|
|
73
98
|
/** Formats `reviewer export` can print. */
|
|
74
99
|
const FORMATS = ['json', 'prompt'];
|
|
75
100
|
|
|
101
|
+
/** Words that, first on the command line, name a subcommand. */
|
|
102
|
+
const COMMANDS = ['export', 'connect', 'reply', 'watch', 'channel'];
|
|
103
|
+
|
|
76
104
|
class UsageError extends Error {
|
|
77
105
|
/** @param {string} message */
|
|
78
106
|
constructor(message) {
|
|
@@ -83,7 +111,7 @@ class UsageError extends Error {
|
|
|
83
111
|
|
|
84
112
|
/**
|
|
85
113
|
* @typedef {object} CliOptions
|
|
86
|
-
* @property {'serve'|'export'} command
|
|
114
|
+
* @property {'serve'|'export'|'connect'|'reply'|'watch'|'channel'} command
|
|
87
115
|
* @property {string|null} repoPath absolute path, or null when not given
|
|
88
116
|
* @property {number|null} port explicit port, or null to use the default
|
|
89
117
|
* @property {boolean} open whether to launch a browser
|
|
@@ -93,6 +121,13 @@ class UsageError extends Error {
|
|
|
93
121
|
* @property {boolean} json whether to print the startup info as one JSON line
|
|
94
122
|
* @property {'json'|'prompt'} format for `export`
|
|
95
123
|
* @property {string|null} file repo-relative file to export, or all files
|
|
124
|
+
* @property {string|null} session `connect`: Claude Code session id
|
|
125
|
+
* @property {boolean} fork `connect`: resume a copy of the session
|
|
126
|
+
* @property {boolean} list `connect`: list sessions instead of connecting
|
|
127
|
+
* @property {string|null} key `reply`: the comment to reply to
|
|
128
|
+
* @property {string|null} text `reply`: what to say
|
|
129
|
+
* @property {boolean} textFromStdin `reply`: the text is to be read from stdin
|
|
130
|
+
* @property {string|null} as `reply`: who it is from
|
|
96
131
|
* @property {boolean} help
|
|
97
132
|
* @property {boolean} version
|
|
98
133
|
*/
|
|
@@ -118,23 +153,48 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
118
153
|
json: false,
|
|
119
154
|
format: 'json',
|
|
120
155
|
file: null,
|
|
156
|
+
session: null,
|
|
157
|
+
fork: false,
|
|
158
|
+
list: false,
|
|
159
|
+
key: null,
|
|
160
|
+
text: null,
|
|
161
|
+
textFromStdin: false,
|
|
162
|
+
as: null,
|
|
121
163
|
help: false,
|
|
122
164
|
version: false
|
|
123
165
|
};
|
|
124
166
|
let positional = null;
|
|
167
|
+
const positionals = [];
|
|
168
|
+
let replyRepo = null;
|
|
125
169
|
|
|
126
|
-
// A leading
|
|
127
|
-
// path, so a directory that happens to be named `export` is still
|
|
128
|
-
// as `reviewer ./export`.
|
|
170
|
+
// A leading command word is the subcommand. Anywhere else it is an
|
|
171
|
+
// ordinary path, so a directory that happens to be named `export` is still
|
|
172
|
+
// reviewable as `reviewer ./export`.
|
|
129
173
|
let rest = argv;
|
|
130
|
-
if (argv[0]
|
|
131
|
-
options.command =
|
|
174
|
+
if (COMMANDS.includes(argv[0])) {
|
|
175
|
+
options.command = argv[0];
|
|
132
176
|
rest = argv.slice(1);
|
|
133
177
|
}
|
|
134
178
|
|
|
135
179
|
for (let i = 0; i < rest.length; i++) {
|
|
136
180
|
const arg = rest[i];
|
|
137
181
|
|
|
182
|
+
// A reply is prose, and prose starts with "- " as often as anything:
|
|
183
|
+
// a Markdown list. After the key, a word that is not one of reply's own
|
|
184
|
+
// options is part of the reply, and `--` makes everything after it so.
|
|
185
|
+
if (options.command === 'reply') {
|
|
186
|
+
if (arg === '--') {
|
|
187
|
+
positionals.push(...rest.slice(i + 1));
|
|
188
|
+
break;
|
|
189
|
+
}
|
|
190
|
+
const ownOption = ['--repo', '--as', '-h', '--help', '-v', '--version'].includes(arg) ||
|
|
191
|
+
/^--(repo|as)=/.test(arg);
|
|
192
|
+
if (positionals.length > 0 && !ownOption) {
|
|
193
|
+
positionals.push(arg);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
138
198
|
switch (arg) {
|
|
139
199
|
case '-h':
|
|
140
200
|
case '--help':
|
|
@@ -188,6 +248,37 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
188
248
|
break;
|
|
189
249
|
}
|
|
190
250
|
|
|
251
|
+
case '--session': {
|
|
252
|
+
const value = rest[++i];
|
|
253
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError('--session needs a session id');
|
|
254
|
+
options.session = value;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
case '--fork':
|
|
259
|
+
options.fork = true;
|
|
260
|
+
break;
|
|
261
|
+
|
|
262
|
+
case '--list':
|
|
263
|
+
options.list = true;
|
|
264
|
+
break;
|
|
265
|
+
|
|
266
|
+
case '--repo': {
|
|
267
|
+
const value = rest[++i];
|
|
268
|
+
if (value === undefined || value.startsWith('-')) throw new UsageError('--repo needs a repository path');
|
|
269
|
+
replyRepo = value;
|
|
270
|
+
break;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
case '--as': {
|
|
274
|
+
const value = rest[++i];
|
|
275
|
+
if (value === undefined || !AUTHOR_PATTERN.test(value)) {
|
|
276
|
+
throw new UsageError('--as needs a short name, such as claude');
|
|
277
|
+
}
|
|
278
|
+
options.as = value;
|
|
279
|
+
break;
|
|
280
|
+
}
|
|
281
|
+
|
|
191
282
|
default: {
|
|
192
283
|
// Support --port=4500 as well as --port 4500.
|
|
193
284
|
const inlinePort = arg.match(/^--port=(.*)$/);
|
|
@@ -208,10 +299,30 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
208
299
|
break;
|
|
209
300
|
}
|
|
210
301
|
|
|
302
|
+
const inlineRepo = arg.match(/^--repo=(.+)$/);
|
|
303
|
+
if (inlineRepo) {
|
|
304
|
+
replyRepo = inlineRepo[1];
|
|
305
|
+
break;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const inlineAs = arg.match(/^--as=(.*)$/);
|
|
309
|
+
if (inlineAs) {
|
|
310
|
+
if (!AUTHOR_PATTERN.test(inlineAs[1])) throw new UsageError('--as needs a short name, such as claude');
|
|
311
|
+
options.as = inlineAs[1];
|
|
312
|
+
break;
|
|
313
|
+
}
|
|
314
|
+
|
|
211
315
|
if (arg.startsWith('-') && arg !== '-') {
|
|
212
316
|
throw new UsageError(`Unknown option: ${arg}`);
|
|
213
317
|
}
|
|
214
318
|
|
|
319
|
+
// `reply <key> <text...>` takes words, not a repository: the reply
|
|
320
|
+
// is everything after the key, so it need not be quoted.
|
|
321
|
+
if (options.command === 'reply') {
|
|
322
|
+
positionals.push(arg);
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
215
326
|
if (positional !== null) {
|
|
216
327
|
throw new UsageError(`Unexpected argument: ${arg}`);
|
|
217
328
|
}
|
|
@@ -225,6 +336,29 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
225
336
|
options.repoPath = path.resolve(cwd, positional);
|
|
226
337
|
}
|
|
227
338
|
|
|
339
|
+
if (options.command === 'reply') {
|
|
340
|
+
if (!options.help && !options.version) {
|
|
341
|
+
const [key, ...words] = positionals;
|
|
342
|
+
if (!key || !KEY_PATTERN.test(key)) throw new UsageError('reply needs the key of the comment to reply to');
|
|
343
|
+
const text = words.join(' ').trim();
|
|
344
|
+
if (text === '') throw new UsageError('reply needs the text of the reply, or - to read it from stdin');
|
|
345
|
+
options.key = key;
|
|
346
|
+
// `-` alone: the reply is on stdin, which suits an answer of several
|
|
347
|
+
// lines and quotes that a shell would otherwise have to survive.
|
|
348
|
+
options.text = text === '-' ? null : text;
|
|
349
|
+
options.textFromStdin = text === '-';
|
|
350
|
+
}
|
|
351
|
+
if (replyRepo !== null) options.repoPath = path.resolve(cwd, replyRepo);
|
|
352
|
+
} else if (replyRepo !== null) {
|
|
353
|
+
throw new UsageError('--repo is only valid with reviewer reply; name the repository directly');
|
|
354
|
+
}
|
|
355
|
+
if (options.as !== null && options.command !== 'reply') {
|
|
356
|
+
throw new UsageError('--as is only valid with reviewer reply');
|
|
357
|
+
}
|
|
358
|
+
for (const [flag, set] of [['--session', options.session !== null], ['--fork', options.fork], ['--list', options.list]]) {
|
|
359
|
+
if (set && options.command !== 'connect') throw new UsageError(`${flag} is only valid with reviewer connect`);
|
|
360
|
+
}
|
|
361
|
+
|
|
228
362
|
if (options.file !== null && options.command !== 'export') {
|
|
229
363
|
throw new UsageError('--file is only valid with reviewer export');
|
|
230
364
|
}
|
|
@@ -242,6 +376,12 @@ function parseArgs(argv, cwd = process.cwd()) {
|
|
|
242
376
|
if (options.json && options.command !== 'serve') {
|
|
243
377
|
throw new UsageError('--json is only valid when serving a review');
|
|
244
378
|
}
|
|
379
|
+
if (options.port !== null && options.command !== 'serve') {
|
|
380
|
+
throw new UsageError('--port is only valid when serving a review');
|
|
381
|
+
}
|
|
382
|
+
if (!options.open && options.command !== 'serve') {
|
|
383
|
+
throw new UsageError('--no-open is only valid when serving a review');
|
|
384
|
+
}
|
|
245
385
|
return options;
|
|
246
386
|
}
|
|
247
387
|
|
|
@@ -337,4 +477,4 @@ function invokedAs(env, argv1) {
|
|
|
337
477
|
|
|
338
478
|
const USAGE = usage();
|
|
339
479
|
|
|
340
|
-
module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, usage, invokedAs };
|
|
480
|
+
module.exports = { parseArgs, buildUrl, UsageError, USAGE, FORMATS, COMMANDS, usage, invokedAs };
|