@vintasoftware/pr-review-canvas 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.
Files changed (157) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +192 -0
  3. package/bin/pr-review.mjs +5 -0
  4. package/docs/reference.md +340 -0
  5. package/package.json +74 -0
  6. package/pr-review.config.example.yml +68 -0
  7. package/prompts/chat-seed.md +64 -0
  8. package/prompts/generation-format.md +255 -0
  9. package/prompts/generation-strict.md +34 -0
  10. package/prompts/generation-surfacing.md +67 -0
  11. package/prompts/layers-default.md +13 -0
  12. package/prompts/quality-standards.md +32 -0
  13. package/skills/pr-review-canvas/SKILL.md +177 -0
  14. package/src/acpx/acpx.ts +530 -0
  15. package/src/acpx/agents.ts +85 -0
  16. package/src/acpx/events.ts +216 -0
  17. package/src/acpx/ndjson.ts +69 -0
  18. package/src/acpx/preflight.ts +44 -0
  19. package/src/canvas/export.ts +95 -0
  20. package/src/canvas/import.ts +138 -0
  21. package/src/canvas/name.ts +55 -0
  22. package/src/canvas/zip.ts +123 -0
  23. package/src/chat/chat-manager.ts +389 -0
  24. package/src/chat/context.ts +160 -0
  25. package/src/chat/seed.ts +71 -0
  26. package/src/chat/threads.ts +114 -0
  27. package/src/cli.ts +199 -0
  28. package/src/commands.ts +424 -0
  29. package/src/config.ts +142 -0
  30. package/src/contract/api.ts +190 -0
  31. package/src/contract/canvas-manifest.ts +29 -0
  32. package/src/contract/chat.ts +76 -0
  33. package/src/contract/comments.ts +96 -0
  34. package/src/contract/discovery.ts +20 -0
  35. package/src/contract/generation-context.ts +77 -0
  36. package/src/contract/keys.ts +14 -0
  37. package/src/contract/links.ts +5 -0
  38. package/src/contract/mermaid-fences.ts +4 -0
  39. package/src/contract/review-artifact.ts +324 -0
  40. package/src/contract/settings.ts +144 -0
  41. package/src/contract/state.ts +46 -0
  42. package/src/contract/validation.ts +43 -0
  43. package/src/git/diff-collector.ts +151 -0
  44. package/src/git/git.ts +115 -0
  45. package/src/git/lang.ts +1 -0
  46. package/src/git/materialize.ts +79 -0
  47. package/src/git/patch-lines.ts +60 -0
  48. package/src/github/attachments.ts +288 -0
  49. package/src/github/capabilities.ts +112 -0
  50. package/src/github/comments.ts +132 -0
  51. package/src/github/gh.ts +196 -0
  52. package/src/github/post-comment.ts +104 -0
  53. package/src/github/post-review.ts +44 -0
  54. package/src/github/pr.ts +133 -0
  55. package/src/github/review-body.ts +72 -0
  56. package/src/github/threads.ts +63 -0
  57. package/src/paths.ts +10 -0
  58. package/src/project-config.ts +219 -0
  59. package/src/prompt-files.ts +26 -0
  60. package/src/review/diagram-nodes.ts +227 -0
  61. package/src/review/doctor.ts +139 -0
  62. package/src/review/glob.ts +33 -0
  63. package/src/review/install-skill.ts +107 -0
  64. package/src/review/normalize.ts +209 -0
  65. package/src/review/prepare.ts +165 -0
  66. package/src/review/prompt.ts +233 -0
  67. package/src/review/publish.ts +209 -0
  68. package/src/review/skill-command.ts +4 -0
  69. package/src/review/test-paths.ts +32 -0
  70. package/src/review/text-length.ts +15 -0
  71. package/src/review/trim-caps.ts +114 -0
  72. package/src/review/validate-folds.ts +110 -0
  73. package/src/review/validate.ts +520 -0
  74. package/src/server/app.ts +46 -0
  75. package/src/server/bundle.ts +266 -0
  76. package/src/server/capped-body.ts +62 -0
  77. package/src/server/context.ts +174 -0
  78. package/src/server/env.ts +7 -0
  79. package/src/server/errors.ts +65 -0
  80. package/src/server/html.ts +140 -0
  81. package/src/server/node-server.ts +42 -0
  82. package/src/server/routes/api.ts +256 -0
  83. package/src/server/routes/chat-routes.ts +221 -0
  84. package/src/server/routes/pages.ts +64 -0
  85. package/src/server/routes/review-routes.ts +245 -0
  86. package/src/server/routes/static.ts +114 -0
  87. package/src/server/security.ts +104 -0
  88. package/src/server/sse.ts +67 -0
  89. package/src/store/atomic-json.ts +68 -0
  90. package/src/store/canvas-store.ts +120 -0
  91. package/src/store/data-dir.ts +29 -0
  92. package/src/store/derived-store.ts +93 -0
  93. package/src/store/pr-store.ts +69 -0
  94. package/src/store/settings-store.ts +152 -0
  95. package/src/store/state-store.ts +121 -0
  96. package/static/js/anchors.js +141 -0
  97. package/static/js/api.js +542 -0
  98. package/static/js/app.js +418 -0
  99. package/static/js/ask.js +35 -0
  100. package/static/js/chat-context.js +137 -0
  101. package/static/js/chat-scroll.js +114 -0
  102. package/static/js/chat.js +843 -0
  103. package/static/js/code-folds.js +200 -0
  104. package/static/js/commands.js +110 -0
  105. package/static/js/comment-link.js +37 -0
  106. package/static/js/composer.js +241 -0
  107. package/static/js/contract-types.d.ts +59 -0
  108. package/static/js/deep-link.js +160 -0
  109. package/static/js/diagram.js +582 -0
  110. package/static/js/diff-decorations.js +204 -0
  111. package/static/js/diff-renderer.js +860 -0
  112. package/static/js/dom.js +145 -0
  113. package/static/js/download.js +52 -0
  114. package/static/js/empty-state.js +161 -0
  115. package/static/js/errors.js +135 -0
  116. package/static/js/fences.js +90 -0
  117. package/static/js/header.js +134 -0
  118. package/static/js/hunks.js +62 -0
  119. package/static/js/import-zone.js +95 -0
  120. package/static/js/interactions.js +952 -0
  121. package/static/js/keyboard.js +131 -0
  122. package/static/js/keys.js +97 -0
  123. package/static/js/lang.js +54 -0
  124. package/static/js/layers.js +596 -0
  125. package/static/js/links.js +150 -0
  126. package/static/js/markdown.js +232 -0
  127. package/static/js/mermaid-fences.js +55 -0
  128. package/static/js/nav.js +91 -0
  129. package/static/js/overview.js +85 -0
  130. package/static/js/points.js +247 -0
  131. package/static/js/progress.js +49 -0
  132. package/static/js/proposed-comment.js +133 -0
  133. package/static/js/quick-questions.js +216 -0
  134. package/static/js/regenerate.js +69 -0
  135. package/static/js/review-session.js +257 -0
  136. package/static/js/scroll-spy.js +66 -0
  137. package/static/js/selection.js +193 -0
  138. package/static/js/settings.js +206 -0
  139. package/static/js/signoff.js +171 -0
  140. package/static/js/skin.js +56 -0
  141. package/static/js/store.js +35 -0
  142. package/static/js/theme.js +56 -0
  143. package/static/js/threads.js +78 -0
  144. package/static/js/vendor.d.ts +15 -0
  145. package/static/styles/base.css +223 -0
  146. package/static/styles/chat-tools.css +130 -0
  147. package/static/styles/chat.css +140 -0
  148. package/static/styles/commands.css +156 -0
  149. package/static/styles/diff.css +258 -0
  150. package/static/styles/header.css +114 -0
  151. package/static/styles/layout.css +123 -0
  152. package/static/styles/panels.css +152 -0
  153. package/static/styles/responsive.css +80 -0
  154. package/static/styles/review-actions.css +124 -0
  155. package/static/styles/review.css +473 -0
  156. package/static/styles/skin-github.css +356 -0
  157. package/static/styles.css +14 -0
@@ -0,0 +1,227 @@
1
+ // The node ids a mermaid source names. The validator uses them to tell a diagram link keyed on a
2
+ // real node from one keyed on a name the drawing does not have.
3
+ //
4
+ // Coverage: the four types the prompt recommends (flowchart, sequence, state, ER) are read
5
+ // statement by statement; every other type, and any source these readers find no name in, falls
6
+ // back to every word of the text. Each reader keeps names it is unsure about, since a name it
7
+ // misses would be reported as a mistake the model did not make.
8
+
9
+ import { diagramKind } from '../contract/mermaid-fences.js'
10
+
11
+ /** A bare word: an identifier, or a number a flowchart may use as a node id. */
12
+ const WORD_RE = /[A-Za-z_][\w.-]*|\d[\w.-]*/g
13
+
14
+ /** Words that open a statement instead of naming a node. */
15
+ const FLOW_KEYWORDS = new Set([
16
+ 'flowchart',
17
+ 'flowchart-elk',
18
+ 'graph',
19
+ 'subgraph',
20
+ 'end',
21
+ 'direction',
22
+ 'accTitle',
23
+ 'accDescr',
24
+ 'LR',
25
+ 'RL',
26
+ 'TB',
27
+ 'BT',
28
+ 'TD',
29
+ ])
30
+
31
+ /** Statements that name styles, classes, or callbacks rather than nodes. */
32
+ const FLOW_SKIP = /^(class|classDef|click|style|linkStyle|href|callback)\b/
33
+
34
+ const STATE_KEYWORDS = new Set([
35
+ 'stateDiagram',
36
+ 'stateDiagram-v2',
37
+ 'state',
38
+ 'as',
39
+ 'direction',
40
+ 'note',
41
+ 'end',
42
+ 'left',
43
+ 'right',
44
+ 'of',
45
+ 'classDef',
46
+ 'class',
47
+ 'accTitle',
48
+ 'accDescr',
49
+ 'LR',
50
+ 'RL',
51
+ 'TB',
52
+ 'BT',
53
+ 'TD',
54
+ ])
55
+
56
+ const ER_KEYWORDS = new Set(['erDiagram', 'accTitle', 'accDescr', 'direction', 'LR', 'RL', 'TB', 'BT', 'TD'])
57
+
58
+ /** The text before the first `sep`, or all of it when there is none. */
59
+ function before(text: string, sep: string): string {
60
+ const at = text.indexOf(sep)
61
+ return at === -1 ? text : text.slice(0, at)
62
+ }
63
+
64
+ /** The words of a text, with no attempt at grammar. */
65
+ function words(text: string): string[] {
66
+ return text.match(WORD_RE) ?? []
67
+ }
68
+
69
+ /** The lines of the source without its comments and without empty lines. */
70
+ function lines(source: string): string[] {
71
+ return source
72
+ .replace(/\r\n/g, '\n')
73
+ .split('\n')
74
+ .map(line => line.trim())
75
+ .filter(line => line !== '' && !line.startsWith('%%'))
76
+ }
77
+
78
+ /** Drops quoted text and the label brackets around it, innermost first. */
79
+ function stripLabels(line: string): string {
80
+ let out = line.replace(/"[^"]*"/g, ' ').replace(/\|[^|]*\|/g, ' ')
81
+ for (let i = 0; i < 6; i++) {
82
+ const next = out.replace(/\[[^[\]]*\]|\([^()]*\)|\{[^{}]*\}/g, ' ')
83
+ if (next === out) {
84
+ break
85
+ }
86
+ out = next
87
+ }
88
+ return out
89
+ }
90
+
91
+ /**
92
+ * Replaces the arrows and edge markers of a flowchart with spaces, the way mermaid's own lexer
93
+ * reads them: two or more of `-=.`, then one head, either `>` or the `x`/`o` of a crossing or
94
+ * circle arrow. A head is part of the arrow even with the next name glued to it (`A---oB` names
95
+ * `B`), while `A-->oven` keeps its `oven`, since that arrow already has its head. A leading
96
+ * `x`/`o` is the tail of a crossing or circle arrow only when the same letter closes it. An
97
+ * invisible link (`~~~`) takes no head, so `A~~~orange` keeps its `orange`.
98
+ */
99
+ function stripFlowArrows(line: string): string {
100
+ return line.replace(/(?<![\w-])([ox])(?=[-=.]{2,}\1(?![-=.]))|<?[-=.]{2,}(?:>|[xo])?|~{2,}|&/g, ' ')
101
+ }
102
+
103
+ function flowchartIds(source: string): string[] {
104
+ const out: string[] = []
105
+ for (const line of lines(source)) {
106
+ if (FLOW_SKIP.test(line)) {
107
+ continue
108
+ }
109
+ for (const word of words(stripFlowArrows(stripLabels(line)))) {
110
+ if (!FLOW_KEYWORDS.has(word)) {
111
+ out.push(word)
112
+ }
113
+ }
114
+ }
115
+ return out
116
+ }
117
+
118
+ function sequenceIds(source: string): string[] {
119
+ const out: string[] = []
120
+ for (const line of lines(source)) {
121
+ const declared = /^(?:participant|actor|create\s+(?:participant|actor))\s+(.+)$/.exec(line)?.[1]
122
+ if (declared !== undefined) {
123
+ out.push(...words(declared.split(/\s+as\s+/)[0] ?? declared).slice(0, 1))
124
+ continue
125
+ }
126
+ // A message names its two sides; the text after the first colon is the message itself.
127
+ const head = before(line, ':')
128
+ if (!/-{1,2}[)>x-]/.test(head)) {
129
+ continue
130
+ }
131
+ for (const side of head.split(/(?:<<)?-{1,2}(?:>>|>|x|\))/)) {
132
+ out.push(...words(side))
133
+ }
134
+ }
135
+ return out
136
+ }
137
+
138
+ function stateIds(source: string): string[] {
139
+ const out: string[] = []
140
+ const take = (text: string): void => {
141
+ for (const word of words(text).slice(0, 1)) {
142
+ if (!STATE_KEYWORDS.has(word)) {
143
+ out.push(word)
144
+ }
145
+ }
146
+ }
147
+ for (const line of lines(source)) {
148
+ const bare = stripLabels(line).replace(/<<[^>]*>>/g, ' ')
149
+ if (bare.startsWith('state ')) {
150
+ const named = /\bas\s+([^\s{]+)/.exec(bare)?.[1]
151
+ take(named === undefined ? bare.slice('state '.length) : named)
152
+ continue
153
+ }
154
+ if (bare.includes('-->')) {
155
+ // `a --> b : label` carries the label on the right side.
156
+ for (const side of bare.split('-->')) {
157
+ take(before(side, ':'))
158
+ }
159
+ continue
160
+ }
161
+ // `id : description` names a state and describes it.
162
+ take(before(bare, ':'))
163
+ }
164
+ return out
165
+ }
166
+
167
+ /**
168
+ * The quoted names on the entity side of an ER line, which is where a name holds a space. The
169
+ * scan stops at the colon that opens the relationship label, so the label is not read as a name.
170
+ */
171
+ function quotedEntities(line: string): string[] {
172
+ const out: string[] = []
173
+ let quote = -1
174
+ for (let i = 0; i < line.length; i++) {
175
+ const ch = line[i]
176
+ if (ch === '"') {
177
+ if (quote === -1) {
178
+ quote = i + 1
179
+ } else {
180
+ out.push(line.slice(quote, i))
181
+ quote = -1
182
+ }
183
+ } else if (ch === ':' && quote === -1) {
184
+ break
185
+ }
186
+ }
187
+ return out
188
+ }
189
+
190
+ function erIds(source: string): string[] {
191
+ const out: string[] = []
192
+ let depth = 0
193
+ for (const line of lines(source)) {
194
+ // A relationship marker (`||--o{`) carries a brace of its own, so it is removed before the
195
+ // braces that open and close an attribute block are counted.
196
+ const clean = stripLabels(line).replace(/[|}{o.-]{2,}/g, ' ')
197
+ if (depth <= 0) {
198
+ out.push(...quotedEntities(line))
199
+ for (const word of words(before(clean, ':'))) {
200
+ if (!ER_KEYWORDS.has(word)) {
201
+ out.push(word)
202
+ }
203
+ }
204
+ }
205
+ depth += (clean.match(/\{/g) ?? []).length - (clean.match(/\}/g) ?? []).length
206
+ }
207
+ return out
208
+ }
209
+
210
+ /**
211
+ * The node ids of a mermaid source. The first line decides how the source is read; a type this
212
+ * module does not know, or a source it reads nothing out of, falls back to every word in the text.
213
+ */
214
+ export function diagramNodeIds(source: string): Set<string> {
215
+ const type = diagramKind(source)
216
+ let found: string[] = []
217
+ if (type === 'flowchart' || type === 'flowchart-elk' || type === 'graph') {
218
+ found = flowchartIds(source)
219
+ } else if (type === 'sequenceDiagram') {
220
+ found = sequenceIds(source)
221
+ } else if (type === 'stateDiagram') {
222
+ found = stateIds(source)
223
+ } else if (type === 'erDiagram') {
224
+ found = erIds(source)
225
+ }
226
+ return new Set(found.length > 0 ? found : words(source))
227
+ }
@@ -0,0 +1,139 @@
1
+ // `pr-review doctor`: one pass over everything the tool needs before it can serve a review, as
2
+ // one JSON line. It reports instead of throwing, so a broken setup still answers.
3
+ import { randomBytes } from 'node:crypto'
4
+ import { rm, writeFile } from 'node:fs/promises'
5
+ import path from 'node:path'
6
+ import { parseGithubRemote } from '../config.js'
7
+ import type { Git } from '../git/git.js'
8
+ import type { GitHubClient } from '../github/gh.js'
9
+ import { ensureDataDir, resolveDataDir } from '../store/data-dir.js'
10
+ import { CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR, SKILL_NAME } from './install-skill.js'
11
+
12
+ export const DOCTOR_CHECKS = ['git', 'origin', 'gh', 'ghAuth', 'dataDir', 'skill'] as const
13
+ export type DoctorCheckName = (typeof DOCTOR_CHECKS)[number]
14
+
15
+ export interface DoctorCheck {
16
+ ok: boolean
17
+ detail: string
18
+ hint?: string
19
+ }
20
+
21
+ export interface DoctorReport {
22
+ ok: boolean
23
+ version: string
24
+ checks: Record<DoctorCheckName, DoctorCheck> & { acpx?: DoctorCheck }
25
+ }
26
+
27
+ export interface DoctorDeps {
28
+ git: Git
29
+ gh: GitHubClient
30
+ version: string
31
+ acpxVersion: () => Promise<string | null>
32
+ /** `--data-dir` or `PR_REVIEW_DATA_DIR`; without it the dir sits next to the git common dir. */
33
+ dataDirOverride?: string | undefined
34
+ /** Answers whether a file can be read; the skill check asks for the SKILL.md inside. */
35
+ exists: (file: string) => Promise<boolean>
36
+ }
37
+
38
+ function message(err: unknown): string {
39
+ return err instanceof Error ? err.message : String(err)
40
+ }
41
+
42
+ /**
43
+ * Can the process write where canvases go? A probe file is written and removed. Its name is
44
+ * random and it is created with `wx`, so the probe writes over nothing.
45
+ */
46
+ async function checkDataDir(dir: string): Promise<DoctorCheck> {
47
+ const probe = path.join(dir, `.doctor-probe-${randomBytes(8).toString('hex')}`)
48
+ try {
49
+ await ensureDataDir(dir)
50
+ await writeFile(probe, '', { encoding: 'utf8', flag: 'wx' })
51
+ await rm(probe, { force: true })
52
+ return { ok: true, detail: dir }
53
+ } catch (err) {
54
+ return { ok: false, detail: `${dir}: ${message(err)}`, hint: 'pass --data-dir <dir> to a writable place' }
55
+ }
56
+ }
57
+
58
+ /** The skill the generation flow needs, in either harness's directory. */
59
+ async function checkSkill(repoRoot: string | null, exists: DoctorDeps['exists']): Promise<DoctorCheck> {
60
+ if (repoRoot === null) {
61
+ return { ok: false, detail: 'no repository, so no skill directory to look in', hint: 'run from a clone' }
62
+ }
63
+ const targets = [CLAUDE_SKILLS_DIR, CODEX_SKILLS_DIR].map(dir => path.join(repoRoot, dir, SKILL_NAME))
64
+ const found: string[] = []
65
+ for (const target of targets) {
66
+ // The file the harness reads, not the directory: an empty directory, a dangling link, and
67
+ // an unreadable file all install nothing.
68
+ if (await exists(path.join(target, 'SKILL.md'))) {
69
+ found.push(path.relative(repoRoot, target))
70
+ }
71
+ }
72
+ if (found.length === 0) {
73
+ return {
74
+ ok: false,
75
+ detail: `${SKILL_NAME} is in neither ${CLAUDE_SKILLS_DIR} nor ${CODEX_SKILLS_DIR}`,
76
+ hint: 'run `pr-review install-skill`',
77
+ }
78
+ }
79
+ return { ok: true, detail: found.join(', ') }
80
+ }
81
+
82
+ async function checkAcpx(deps: DoctorDeps): Promise<DoctorCheck> {
83
+ const hint = 'install with `npm install -g acpx` and check `acpx --version`'
84
+ try {
85
+ const version = (await deps.acpxVersion())?.trim()
86
+ return version
87
+ ? { ok: true, detail: version }
88
+ : { ok: false, detail: 'acpx is missing or could not report its version', hint }
89
+ } catch (err) {
90
+ return { ok: false, detail: message(err), hint }
91
+ }
92
+ }
93
+
94
+ /** Runs core checks and, with allChecks, checks acpx for AI Chat. */
95
+ export async function runDoctorChecks(deps: DoctorDeps, options: { allChecks?: boolean } = {}): Promise<DoctorReport> {
96
+ let repoRoot: string | null = null
97
+ let git: DoctorCheck
98
+ try {
99
+ repoRoot = await deps.git.topLevel()
100
+ git = { ok: true, detail: repoRoot }
101
+ } catch (err) {
102
+ git = { ok: false, detail: message(err), hint: 'run from a clone or pass --repo <dir>' }
103
+ }
104
+
105
+ let origin: DoctorCheck
106
+ try {
107
+ const url = await deps.git.remoteUrl('origin')
108
+ const repo = url === null ? null : parseGithubRemote(url)
109
+ origin =
110
+ repo === null
111
+ ? { ok: false, detail: url ?? 'no origin remote', hint: 'add a github.com origin' }
112
+ : { ok: true, detail: `${repo.owner}/${repo.name}` }
113
+ } catch (err) {
114
+ origin = { ok: false, detail: message(err), hint: 'add a github.com origin' }
115
+ }
116
+
117
+ const status = await deps.gh.authStatus()
118
+ const gh: DoctorCheck = status.installed
119
+ ? { ok: true, detail: status.detail }
120
+ : { ok: false, detail: status.detail, hint: 'install it from https://cli.github.com' }
121
+ const ghAuth: DoctorCheck = status.authenticated
122
+ ? { ok: true, detail: status.detail }
123
+ : { ok: false, detail: status.detail, hint: 'run `gh auth login`' }
124
+
125
+ let dataDir: DoctorCheck
126
+ try {
127
+ const commonDir = await deps.git.commonDir()
128
+ dataDir = await checkDataDir(resolveDataDir({ override: deps.dataDirOverride, commonDir }))
129
+ } catch (err) {
130
+ dataDir = { ok: false, detail: message(err), hint: 'pass --data-dir <dir>' }
131
+ }
132
+
133
+ const skill = await checkSkill(repoRoot, deps.exists)
134
+ const checks: DoctorReport['checks'] = { git, origin, gh, ghAuth, dataDir, skill }
135
+ if (options.allChecks) {
136
+ checks.acpx = await checkAcpx(deps)
137
+ }
138
+ return { ok: Object.values(checks).every(check => check.ok), version: deps.version, checks }
139
+ }
@@ -0,0 +1,33 @@
1
+ /**
2
+ * The subset of glob syntax `pr-review.config.yml` patterns use: `**` crosses directories,
3
+ * `*` and `?` stay inside one path segment. Patterns match repo-relative paths.
4
+ */
5
+ export function globToRegExp(pattern: string): RegExp {
6
+ let out = '^'
7
+ for (let i = 0; i < pattern.length; i++) {
8
+ const ch = pattern[i]
9
+ if (ch === '*') {
10
+ if (pattern[i + 1] === '*') {
11
+ i += 1
12
+ if (pattern[i + 1] === '/') {
13
+ // `**/` matches zero or more whole directories.
14
+ i += 1
15
+ out += '(?:.*/)?'
16
+ } else {
17
+ out += '.*'
18
+ }
19
+ } else {
20
+ out += '[^/]*'
21
+ }
22
+ } else if (ch === '?') {
23
+ out += '[^/]'
24
+ } else if (ch !== undefined) {
25
+ out += ch.replace(/[.+^${}()|[\]\\]/g, '\\$&')
26
+ }
27
+ }
28
+ return new RegExp(`${out}$`)
29
+ }
30
+
31
+ export function matchesGlob(pattern: string, filePath: string): boolean {
32
+ return globToRegExp(pattern).test(filePath)
33
+ }
@@ -0,0 +1,107 @@
1
+ // `pr-review install-skill`: link the bundled skill into the host repo's skill directories, so
2
+ // Claude Code (`.claude/skills`) and Codex (`.agents/skills`) both see `/pr-review-canvas`.
3
+ import { appendFile, cp, lstat, mkdir, readlink, realpath, rm, symlink, writeFile } from 'node:fs/promises'
4
+ import path from 'node:path'
5
+ import { PACKAGE_ROOT } from '../server/context.js'
6
+ import { readText } from '../store/atomic-json.js'
7
+
8
+ export const SKILL_NAME = 'pr-review-canvas'
9
+ export const SKILL_SOURCE_DIR = path.join(PACKAGE_ROOT, 'skills', SKILL_NAME)
10
+ export const CLAUDE_SKILLS_DIR = '.claude/skills'
11
+ /** Codex reads repo skills from `.agents/skills` under the project root (codex-rs/ext/skills). */
12
+ export const CODEX_SKILLS_DIR = '.agents/skills'
13
+
14
+ export async function ignoreLocalSettings(repoRoot: string): Promise<void> {
15
+ const file = path.join(repoRoot, '.gitignore')
16
+ const text = (await readText(file)) ?? ''
17
+ const entry = '.pr-review/settings.yml'
18
+ if (text.split(/\r?\n/).some(line => line === entry || line === `/${entry}`)) {
19
+ return
20
+ }
21
+ const newline = text.includes('\r\n') ? '\r\n' : '\n'
22
+ const separator = text.length > 0 && !text.endsWith('\n') ? newline : ''
23
+ await appendFile(file, `${separator}${entry}${newline}`, 'utf8')
24
+ }
25
+
26
+ export interface InstallSkillOptions {
27
+ /** Absolute skills directories to install into; each gets `<dir>/pr-review-canvas`. */
28
+ targets: Array<{ kind: 'claude' | 'codex'; dir: string }>
29
+ source?: string
30
+ /** Windows copies; everything else links. */
31
+ platform?: NodeJS.Platform
32
+ /** Replace a real directory that already sits at the target. */
33
+ force?: boolean
34
+ }
35
+
36
+ /** A real directory (a customized copy of the skill) sits where the link would go. */
37
+ export class SkillDirExistsError extends Error {
38
+ readonly path: string
39
+
40
+ constructor(target: string) {
41
+ super(`${target} is a directory, not a link to the bundled skill`)
42
+ this.name = 'SkillDirExistsError'
43
+ this.path = target
44
+ }
45
+ }
46
+
47
+ export type InstallStatus = 'linked' | 'copied' | 'exists' | 'replaced'
48
+
49
+ export interface InstallSkillResult {
50
+ skill: string
51
+ targets: Array<{ kind: 'claude' | 'codex'; path: string; status: InstallStatus }>
52
+ }
53
+
54
+ /** What sits at the target now: nothing, a symlink (with its target), or something else. */
55
+ async function inspect(target: string): Promise<{ kind: 'none' } | { kind: 'link'; to: string } | { kind: 'other' }> {
56
+ let stats: Awaited<ReturnType<typeof lstat>>
57
+ try {
58
+ stats = await lstat(target)
59
+ } catch {
60
+ return { kind: 'none' }
61
+ }
62
+ return stats.isSymbolicLink() ? { kind: 'link', to: await readlink(target) } : { kind: 'other' }
63
+ }
64
+
65
+ /** A copy carries a marker file, so a later run can tell it from a hand-made directory. */
66
+ export const COPY_MARKER = '.pr-review-install'
67
+
68
+ async function installOne(
69
+ source: string,
70
+ dir: string,
71
+ copy: boolean,
72
+ force: boolean
73
+ ): Promise<{ path: string; status: InstallStatus }> {
74
+ await mkdir(dir, { recursive: true })
75
+ const realDir = await realpath(dir)
76
+ const target = path.join(realDir, SKILL_NAME)
77
+ const current = await inspect(target)
78
+ if (current.kind === 'other' && !force && (await inspect(path.join(target, COPY_MARKER))).kind === 'none') {
79
+ throw new SkillDirExistsError(target)
80
+ }
81
+ if (copy) {
82
+ await rm(target, { recursive: true, force: true })
83
+ await cp(source, target, { recursive: true })
84
+ await writeFile(path.join(target, COPY_MARKER), `copied from ${source}\n`, 'utf8')
85
+ return { path: target, status: 'copied' }
86
+ }
87
+ const relative = path.relative(realDir, source)
88
+ if (current.kind === 'link' && current.to === relative) {
89
+ return { path: target, status: 'exists' }
90
+ }
91
+ if (current.kind !== 'none') {
92
+ await rm(target, { recursive: true, force: true })
93
+ }
94
+ await symlink(relative, target, 'dir')
95
+ return { path: target, status: current.kind === 'none' ? 'linked' : 'replaced' }
96
+ }
97
+
98
+ export async function installSkill(opts: InstallSkillOptions): Promise<InstallSkillResult> {
99
+ const source = await realpath(opts.source ?? SKILL_SOURCE_DIR)
100
+ const copy = (opts.platform ?? process.platform) === 'win32'
101
+ const targets: InstallSkillResult['targets'] = []
102
+ for (const t of opts.targets) {
103
+ const done = await installOne(source, t.dir, copy, opts.force === true)
104
+ targets.push({ kind: t.kind, ...done })
105
+ }
106
+ return { skill: SKILL_NAME, targets }
107
+ }