orizu 0.5.6 → 0.5.8

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.
@@ -5,7 +5,7 @@
5
5
  * server — re-applying returns the stored outcome.
6
6
  */
7
7
  import { authedFetch } from './http.js';
8
- const MANIFEST_ACTIONS = new Set(['approve', 'reject', 'apply']);
8
+ const MANIFEST_ACTIONS = new Set(['approve', 'reject', 'apply', 'revert']);
9
9
  async function responseMessage(response) {
10
10
  try {
11
11
  const data = (await response.clone().json());
@@ -73,6 +73,86 @@ function asRecord(value) {
73
73
  function num(value) {
74
74
  return typeof value === 'number' ? value : 0;
75
75
  }
76
+ // Auto-applied manifests (ALI-1040) landed by SERVER POLICY, not a human approver
77
+ // (approved_by is NULL). Mark them so a reviewer can tell policy applies from
78
+ // human applies at a glance; the reason lives in the outcome for `show`.
79
+ function wasAutoApplied(manifest) {
80
+ return asRecord(manifest.outcome).autoApplied === true;
81
+ }
82
+ /** STATUS cell: `applied*` when the server auto-applied it, else the raw status. */
83
+ function statusDisplay(manifest) {
84
+ const status = typeof manifest.status === 'string' ? manifest.status : '(unknown)';
85
+ return wasAutoApplied(manifest) ? `${status}*` : status;
86
+ }
87
+ /** One-line auto-apply audit note for `show`, or null when not auto-applied. */
88
+ function autoApplyNote(manifest) {
89
+ if (!wasAutoApplied(manifest))
90
+ return null;
91
+ const reason = asRecord(manifest.outcome).autoApplyReason;
92
+ return typeof reason === 'string' && reason.length > 0
93
+ ? `auto-applied by policy: ${reason}`
94
+ : 'auto-applied by policy';
95
+ }
96
+ // -- list table (ALI-1038) ----------------------------------------------------
97
+ /** "3m" / "4h" / "2d" — enough resolution to pick a manifest out of a list. */
98
+ function relativeAge(iso, now) {
99
+ if (typeof iso !== 'string')
100
+ return '-';
101
+ const then = Date.parse(iso);
102
+ if (Number.isNaN(then))
103
+ return '-';
104
+ const mins = Math.max(0, Math.floor((now - then) / 60_000));
105
+ if (mins < 60)
106
+ return `${mins}m`;
107
+ const hours = Math.floor(mins / 60);
108
+ if (hours < 48)
109
+ return `${hours}h`;
110
+ return `${Math.floor(hours / 24)}d`;
111
+ }
112
+ /** One-glance change summary from the stored proposedState — no live API call. */
113
+ function changesSummary(manifest) {
114
+ if (manifest.actionType !== 'repo_merge')
115
+ return '-';
116
+ const proposed = asRecord(manifest.proposedState);
117
+ const files = num(proposed.filesChanged);
118
+ const parts = `${files} file${files === 1 ? '' : 's'} +${num(proposed.additions)}/-${num(proposed.deletions)}`;
119
+ // Instruction files steer agent behavior — reviewers should see the flag in the list.
120
+ return proposed.touches_instruction_files === true ? `${parts} !instr` : parts;
121
+ }
122
+ /** Reviewable-first (pending_approval, then draft), newest first within a group. */
123
+ function listOrder(a, b) {
124
+ const rank = (m) => m.status === 'pending_approval' ? 0 : m.status === 'draft' ? 1 : 2;
125
+ const byRank = rank(a) - rank(b);
126
+ if (byRank !== 0)
127
+ return byRank;
128
+ const created = (m) => typeof m.createdAt === 'string' ? Date.parse(m.createdAt) || 0 : 0;
129
+ return created(b) - created(a);
130
+ }
131
+ /** Render manifests as the standard padEnd table (matches teams/projects list). */
132
+ function manifestsTable(manifests, now) {
133
+ if (manifests.length === 0)
134
+ return 'promotion manifests\n(none)';
135
+ const rows = [...manifests].sort(listOrder).map(manifest => ({
136
+ id: typeof manifest.id === 'string' ? manifest.id : '(unknown)',
137
+ status: statusDisplay(manifest),
138
+ type: typeof manifest.actionType === 'string' ? manifest.actionType : '(unknown)',
139
+ changes: changesSummary(manifest),
140
+ author: typeof manifest.authorActorType === 'string' ? manifest.authorActorType : '-',
141
+ age: relativeAge(manifest.createdAt, now),
142
+ }));
143
+ const idWidth = Math.max('ID'.length, ...rows.map(row => row.id.length));
144
+ const statusWidth = Math.max('STATUS'.length, ...rows.map(row => row.status.length));
145
+ const typeWidth = Math.max('TYPE'.length, ...rows.map(row => row.type.length));
146
+ const changesWidth = Math.max('CHANGES'.length, ...rows.map(row => row.changes.length));
147
+ const authorWidth = Math.max('AUTHOR'.length, ...rows.map(row => row.author.length));
148
+ const header = `${'ID'.padEnd(idWidth)} ${'STATUS'.padEnd(statusWidth)} ${'TYPE'.padEnd(typeWidth)} ` +
149
+ `${'CHANGES'.padEnd(changesWidth)} ${'AUTHOR'.padEnd(authorWidth)} AGE`;
150
+ const divider = `${'-'.repeat(idWidth)} ${'-'.repeat(statusWidth)} ${'-'.repeat(typeWidth)} ` +
151
+ `${'-'.repeat(changesWidth)} ${'-'.repeat(authorWidth)} ---`;
152
+ const body = rows.map(row => `${row.id.padEnd(idWidth)} ${row.status.padEnd(statusWidth)} ${row.type.padEnd(typeWidth)} ` +
153
+ `${row.changes.padEnd(changesWidth)} ${row.author.padEnd(authorWidth)} ${row.age}`);
154
+ return [header, divider, ...body].join('\n');
155
+ }
76
156
  // repo_merge manifests (ALI-972) render the compare summary from stored evidence
77
157
  // — no live GitHub call — so a reviewer can approve informed without a PR.
78
158
  function repoMergeShow(manifest) {
@@ -108,7 +188,7 @@ export async function manifestsCommand(args, io) {
108
188
  const response = await fetcherFrom(io)(`/api/cli/promotion-manifests${query}`);
109
189
  const data = await requireOk(response, 'Manifest list');
110
190
  const manifests = Array.isArray(data.manifests) ? data.manifests : [];
111
- emit(io, { manifests }, ['promotion manifests', ...manifests.map(manifestLine)].join('\n'));
191
+ emit(io, { manifests }, manifestsTable(manifests, Date.now()));
112
192
  return 0;
113
193
  }
114
194
  if (subcommand === 'show') {
@@ -119,7 +199,9 @@ export async function manifestsCommand(args, io) {
119
199
  const response = await fetcherFrom(io)(`/api/cli/promotion-manifests/${encodeURIComponent(id)}`);
120
200
  const data = await requireOk(response, 'Manifest show');
121
201
  const manifest = (data.manifest ?? {});
122
- const human = manifest.actionType === 'repo_merge' ? repoMergeShow(manifest) : manifestLine(manifest);
202
+ const base = manifest.actionType === 'repo_merge' ? repoMergeShow(manifest) : manifestLine(manifest);
203
+ const note = autoApplyNote(manifest);
204
+ const human = note ? `${base}\n ${note}` : base;
123
205
  emit(io, { manifest }, human);
124
206
  return 0;
125
207
  }
@@ -135,9 +217,25 @@ export async function manifestsCommand(args, io) {
135
217
  });
136
218
  const data = await requireOk(response, `Manifest ${subcommand}`);
137
219
  const manifest = (data.manifest ?? {});
138
- emit(io, { manifest }, manifestLine(manifest));
220
+ const lines = [manifestLine(manifest)];
221
+ // Reject reaps the session branch best-effort; a failed reap must never be
222
+ // silent (ALI-1043) — surface it with the exact cleanup command.
223
+ if (subcommand === 'reject' && manifest.actionType === 'repo_merge') {
224
+ const outcome = asRecord(manifest.outcome);
225
+ const branch = asRecord(manifest.proposedState).branch;
226
+ if (outcome.branchDeleted === true) {
227
+ lines.push('session branch deleted');
228
+ }
229
+ else if (typeof branch === 'string' && branch.length > 0) {
230
+ lines.push(`warning: the session branch could not be deleted — remove it with: git push origin --delete ${branch}`);
231
+ }
232
+ else {
233
+ lines.push('warning: the session branch could not be deleted (branch name unavailable)');
234
+ }
235
+ }
236
+ emit(io, { manifest }, lines.join('\n'));
139
237
  return 0;
140
238
  }
141
- io.print('Usage: orizu manifests <list|show|approve|reject|apply> ... [--json]');
239
+ io.print('Usage: orizu manifests <list|show|approve|reject|apply|revert> ... [--json]');
142
240
  return 1;
143
241
  }
@@ -0,0 +1,325 @@
1
+ const COMMENTS_TARGET_USAGE = 'Use exactly one of --prompt <id-or-name>, --run <run-id>, or --task <task-id>';
2
+ function reportCommentsUsage(action) {
3
+ const target = '(--prompt <id-or-name> --project <team/project> [--label <label> | --version <id>] | --run <run-id> | --task <task-id>)';
4
+ if (action === 'list') {
5
+ return `Usage: orizu comments list ${target} [--json]`;
6
+ }
7
+ if (action === 'add') {
8
+ return `Usage: orizu comments add ${target} --body <text|@file> [--anchor <text>] [--lines <start:end>] [--via <name>] [--json]`;
9
+ }
10
+ if (action === 'reply') {
11
+ return 'Usage: orizu comments reply <comment-id> --body <text|@file> [--via <name>] [--json]';
12
+ }
13
+ if (action === 'edit') {
14
+ return 'Usage: orizu comments edit <comment-id> --body <text|@file> [--json]';
15
+ }
16
+ return `Usage: orizu comments ${action} <comment-id> [--json]`;
17
+ }
18
+ function compactTerminalText(ctx, value, maxLength = 160) {
19
+ const compact = ctx.sanitizeTerminalText(value).replace(/\s+/g, ' ').trim();
20
+ if (compact.length <= maxLength) {
21
+ return compact;
22
+ }
23
+ return `${compact.slice(0, Math.max(0, maxLength - 1)).trim()}…`;
24
+ }
25
+ async function readReportCommentError(ctx, response) {
26
+ const status = response.status
27
+ ? `HTTP ${response.status}${response.statusText ? ` ${response.statusText}` : ''}`
28
+ : 'request failed';
29
+ const contentType = response.headers.get('content-type') || '';
30
+ if (!contentType.toLowerCase().includes('application/json')) {
31
+ return status;
32
+ }
33
+ try {
34
+ const parsed = await response.clone().json();
35
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
36
+ const error = parsed.error;
37
+ if (typeof error === 'string' && error.trim()) {
38
+ return compactTerminalText(ctx, error, 220);
39
+ }
40
+ }
41
+ }
42
+ catch {
43
+ // Fall through to the status-only message for malformed JSON errors.
44
+ }
45
+ return status;
46
+ }
47
+ function printIndentedBody(ctx, value, indent) {
48
+ const lines = ctx.sanitizeTerminalText(value || '').split(/\r?\n/);
49
+ for (const line of lines) {
50
+ ctx.printLine(`${indent}${line}`);
51
+ }
52
+ }
53
+ function formatReportCommentAnchor(ctx, anchor) {
54
+ if (!anchor) {
55
+ return null;
56
+ }
57
+ const lineLabel = anchor.startLine && anchor.endLine
58
+ ? anchor.startLine === anchor.endLine
59
+ ? `line ${anchor.startLine}`
60
+ : `lines ${anchor.startLine}-${anchor.endLine}`
61
+ : null;
62
+ const selectedText = anchor.text ? `"${compactTerminalText(ctx, anchor.text, 180)}"` : null;
63
+ if (lineLabel && selectedText) {
64
+ return `${lineLabel}: ${selectedText}`;
65
+ }
66
+ return lineLabel || selectedText;
67
+ }
68
+ function reportCommentAuthorName(author) {
69
+ const personName = author?.person?.name;
70
+ const name = personName || author?.name || 'Unknown';
71
+ const via = author?.via ? ` via ${author.via}` : '';
72
+ const agent = author?.actor === 'agent'
73
+ ? author.onBehalfOf
74
+ ? ` (agent on behalf of ${author.onBehalfOf})`
75
+ : ' (agent)'
76
+ : '';
77
+ return `${name}${via}${agent}`;
78
+ }
79
+ function printReportCommentSubject(ctx, data) {
80
+ if (data.prompt && data.version) {
81
+ const versionLabel = data.version.versionLabel || (data.version.versionNumber ? `v${data.version.versionNumber}` : data.version.id);
82
+ ctx.printLine(`Prompt: ${ctx.sanitizeTerminalText(data.prompt.name)} (${ctx.sanitizeTerminalText(data.prompt.id)})`);
83
+ ctx.printLine(`Version: ${ctx.sanitizeTerminalText(String(versionLabel))} (${ctx.sanitizeTerminalText(data.version.id)})`);
84
+ return;
85
+ }
86
+ if (data.run) {
87
+ ctx.printLine(`Optimization run: ${ctx.sanitizeTerminalText(data.run.id)}`);
88
+ return;
89
+ }
90
+ if (data.task) {
91
+ const title = data.task.title ? `${ctx.sanitizeTerminalText(data.task.title)} ` : '';
92
+ ctx.printLine(`Task: ${title}(${ctx.sanitizeTerminalText(data.task.id)})`);
93
+ return;
94
+ }
95
+ if (data.subject) {
96
+ ctx.printLine(`Subject: ${ctx.sanitizeTerminalText(data.subject.type)} ${ctx.sanitizeTerminalText(data.subject.id)}`);
97
+ }
98
+ }
99
+ function printReportComments(ctx, data) {
100
+ printReportCommentSubject(ctx, data);
101
+ ctx.printLine(`Comments: ${data.summary.threadCount} thread${data.summary.threadCount === 1 ? '' : 's'} ` +
102
+ `(${data.summary.openThreadCount} open, ${data.summary.resolvedThreadCount} resolved, ${data.summary.replyCount} replies)`);
103
+ if (data.comments.length === 0) {
104
+ ctx.printLine('\nNo comments found for this report.');
105
+ return;
106
+ }
107
+ for (const [index, thread] of data.comments.entries()) {
108
+ const authorName = reportCommentAuthorName(thread.author);
109
+ const anchor = formatReportCommentAnchor(ctx, thread.anchor);
110
+ ctx.printLine('');
111
+ ctx.printLine(`${index + 1}. [${thread.status}] ${ctx.sanitizeTerminalText(thread.id)} · ` +
112
+ `${ctx.sanitizeTerminalText(authorName)} · ${ctx.sanitizeTerminalText(thread.createdAt)}`);
113
+ if (thread.editedAt) {
114
+ ctx.printLine(` Edited: ${ctx.sanitizeTerminalText(thread.editedAt)}`);
115
+ }
116
+ if (anchor) {
117
+ ctx.printLine(` Selection: ${anchor}`);
118
+ }
119
+ printIndentedBody(ctx, thread.body, ' ');
120
+ if (thread.replies.length > 0) {
121
+ ctx.printLine(` Replies (${thread.replies.length})`);
122
+ for (const reply of thread.replies) {
123
+ const replyAuthor = reportCommentAuthorName(reply.author);
124
+ ctx.printLine(` - ${ctx.sanitizeTerminalText(reply.id)} · ` +
125
+ `${ctx.sanitizeTerminalText(replyAuthor)} · ${ctx.sanitizeTerminalText(reply.createdAt)}`);
126
+ if (reply.editedAt) {
127
+ ctx.printLine(` Edited: ${ctx.sanitizeTerminalText(reply.editedAt)}`);
128
+ }
129
+ printIndentedBody(ctx, reply.body, ' ');
130
+ }
131
+ }
132
+ }
133
+ }
134
+ async function reportCommentsTargetParams(ctx, action) {
135
+ const promptRef = ctx.getArg('--prompt');
136
+ const runId = ctx.getArg('--run') || ctx.getArg('--run-id');
137
+ const taskId = ctx.getArg('--task');
138
+ ctx.rejectDashPrefixedOptionValue('--prompt', promptRef);
139
+ ctx.rejectDashPrefixedOptionValue('--run', runId);
140
+ ctx.rejectDashPrefixedOptionValue('--task', taskId);
141
+ const present = [
142
+ promptRef ? 'prompt' : null,
143
+ runId ? 'run' : null,
144
+ taskId ? 'task' : null,
145
+ ].filter(Boolean);
146
+ if (present.length !== 1) {
147
+ throw new Error(`${reportCommentsUsage(action)}\n${COMMENTS_TARGET_USAGE}`);
148
+ }
149
+ const params = new URLSearchParams();
150
+ if (promptRef) {
151
+ const project = ctx.getArg('--project') || await ctx.resolveProjectSlug(null);
152
+ const label = ctx.getArg('--label');
153
+ const version = ctx.getArg('--version');
154
+ ctx.rejectDashPrefixedOptionValue('--project', project);
155
+ ctx.rejectDashPrefixedOptionValue('--label', label);
156
+ ctx.rejectDashPrefixedOptionValue('--version', version);
157
+ if (label && version) {
158
+ throw new Error('Use either --label or --version, not both');
159
+ }
160
+ params.set('prompt', promptRef);
161
+ params.set('project', project);
162
+ if (label)
163
+ params.set('label', label);
164
+ if (version)
165
+ params.set('version', version);
166
+ return params;
167
+ }
168
+ if (runId) {
169
+ params.set('run', runId);
170
+ return params;
171
+ }
172
+ params.set('task', taskId);
173
+ return params;
174
+ }
175
+ function readReportCommentBodyInput(ctx, action) {
176
+ const body = ctx.getArg('--body');
177
+ ctx.rejectDashPrefixedOptionValue('--body', body);
178
+ if (!body) {
179
+ throw new Error(reportCommentsUsage(action));
180
+ }
181
+ if (body === '@') {
182
+ throw new Error('Usage: --body <text|@file>');
183
+ }
184
+ const value = body.startsWith('@') ? ctx.readSourceFile(body.slice(1)) : body;
185
+ if (!value.trim()) {
186
+ throw new Error('Comment body must not be blank');
187
+ }
188
+ return value;
189
+ }
190
+ function readReportCommentAnchorInput(ctx) {
191
+ const anchor = ctx.getArg('--anchor');
192
+ const lines = ctx.getArg('--lines');
193
+ ctx.rejectDashPrefixedOptionValue('--anchor', anchor);
194
+ ctx.rejectDashPrefixedOptionValue('--lines', lines);
195
+ let startLine = null;
196
+ let endLine = null;
197
+ if (lines) {
198
+ const match = lines.match(/^(\d+):(\d+)$/);
199
+ if (!match) {
200
+ throw new Error('--lines must use format <start:end>');
201
+ }
202
+ startLine = Number(match[1]);
203
+ endLine = Number(match[2]);
204
+ if (!Number.isInteger(startLine) || !Number.isInteger(endLine) || startLine < 1 || endLine < startLine) {
205
+ throw new Error('--lines must contain positive integers where end is greater than or equal to start');
206
+ }
207
+ }
208
+ if (!anchor && startLine === null) {
209
+ return null;
210
+ }
211
+ return {
212
+ text: anchor ? anchor.trim() : null,
213
+ startLine,
214
+ endLine,
215
+ };
216
+ }
217
+ function readReportCommentViaInput(ctx) {
218
+ const via = ctx.getArg('--via');
219
+ ctx.rejectDashPrefixedOptionValue('--via', via);
220
+ return via?.trim() || undefined;
221
+ }
222
+ async function listReportComments(ctx) {
223
+ const params = await reportCommentsTargetParams(ctx, 'list');
224
+ const response = await ctx.authedFetch(`/api/cli/report-comments?${params.toString()}`);
225
+ if (!response.ok) {
226
+ throw new Error(`Failed to fetch report comments: ${await readReportCommentError(ctx, response)}`);
227
+ }
228
+ const data = await ctx.parseJsonResponse(response, 'Report comments');
229
+ if (ctx.hasJsonFlag()) {
230
+ ctx.printJson(data);
231
+ return;
232
+ }
233
+ printReportComments(ctx, data);
234
+ }
235
+ async function addReportComment(ctx) {
236
+ const params = await reportCommentsTargetParams(ctx, 'add');
237
+ const body = readReportCommentBodyInput(ctx, 'add');
238
+ const response = await ctx.authedFetch(`/api/cli/report-comments?${params.toString()}`, {
239
+ method: 'POST',
240
+ headers: { 'Content-Type': 'application/json' },
241
+ body: JSON.stringify({
242
+ body,
243
+ via: readReportCommentViaInput(ctx),
244
+ anchor: readReportCommentAnchorInput(ctx),
245
+ }),
246
+ });
247
+ if (!response.ok) {
248
+ throw new Error(`Failed to add report comment: ${await readReportCommentError(ctx, response)}`);
249
+ }
250
+ const data = await ctx.parseJsonResponse(response, 'Report comment add');
251
+ if (ctx.hasJsonFlag()) {
252
+ ctx.printJson(data);
253
+ return;
254
+ }
255
+ ctx.printLine(`Added comment ${ctx.sanitizeTerminalText(data.comment.id)}`);
256
+ }
257
+ async function replyReportComment(ctx) {
258
+ const commentId = ctx.getPositionalArg(2);
259
+ if (!commentId) {
260
+ throw new Error(reportCommentsUsage('reply'));
261
+ }
262
+ const response = await ctx.authedFetch(`/api/cli/report-comments/${encodeURIComponent(commentId)}`, {
263
+ method: 'POST',
264
+ headers: { 'Content-Type': 'application/json' },
265
+ body: JSON.stringify({
266
+ body: readReportCommentBodyInput(ctx, 'reply'),
267
+ via: readReportCommentViaInput(ctx),
268
+ }),
269
+ });
270
+ if (!response.ok) {
271
+ throw new Error(`Failed to reply to report comment: ${await readReportCommentError(ctx, response)}`);
272
+ }
273
+ const data = await ctx.parseJsonResponse(response, 'Report comment reply');
274
+ if (ctx.hasJsonFlag()) {
275
+ ctx.printJson(data);
276
+ return;
277
+ }
278
+ ctx.printLine(`Added reply ${ctx.sanitizeTerminalText(data.comment.id)} to ${ctx.sanitizeTerminalText(commentId)}`);
279
+ }
280
+ async function updateReportComment(ctx, action) {
281
+ const commentId = ctx.getPositionalArg(2);
282
+ if (!commentId) {
283
+ throw new Error(reportCommentsUsage(action));
284
+ }
285
+ const payload = action === 'edit'
286
+ ? { body: readReportCommentBodyInput(ctx, 'edit') }
287
+ : { resolved: action === 'resolve' };
288
+ const response = await ctx.authedFetch(`/api/cli/report-comments/${encodeURIComponent(commentId)}`, {
289
+ method: 'PATCH',
290
+ headers: { 'Content-Type': 'application/json' },
291
+ body: JSON.stringify(payload),
292
+ });
293
+ if (!response.ok) {
294
+ throw new Error(`Failed to ${action} report comment: ${await readReportCommentError(ctx, response)}`);
295
+ }
296
+ const data = await ctx.parseJsonResponse(response, `Report comment ${action}`);
297
+ if (ctx.hasJsonFlag()) {
298
+ ctx.printJson(data);
299
+ return;
300
+ }
301
+ const verb = action === 'unresolve' ? 'Reopened' : action === 'edit' ? 'Edited' : 'Resolved';
302
+ ctx.printLine(`${verb} comment ${ctx.sanitizeTerminalText(data.comment.id)}`);
303
+ }
304
+ export async function reportCommentsCommand(action, ctx) {
305
+ if (action === 'list') {
306
+ await listReportComments(ctx);
307
+ return;
308
+ }
309
+ if (action === 'add') {
310
+ await addReportComment(ctx);
311
+ return;
312
+ }
313
+ if (action === 'reply') {
314
+ await replyReportComment(ctx);
315
+ return;
316
+ }
317
+ if (action === 'resolve' || action === 'unresolve' || action === 'edit') {
318
+ await updateReportComment(ctx, action);
319
+ return;
320
+ }
321
+ throw new Error('Usage: orizu comments <list|add|reply|resolve|unresolve|edit> ...');
322
+ }
323
+ export function throwDeprecatedPromptCommentsCommand() {
324
+ throw new Error('orizu prompts comments has moved. Use `orizu comments list --prompt <prompt-id-or-name> --project <team/project> [--label <label> | --version <id>]`.');
325
+ }
@@ -14,14 +14,17 @@ export const SKILL_INSTALL_TARGETS = [
14
14
  'codex-project',
15
15
  'claude-user',
16
16
  'claude-project',
17
+ 'opencode-user',
18
+ 'opencode-project',
17
19
  'agents-md',
18
20
  ];
19
21
  export const SKILL_META_FILENAME = '.orizu-skill-meta.json';
20
- export const SKILL_INSTALL_AGENTS = ['claude', 'codex'];
22
+ export const SKILL_INSTALL_AGENTS = ['claude', 'codex', 'opencode'];
21
23
  const PROJECT_LEVEL_TARGETS = [
22
24
  'agents-project',
23
25
  'codex-project',
24
26
  'claude-project',
27
+ 'opencode-project',
25
28
  ];
26
29
  function getCliRoot() {
27
30
  return resolve(dirname(fileURLToPath(import.meta.url)), '..');
@@ -134,12 +137,24 @@ export function getSkillInstallPath(target, options) {
134
137
  if (target === 'claude-project') {
135
138
  return resolve(cwd, '.claude', 'skills', SKILL_NAME);
136
139
  }
140
+ // OpenCode discovers user skills under XDG config (~/.config/opencode/skills)
141
+ // and project skills under .opencode/skills (ALI-1044). The copy/link machinery
142
+ // is agent-neutral; only the destination path differs.
143
+ if (target === 'opencode-user') {
144
+ return resolve(home, '.config', 'opencode', 'skills', SKILL_NAME);
145
+ }
146
+ if (target === 'opencode-project') {
147
+ return resolve(cwd, '.opencode', 'skills', SKILL_NAME);
148
+ }
137
149
  return resolve(cwd, 'AGENTS.md');
138
150
  }
139
151
  export function getTargetForAgent(agent, scope) {
140
152
  if (agent === 'claude') {
141
153
  return scope === 'global' ? 'claude-user' : 'claude-project';
142
154
  }
155
+ if (agent === 'opencode') {
156
+ return scope === 'global' ? 'opencode-user' : 'opencode-project';
157
+ }
143
158
  return scope === 'global' ? 'codex-user' : 'agents-project';
144
159
  }
145
160
  export function isSkillInstallAgent(value) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orizu",
3
- "version": "0.5.6",
3
+ "version": "0.5.8",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -210,6 +210,52 @@ export function getActiveBaseUrl(): string | null {
210
210
  return config?.activeBaseUrl || null
211
211
  }
212
212
 
213
+ /**
214
+ * In-sandbox bearer resolution (ALI-1044). Resolves a bearer supplied out-of-band
215
+ * to the hosted agent, WITHOUT consulting credentials.json:
216
+ * 1. `ORIZU_TOKEN` — an explicit bearer in the environment (wins if present).
217
+ * 2. `ORIZU_TOKEN_FILE` — an absolute path to a 0600 file whose TRIMMED contents
218
+ * are the bearer.
219
+ * Returns null when neither is set, so callers fall through to credentials.json.
220
+ *
221
+ * The token file is read FRESH on EVERY call and never cached: the hosted loop
222
+ * ROTATES the file underneath a long-lived agent process, so caching the value
223
+ * would pin a stale (soon-expired) bearer. The token is never logged, echoed, or
224
+ * passed via argv — only returned to the caller for a single request's header.
225
+ *
226
+ * A set-but-unreadable/empty `ORIZU_TOKEN_FILE` throws a clear error rather than
227
+ * returning null, so a misconfigured sandbox fails loudly instead of silently
228
+ * falling back to (absent) credentials.json and emitting a confusing "not logged
229
+ * in" message.
230
+ */
231
+ export function resolveEnvBearerToken(): string | null {
232
+ const explicit = process.env.ORIZU_TOKEN
233
+ if (explicit && explicit.trim()) {
234
+ return explicit.trim()
235
+ }
236
+
237
+ const tokenFile = process.env.ORIZU_TOKEN_FILE
238
+ if (tokenFile && tokenFile.length > 0) {
239
+ if (!existsSync(tokenFile)) {
240
+ throw new Error(`ORIZU_TOKEN_FILE is set to ${tokenFile} but no such file exists.`)
241
+ }
242
+ let raw: string
243
+ try {
244
+ raw = readFileSync(tokenFile, 'utf8')
245
+ } catch (error) {
246
+ const detail = isNodeError(error) ? error.message : String(error)
247
+ throw new Error(`Failed to read ORIZU_TOKEN_FILE (${tokenFile}): ${detail}`)
248
+ }
249
+ const token = raw.trim()
250
+ if (!token) {
251
+ throw new Error(`ORIZU_TOKEN_FILE (${tokenFile}) is empty.`)
252
+ }
253
+ return token
254
+ }
255
+
256
+ return null
257
+ }
258
+
213
259
  export function setActiveBaseUrl(baseUrl: string | null) {
214
260
  const config = loadCredentialsConfigForWrite()
215
261
  config.activeBaseUrl = baseUrl