orizu 0.5.17 → 0.5.18

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.
@@ -0,0 +1,30 @@
1
+ import { parseJsonResponse, sanitizeHumanInlineText, } from './json-response.js';
2
+ export function printDiffCommentsSuppressionMarker(ctx, data) {
3
+ const reason = data.diffCommentsSuppressedReason;
4
+ if (typeof reason !== 'string' || reason.length === 0) {
5
+ return;
6
+ }
7
+ ctx.printLine(`Diff comments suppressed: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, reason)}`);
8
+ }
9
+ export async function exportOptimizationRunCommand(ctx) {
10
+ const runId = ctx.getPositionalArg(2) || ctx.getArg('--run-id');
11
+ const outPathArg = ctx.getArg('--out');
12
+ if (!runId) {
13
+ throw new Error('Usage: orizu optimizations export <run-id> [--out <path>] [--json]');
14
+ }
15
+ const response = await ctx.authedFetch(`/api/cli/optimization-runs/${encodeURIComponent(runId)}/export`);
16
+ if (!response.ok) {
17
+ throw new Error(`Failed to export optimization run: ${await response.text()}`);
18
+ }
19
+ const data = await parseJsonResponse(response, 'Optimization export');
20
+ if (ctx.hasJsonFlag() && !outPathArg) {
21
+ ctx.printJson(data);
22
+ return;
23
+ }
24
+ const filename = outPathArg
25
+ ? ctx.expandHomePath(outPathArg)
26
+ : `${runId}.optimization.json`;
27
+ ctx.writeTextFileEnsuringDir(filename, `${JSON.stringify(data, null, 2)}\n`);
28
+ ctx.printLine(`Saved optimization export to ${ctx.sanitizeTerminalText(filename)}`);
29
+ printDiffCommentsSuppressionMarker(ctx, data);
30
+ }
@@ -12,6 +12,7 @@
12
12
  */
13
13
  import { authedFetch } from './http.js';
14
14
  import { extractErrorMessage } from './error-response.js';
15
+ import { sanitizeTerminalText } from './json-response.js';
15
16
  // ALI-1175: best scores here derive from agent-reported score runs — nothing is
16
17
  // server-attested yet. Both the table footer AND the --json payload carry this
17
18
  // signal so no consumer, human or machine, mistakes the number for attested
@@ -25,9 +26,6 @@ function argValue(args, flag) {
25
26
  }
26
27
  return args[index + 1];
27
28
  }
28
- function sanitizeTerminalText(value) {
29
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
30
- }
31
29
  // Shared-parser behavior (artifact-pull.ts pattern): a non-JSON or malformed
32
30
  // body becomes a bounded, sanitized error — never a raw JSON.parse throw.
33
31
  async function parseJsonPayload(response, context) {
@@ -1,4 +1,5 @@
1
1
  import { authedFetch } from './http.js';
2
+ import { sanitizeTerminalText } from './json-response.js';
2
3
  import { appendRegistrationTagWarning } from './registration-tag-warning.js';
3
4
  /**
4
5
  * ADR-007 P4 (ALI-1074): the session-scoped, commit-first prompt push.
@@ -18,11 +19,6 @@ import { appendRegistrationTagWarning } from './registration-tag-warning.js';
18
19
  * Lives outside index.ts per the CLI line ratchet (ALI-976).
19
20
  */
20
21
  export const PROMPT_PRIMARY_GIT_FILENAME = 'prompt.md';
21
- // Mirrors index.ts `sanitizeTerminalText` (precedent: artifact-pull.ts keeps
22
- // its own copy to avoid importing the CLI entrypoint).
23
- function sanitizeTerminalText(value) {
24
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
25
- }
26
22
  /**
27
23
  * Build the git file set for a prompt-version draft: the primary text at the
28
24
  * canonical prompt.md plus every sidecar at its own relative path. A sidecar
@@ -74,6 +70,12 @@ export async function pushPromptDraft(input) {
74
70
  parentVersionId: input.parentVersionId,
75
71
  createdBy: manifest.provenance || { kind: 'human-edit' },
76
72
  files,
73
+ ...(input.report
74
+ ? {
75
+ reportMarkdown: input.report.markdown,
76
+ reportSourceName: input.report.sourceName,
77
+ }
78
+ : {}),
77
79
  }),
78
80
  });
79
81
  if (!response.ok) {
@@ -0,0 +1,86 @@
1
+ import { extractErrorMessage } from './error-response.js';
2
+ import { authedFetch } from './http.js';
3
+ import { parseJsonResponse, sanitizeTerminalText } from './json-response.js';
4
+ import { readMarkdownReportInput } from './markdown-report-input.js';
5
+ const IDENTICAL_PROMPT_VERSION_ERROR = 'Prompt version is identical to the current prompt version.';
6
+ function getArg(args, name) {
7
+ const index = args.indexOf(name);
8
+ if (index === -1 || index + 1 >= args.length)
9
+ return null;
10
+ return args[index + 1];
11
+ }
12
+ function rejectDashPrefixedOptionValue(name, value) {
13
+ if (value?.startsWith('-')) {
14
+ throw new Error(`Invalid value for ${name}: option values cannot start with a dash`);
15
+ }
16
+ }
17
+ export async function promptReportCommand(args, context) {
18
+ const prompt = getArg(args, '--prompt');
19
+ const projectArg = getArg(args, '--project');
20
+ const version = getArg(args, '--version');
21
+ const versionId = getArg(args, '--version-id');
22
+ for (const [name, value] of [
23
+ ['--prompt', prompt],
24
+ ['--project', projectArg],
25
+ ['--version-id', versionId],
26
+ ]) {
27
+ rejectDashPrefixedOptionValue(name, value);
28
+ }
29
+ if (!prompt) {
30
+ throw new Error('Usage: orizu prompts report set --prompt <id-or-name> --project <team/project> ' +
31
+ '(--version <n> | --version-id <id>) ' +
32
+ '(--report <markdown|@file> | --report-file <path>) [--json]');
33
+ }
34
+ if (Boolean(version) === Boolean(versionId)) {
35
+ throw new Error('Exactly one of --version or --version-id is required');
36
+ }
37
+ if (version && (!/^[1-9]\d*$/.test(version) || !Number.isSafeInteger(Number(version)))) {
38
+ throw new Error('--version must be a positive integer');
39
+ }
40
+ const report = readMarkdownReportInput(args, 'Prompt');
41
+ if (!report) {
42
+ throw new Error('Usage: orizu prompts report set --prompt <id-or-name> --project <team/project> ' +
43
+ '(--version <n> | --version-id <id>) ' +
44
+ '(--report <markdown|@file> | --report-file <path>) [--json]');
45
+ }
46
+ const project = projectArg || await context.resolveProjectSlug(null);
47
+ const query = new URLSearchParams({ project });
48
+ if (version)
49
+ query.set('version', version);
50
+ else
51
+ query.set('versionId', versionId);
52
+ const fetcher = context.fetcher ?? authedFetch;
53
+ const response = await fetcher(`/api/cli/prompts/${encodeURIComponent(prompt)}/report?${query.toString()}`, {
54
+ method: 'PATCH',
55
+ headers: { 'Content-Type': 'application/json' },
56
+ body: JSON.stringify({
57
+ reportMarkdown: report.markdown,
58
+ reportSourceName: report.sourceName,
59
+ }),
60
+ });
61
+ if (!response.ok) {
62
+ throw new Error(`Failed to update prompt report: ${await extractErrorMessage(response)}`);
63
+ }
64
+ const data = await parseJsonResponse(response, 'Prompt report update');
65
+ if (context.json) {
66
+ context.printJson(data);
67
+ return;
68
+ }
69
+ const versionLabel = data.version
70
+ ? data.version.versionNumber === null
71
+ ? data.version.id
72
+ : `v${data.version.versionNumber}`
73
+ : data.promptVersionId || versionId || `v${version}`;
74
+ const promptName = data.prompt?.name || prompt;
75
+ const status = data.version?.status || 'updated';
76
+ context.printLine(`Uploaded prompt report for ${sanitizeTerminalText(promptName)} ` +
77
+ `(${sanitizeTerminalText(versionLabel)}) [${sanitizeTerminalText(status)}]`);
78
+ }
79
+ export async function promptPushErrorMessage(response, kind, hasReport) {
80
+ const message = await extractErrorMessage(response);
81
+ if (hasReport && response.status === 409 && message === IDENTICAL_PROMPT_VERSION_ERROR) {
82
+ return (`${IDENTICAL_PROMPT_VERSION_ERROR} ` +
83
+ 'Attach the report to the existing version with `orizu prompts report set`.');
84
+ }
85
+ return `Failed to push ${kind}: ${message}`;
86
+ }
@@ -1,27 +1,6 @@
1
1
  import { authedFetch } from './http.js';
2
+ import { sanitizeTerminalText } from './json-response.js';
2
3
  import { appendRegistrationTagWarning } from './registration-tag-warning.js';
3
- /**
4
- * ADR-007 P5 (ALI-1074): the session-scoped, commit-first scorer push.
5
- *
6
- * `orizu scorers register --session <id>` creates the version through
7
- * POST /api/cli/scorers/drafts: the server commits the scorer's canonical
8
- * `manifest.json` to the session's `orizu/session-*` branch (one Git Data API
9
- * commit) and then writes a DRAFT row pinning
10
- * repo_path/content_sha/commit_sha. The draft seals — and gets its version
11
- * number — when the session branch merges via `orizu session finish` +
12
- * promotion-manifest apply.
13
- *
14
- * The git filename is ALWAYS `manifest.json`: the server read side
15
- * (lib/artifact-git-read.ts SCORER_MANIFEST_FILENAMES) prefers that name.
16
- *
17
- * Lives outside index.ts per the CLI line ratchet (ALI-976); mirrors
18
- * prompt-draft-push.ts.
19
- */
20
- // Mirrors index.ts `sanitizeTerminalText` (precedent: artifact-pull.ts keeps
21
- // its own copy to avoid importing the CLI entrypoint).
22
- function sanitizeTerminalText(value) {
23
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
24
- }
25
4
  /** The full `orizu scorers register` command (moved out of index.ts for the
26
5
  * CLI line ratchet): --session takes the ADR-007 P5 commit-first draft path,
27
6
  * sessionless stays on the legacy create endpoint. */
@@ -11,9 +11,7 @@
11
11
  * own argument parsing and printing via injected io.
12
12
  */
13
13
  import { authedFetch } from './http.js';
14
- function sanitizeTerminalText(value) {
15
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
16
- }
14
+ import { sanitizeTerminalText } from './json-response.js';
17
15
  function argValue(args, flag) {
18
16
  const index = args.indexOf(flag);
19
17
  if (index === -1 || index + 1 >= args.length || args[index + 1].startsWith('--')) {
@@ -1,31 +1,8 @@
1
1
  import { basename } from 'path';
2
2
  import { zipDirectoryToBase64 } from './artifact-archive.js';
3
3
  import { authedFetch } from './http.js';
4
+ import { sanitizeTerminalText } from './json-response.js';
4
5
  import { appendRegistrationTagWarning } from './registration-tag-warning.js';
5
- /**
6
- * ADR-007 P5 (ALI-1074): the session-scoped, commit-first runner/optimizer
7
- * push.
8
- *
9
- * `orizu runners|optimizers push --session <id>` sends the SAME deterministic
10
- * zip as the legacy push to POST /api/cli/{runners|optimizers}/drafts: the
11
- * server unpacks the zip's file set and commits it to the session's
12
- * `orizu/session-*` branch (one Git Data API commit; binary entries travel
13
- * base64), keeps uploading the zip to storage (dual-write until ALI-1046),
14
- * and writes a DRAFT row pinning repo_path/content_sha/commit_sha. The draft
15
- * seals — and gets its version number — when the session branch merges via
16
- * `orizu session finish` + promotion-manifest apply.
17
- *
18
- * Labels cannot ride a draft push (`--label` with `--session` is an error at
19
- * the command layer): labels address sealed versions only.
20
- *
21
- * Lives outside index.ts per the CLI line ratchet (ALI-976); mirrors
22
- * prompt-draft-push.ts.
23
- */
24
- // Mirrors index.ts `sanitizeTerminalText` (precedent: artifact-pull.ts keeps
25
- // its own copy to avoid importing the CLI entrypoint).
26
- function sanitizeTerminalText(value) {
27
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '');
28
- }
29
6
  /** The full `orizu runners|optimizers push` command (moved out of index.ts
30
7
  * for the CLI line ratchet): --session takes the ADR-007 P5 commit-first
31
8
  * draft path, sessionless stays on the legacy zip-only endpoint. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.17",
3
+ "version": "0.5.18",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -8,6 +8,7 @@
8
8
 
9
9
  import { authedFetch } from './http.js'
10
10
  import { extractErrorMessage } from './error-response.js'
11
+ import { sanitizeTerminalText } from './json-response.js'
11
12
 
12
13
  type ArchiveStatus = 'active' | 'archived' | 'all'
13
14
  type ArchiveAction = 'archive' | 'restore'
@@ -109,13 +110,6 @@ function argValue(args: readonly string[], flag: string): string | null {
109
110
  return args[index + 1]
110
111
  }
111
112
 
112
- function sanitizeTerminalText(value: unknown): string {
113
- return String(value).replace(
114
- /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
115
- ''
116
- )
117
- }
118
-
119
113
  function columnWidths(headers: string[], rows: string[][]): number[] {
120
114
  return headers.map((header, index) =>
121
115
  Math.max(header.length, ...rows.map(row => row[index]?.length || 0))
@@ -18,6 +18,7 @@ import { tmpdir } from 'os'
18
18
  import { dirname, join } from 'path'
19
19
 
20
20
  import { authedFetch } from './http.js'
21
+ import { sanitizeTerminalText } from './json-response.js'
21
22
 
22
23
  export type PullableArtifactKind = 'runner' | 'optimizer'
23
24
 
@@ -31,10 +32,6 @@ export interface PullArtifactIo {
31
32
  // Shared with the runners exec download path in index.ts.
32
33
  export const ARTIFACT_MAX_BYTES = 25 * 1024 * 1024
33
34
 
34
- function sanitizeTerminalText(value: unknown): string {
35
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
36
- }
37
-
38
35
  function expandHomePath(path: string): string {
39
36
  if (path.startsWith('~/')) {
40
37
  const home = process.env.HOME || ''
@@ -1,3 +1,9 @@
1
+ import {
2
+ extractErrorMessage,
3
+ isJsonErrorResponsePayload,
4
+ } from './error-response.js'
5
+ import { sanitizeHumanInlineText } from './json-response.js'
6
+
1
7
  interface DiffCommentAuthor {
2
8
  name?: string
3
9
  person?: { name?: string }
@@ -5,6 +11,7 @@ interface DiffCommentAuthor {
5
11
 
6
12
  type DiffCommentContextUnavailableReason =
7
13
  | 'diff_degraded_size_limit'
14
+ | 'diff_degraded_cell_limit'
8
15
  | 'bodies_unavailable_event_cap'
9
16
  | 'body_unresolvable'
10
17
  | 'anchor_out_of_range'
@@ -25,8 +32,8 @@ interface DiffCommentPayloadComment {
25
32
  anchor: { side: 'old' | 'new'; line: number }
26
33
  context: {
27
34
  lineText: string
28
- lineOp: 'add' | 'del' | 'context'
29
- hunk: { lines: DiffCommentContextLine[] }
35
+ lineOp: 'add' | 'del' | 'context' | null
36
+ hunk: { lines: DiffCommentContextLine[] } | null
30
37
  } | null
31
38
  contextUnavailableReason?: DiffCommentContextUnavailableReason
32
39
  }
@@ -71,6 +78,7 @@ export interface DiffCommentsCliContext {
71
78
  }
72
79
 
73
80
  const DETAIL_LEVELS = ['hunk', 'diff', 'full'] as const
81
+ const CONTEXT_UNAVAILABLE_WITHOUT_REASON = ' Context unavailable (no reason supplied)'
74
82
 
75
83
  function isCandidatePair(
76
84
  pair: DiffCommentPayloadPair
@@ -92,18 +100,47 @@ function sanitizeHumanText(
92
100
  return ctx.sanitizeTerminalText(value).replaceAll('\r', '')
93
101
  }
94
102
 
95
- function sanitizeHumanInlineText(
96
- ctx: DiffCommentsCliContext,
97
- value: unknown
98
- ): string {
99
- return sanitizeHumanText(ctx, value).replaceAll('\n', '\\n')
103
+ // Quote untrusted multiline blocks so none of their rows can impersonate a
104
+ // genuine hunk row, whose marker begins immediately after the four-space indent.
105
+ function printHumanTextBlock(ctx: DiffCommentsCliContext, value: unknown): void {
106
+ for (const line of sanitizeHumanText(ctx, value).split('\n')) {
107
+ ctx.printLine(` │ ${line}`)
108
+ }
109
+ }
110
+
111
+ function hasExplicitlyEmptyJsonError(response: Response, rawBody: string): boolean {
112
+ try {
113
+ const payload: unknown = JSON.parse(rawBody)
114
+ return isJsonErrorResponsePayload(response, payload) && payload.error.length === 0
115
+ } catch {
116
+ return false
117
+ }
118
+ }
119
+
120
+ async function extractDiffCommentsErrorMessage(response: Response): Promise<string> {
121
+ const statusFallback = `HTTP ${response.status}`
122
+
123
+ try {
124
+ const rawBody = await response.text()
125
+ if (hasExplicitlyEmptyJsonError(response, rawBody)) {
126
+ return statusFallback
127
+ }
128
+
129
+ const copiedResponse = new Response(rawBody, { headers: response.headers })
130
+ const extractedMessage = await extractErrorMessage(copiedResponse)
131
+ return extractedMessage.trim() ? extractedMessage : statusFallback
132
+ } catch {
133
+ return statusFallback
134
+ }
100
135
  }
101
136
 
102
137
  function humanLineNumber(
103
138
  ctx: DiffCommentsCliContext,
104
139
  line: number | null
105
140
  ): string {
106
- return line === null ? '-' : sanitizeHumanText(ctx, String(line + 1))
141
+ return line === null
142
+ ? '-'
143
+ : sanitizeHumanInlineText(ctx.sanitizeTerminalText, String(line + 1))
107
144
  }
108
145
 
109
146
  function buildParams(ctx: DiffCommentsCliContext): URLSearchParams {
@@ -123,9 +160,15 @@ function buildParams(ctx: DiffCommentsCliContext): URLSearchParams {
123
160
  ctx.rejectDashPrefixedOptionValue(name, value)
124
161
  }
125
162
 
163
+ if (runId === '' || promptId === '') {
164
+ throw new Error(usage())
165
+ }
126
166
  if (Number(Boolean(runId)) + Number(Boolean(promptId)) !== 1) {
127
167
  throw new Error(usage())
128
168
  }
169
+ if (from === '' || to === '') {
170
+ throw new Error('--from and --to must not be empty')
171
+ }
129
172
  if (Boolean(from) !== Boolean(to)) {
130
173
  throw new Error('--from and --to must be provided together')
131
174
  }
@@ -164,16 +207,17 @@ function printDiffComments(ctx: DiffCommentsCliContext, payload: DiffCommentsPay
164
207
  ctx.printLine('')
165
208
  ctx.printLine(
166
209
  `${hasCandidateIds ? 'Candidates' : 'Versions'} ` +
167
- `${sanitizeHumanInlineText(ctx, from)} → ${sanitizeHumanInlineText(ctx, to)}`
210
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, from)} → ` +
211
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, to)}`
168
212
  )
169
213
  if (pair.degraded) {
170
- ctx.printLine(` Context unavailable: ${sanitizeHumanText(ctx, pair.degraded.reason)}`)
214
+ ctx.printLine(
215
+ ` Context unavailable: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, pair.degraded.reason)}`
216
+ )
171
217
  }
172
218
  if (pair.diff !== null) {
173
219
  ctx.printLine(' Diff:')
174
- for (const line of sanitizeHumanText(ctx, pair.diff).split('\n')) {
175
- ctx.printLine(` ${line}`)
176
- }
220
+ printHumanTextBlock(ctx, pair.diff)
177
221
  }
178
222
  if (pair.bodies !== null) {
179
223
  for (const [label, body] of [
@@ -181,29 +225,40 @@ function printDiffComments(ctx: DiffCommentsCliContext, payload: DiffCommentsPay
181
225
  ['To body', pair.bodies.to],
182
226
  ] as const) {
183
227
  ctx.printLine(` ${label}:`)
184
- for (const line of sanitizeHumanText(ctx, body).split('\n')) {
185
- ctx.printLine(` ${line}`)
186
- }
228
+ printHumanTextBlock(ctx, body)
187
229
  }
188
230
  }
189
231
 
190
232
  for (const comment of pair.comments) {
191
233
  const authorName = comment.author?.person?.name || comment.author?.name || 'Unknown'
192
234
  ctx.printLine(
193
- ` ${sanitizeHumanInlineText(ctx, comment.id)} · ` +
194
- `${sanitizeHumanInlineText(ctx, authorName)} · ` +
195
- `${sanitizeHumanInlineText(ctx, comment.createdAt)} · ` +
196
- `${sanitizeHumanInlineText(ctx, comment.anchor.side)} line ` +
197
- `${sanitizeHumanInlineText(ctx, String(comment.anchor.line + 1))}`
235
+ ` ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.id)} · ` +
236
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, authorName)} · ` +
237
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.createdAt)} · ` +
238
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.anchor.side)} line ` +
239
+ `${sanitizeHumanInlineText(ctx.sanitizeTerminalText, String(comment.anchor.line + 1))}`
198
240
  )
199
- for (const line of sanitizeHumanText(ctx, comment.body).split('\n')) {
200
- ctx.printLine(` ${line}`)
201
- }
241
+ printHumanTextBlock(ctx, comment.body)
202
242
 
243
+ if (comment.contextUnavailableReason) {
244
+ ctx.printLine(
245
+ ` Context unavailable: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.contextUnavailableReason)}`
246
+ )
247
+ if (comment.context) {
248
+ ctx.printLine(
249
+ ` Anchored line: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.context.lineText)}`
250
+ )
251
+ }
252
+ continue
253
+ }
203
254
  if (!comment.context) {
255
+ ctx.printLine(CONTEXT_UNAVAILABLE_WITHOUT_REASON)
256
+ continue
257
+ }
258
+ if (!comment.context.hunk) {
259
+ ctx.printLine(CONTEXT_UNAVAILABLE_WITHOUT_REASON)
204
260
  ctx.printLine(
205
- ` Context unavailable: ` +
206
- `${sanitizeHumanText(ctx, comment.contextUnavailableReason ?? 'body_unresolvable')}`
261
+ ` Anchored line: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, comment.context.lineText)}`
207
262
  )
208
263
  continue
209
264
  }
@@ -217,7 +272,7 @@ function printDiffComments(ctx: DiffCommentsCliContext, payload: DiffCommentsPay
217
272
  ctx.printLine(
218
273
  ` ${marker} old:${humanLineNumber(ctx, line.oldLine)} ` +
219
274
  `new:${humanLineNumber(ctx, line.newLine)} | ` +
220
- `${prefix}${sanitizeHumanText(ctx, line.text)}`
275
+ `${prefix}${sanitizeHumanInlineText(ctx.sanitizeTerminalText, line.text)}`
221
276
  )
222
277
  }
223
278
  }
@@ -228,16 +283,10 @@ export async function diffCommentsCommand(ctx: DiffCommentsCliContext) {
228
283
  const params = buildParams(ctx)
229
284
  const response = await ctx.authedFetch(`/api/cli/diff-comments?${params.toString()}`)
230
285
  if (!response.ok) {
231
- let message = `HTTP ${response.status}`
232
- try {
233
- const payload = await response.clone().json() as { error?: unknown }
234
- if (typeof payload.error === 'string' && payload.error.trim()) {
235
- message = sanitizeHumanInlineText(ctx, payload.error)
236
- }
237
- } catch {
238
- // Keep the status-only error for non-JSON responses.
239
- }
240
- throw new Error(`Failed to fetch diff comments: ${message}`)
286
+ const message = await extractDiffCommentsErrorMessage(response)
287
+ throw new Error(
288
+ `Failed to fetch diff comments: ${sanitizeHumanInlineText(ctx.sanitizeTerminalText, message)}`
289
+ )
241
290
  }
242
291
 
243
292
  const payload = await ctx.parseJsonResponse<DiffCommentsPayload>(response, 'Diff comments')
@@ -1,37 +1,39 @@
1
+ import { sanitizeTerminalText } from './json-response.js'
2
+
1
3
  interface ErrorResponsePayload {
2
4
  error?: string
3
5
  }
4
6
 
5
7
  const ERROR_BODY_PREVIEW_LIMIT = 180
6
8
 
9
+ export function isJsonErrorResponsePayload(
10
+ response: Response,
11
+ payload: unknown
12
+ ): payload is ErrorResponsePayload & { error: string } {
13
+ const contentType = response.headers.get('content-type') || ''
14
+ return (
15
+ contentType.includes('application/json') &&
16
+ payload !== null &&
17
+ typeof payload === 'object' &&
18
+ !Array.isArray(payload) &&
19
+ typeof (payload as ErrorResponsePayload).error === 'string'
20
+ )
21
+ }
22
+
7
23
  function boundedTerminalPreview(value: unknown): string {
8
- return String(value)
9
- .replace(
10
- /[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g,
11
- ''
12
- )
13
- .slice(0, ERROR_BODY_PREVIEW_LIMIT)
24
+ return sanitizeTerminalText(value).slice(0, ERROR_BODY_PREVIEW_LIMIT)
14
25
  }
15
26
 
16
27
  export async function extractErrorMessage(response: Response): Promise<string> {
17
- const contentType = response.headers.get('content-type') || ''
18
28
  const rawBody = await response.text()
19
29
 
20
- if (contentType.includes('application/json')) {
21
- try {
22
- const payload = JSON.parse(rawBody) as ErrorResponsePayload | null
23
- if (
24
- payload &&
25
- typeof payload === 'object' &&
26
- !Array.isArray(payload) &&
27
- typeof payload.error === 'string' &&
28
- payload.error.length > 0
29
- ) {
30
- return boundedTerminalPreview(payload.error)
31
- }
32
- } catch {
33
- // Fall through to raw body
30
+ try {
31
+ const payload: unknown = JSON.parse(rawBody)
32
+ if (isJsonErrorResponsePayload(response, payload) && payload.error.length > 0) {
33
+ return boundedTerminalPreview(payload.error)
34
34
  }
35
+ } catch {
36
+ // Fall through to raw body
35
37
  }
36
38
 
37
39
  return boundedTerminalPreview(rawBody)
@@ -19,20 +19,11 @@ import { existsSync, mkdirSync } from 'fs'
19
19
  import { join } from 'path'
20
20
 
21
21
  import { authedFetch } from './http.js'
22
+ import { sanitizeTerminalText } from './json-response.js'
22
23
  import { assertWorkspaceDirUsable, workspaceExists } from './workspace.js'
23
24
 
24
25
  export type SetupFetcher = (path: string, init?: RequestInit) => Promise<Response>
25
26
 
26
- /**
27
- * Strip terminal control characters from server-supplied strings before they
28
- * are printed (mirrors index.ts `sanitizeTerminalText`), so a hostile or
29
- * mangled provisioning `warnings[]`/`defaultBranch` value can't inject escape
30
- * sequences into the user's terminal.
31
- */
32
- function sanitizeServerText(value: unknown): string {
33
- return String(value).replace(/[\u0000-\u0008\u000B\u000C\u000E-\u001F\u007F-\u009F]/g, '')
34
- }
35
-
36
27
  export interface GithubLinkIo {
37
28
  print: (line: string) => void
38
29
  fetcher?: SetupFetcher
@@ -349,12 +340,12 @@ export async function runHostedAttach(
349
340
  if (data.rulesetApplied === true) {
350
341
  io.print(
351
342
  ` Default-branch protection applied (ruleset "orizu-default-branch-protection"${
352
- data.defaultBranch ? ` on ${sanitizeServerText(data.defaultBranch)}` : ''
343
+ data.defaultBranch ? ` on ${sanitizeTerminalText(data.defaultBranch)}` : ''
353
344
  }).`
354
345
  )
355
346
  }
356
347
  for (const warning of data.warnings ?? []) {
357
- io.print(` ⚠ ${sanitizeServerText(warning)}`)
348
+ io.print(` ⚠ ${sanitizeTerminalText(warning)}`)
358
349
  }
359
350
  }
360
351
 
package/src/help.ts CHANGED
@@ -362,10 +362,30 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
362
362
  },
363
363
  {
364
364
  path: ['prompts', 'push'],
365
- usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
365
+ usage: 'orizu prompts push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--report-file <path>] [--json]',
366
366
  summary: 'Push a local prompt artifact directory as a new version (with --session: a commit-first git draft).',
367
367
  group: 'Prompts and judges',
368
368
  },
369
+ {
370
+ path: ['prompts', 'report', 'set'],
371
+ usage: 'orizu prompts report set --prompt <id-or-name> --project <team/project> (--version <n> | --version-id <id>) (--report <markdown|@file> | --report-file <path>) [--json]',
372
+ summary: 'Upload or replace the Markdown report for one explicit prompt version.',
373
+ group: 'Prompts and judges',
374
+ aliases: [['prompts', 'report', 'upload']],
375
+ options: [
376
+ { name: '--prompt <id-or-name>', help: 'Prompt ID or name.', required: true },
377
+ { name: '--project <team/project>', help: 'Project slug containing the prompt.', required: true },
378
+ { name: '--version <n>', help: 'Positive prompt version number.' },
379
+ { name: '--version-id <id>', help: 'Explicit prompt version ID.' },
380
+ { name: '--report <markdown|@file>', help: 'Inline Markdown or an @file reference.' },
381
+ { name: '--report-file <path>', help: 'Markdown file to upload.' },
382
+ { name: '--json', help: 'Emit the updated prompt report payload.' },
383
+ ],
384
+ examples: [
385
+ 'orizu prompts report set --prompt Generator --project core/evals --version 3 --report-file ./report.md',
386
+ 'orizu prompts report upload --prompt Generator --project core/evals --version-id <id> --report @./report.md',
387
+ ],
388
+ },
369
389
  {
370
390
  path: ['prompts', 'labels', 'set'],
371
391
  usage: 'orizu prompts labels set <prompt-name> <label> --version <version-id> [--project <team/project>] [--json]',
@@ -398,7 +418,7 @@ export const COMMAND_DOCS: CliCommandDoc[] = [
398
418
  },
399
419
  {
400
420
  path: ['judges', 'push'],
401
- usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--json]',
421
+ usage: 'orizu judges push <dir> [--runner-version <id>] [--project <team/project>] [--parent <version-id>] [--session <session-id>] [--report-file <path>] [--json]',
402
422
  summary: 'Push a local judge artifact directory as a new version (with --session: a commit-first git draft).',
403
423
  group: 'Prompts and judges',
404
424
  },