orizu 0.3.7 → 0.4.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.
@@ -0,0 +1,27 @@
1
+ export interface AgentSetupPromptOptions {
2
+ workspacePath?: string | null
3
+ }
4
+
5
+ export function renderAgentSetupPrompt(options?: AgentSetupPromptOptions): string {
6
+ const workspaceLine = options?.workspacePath
7
+ ? `- Local Orizu artifacts belong under ${options.workspacePath} (gitignored). Keep secrets out of the repo; credentials live in ~/.config/orizu.`
8
+ : '- If local Orizu artifacts are needed, suggest creating a gitignored .orizu/ workspace (orizu setup can do this).'
9
+
10
+ return `Help me embed Orizu (eval-first LLM optimization) into this repository's workflow.
11
+
12
+ Start by learning the Orizu workflow:
13
+ 1. Run \`orizu skills path --json\` and read the SKILL.md it points to, plus the references it links.
14
+ 2. Run \`orizu --version\` and \`orizu whoami\` to confirm the CLI is installed and authenticated. If either fails, pause and tell me how to fix it (npm i -g orizu, orizu login) before continuing.
15
+
16
+ Then inspect this repository and propose how Orizu should fit it:
17
+ - Identify the LLM application surfaces worth evaluating (prompts, agents, pipelines) and where their inputs/outputs live.
18
+ - Propose Orizu team(s) and project(s) that match how this repo is organized.
19
+ - Propose datasets: existing data that can be uploaded now, or a concrete plan to collect examples if none exists.
20
+ - Propose which prompts to version in Orizu, and which scorers or LLM judges would capture quality for them.
21
+ - Propose runner artifacts where local execution is needed.
22
+ ${workspaceLine}
23
+
24
+ Important boundaries:
25
+ - Present your plan and get my approval before creating anything on the Orizu server or changing repo files.
26
+ - Never duplicate Orizu runtime behavior (scoring, optimization, auth) in repo code; always go through the orizu CLI.`
27
+ }
@@ -1,14 +1,20 @@
1
1
  import {
2
2
  cpSync,
3
3
  existsSync,
4
+ lstatSync,
4
5
  mkdirSync,
6
+ readdirSync,
5
7
  readFileSync,
8
+ readlinkSync,
9
+ realpathSync,
6
10
  rmSync,
11
+ symlinkSync,
7
12
  writeFileSync,
8
13
  } from 'fs'
9
- import { dirname, isAbsolute, relative, resolve } from 'path'
14
+ import { dirname, isAbsolute, join, relative, resolve } from 'path'
10
15
  import { fileURLToPath } from 'url'
11
16
  import { homedir } from 'os'
17
+ import { createHash } from 'crypto'
12
18
 
13
19
  export const SKILL_NAME = 'orizu-cli'
14
20
  export const AGENTS_START_MARKER = '<!-- orizu-cli:start -->'
@@ -16,6 +22,7 @@ export const AGENTS_END_MARKER = '<!-- orizu-cli:end -->'
16
22
 
17
23
  export const SKILL_INSTALL_TARGETS = [
18
24
  'agent-user',
25
+ 'agents-project',
19
26
  'codex-project',
20
27
  'claude-user',
21
28
  'claude-project',
@@ -24,17 +31,46 @@ export const SKILL_INSTALL_TARGETS = [
24
31
 
25
32
  export type SkillInstallTarget = typeof SKILL_INSTALL_TARGETS[number]
26
33
 
34
+ export const SKILL_META_FILENAME = '.orizu-skill-meta.json'
35
+
36
+ export const SKILL_INSTALL_AGENTS = ['claude', 'codex'] as const
37
+
38
+ export type SkillInstallAgent = typeof SKILL_INSTALL_AGENTS[number]
39
+
40
+ export type SkillInstallScope = 'global' | 'local'
41
+
42
+ export type SkillInstallMode = 'auto' | 'link' | 'copy'
43
+
44
+ const PROJECT_LEVEL_TARGETS: readonly SkillInstallTarget[] = [
45
+ 'agents-project',
46
+ 'codex-project',
47
+ 'claude-project',
48
+ ]
49
+
27
50
  export interface SkillInstallOptions {
28
51
  cwd?: string
29
52
  homeDir?: string
30
53
  overwrite?: boolean
31
54
  dryRun?: boolean
55
+ mode?: SkillInstallMode
56
+ cliVersion?: string
32
57
  }
33
58
 
34
59
  export interface SkillInstallResult {
35
60
  target: SkillInstallTarget
36
61
  path: string
37
62
  action: 'created' | 'updated' | 'would-create' | 'would-update'
63
+ mode: 'link' | 'copy' | 'section'
64
+ }
65
+
66
+ export interface SkillInstallMeta {
67
+ name: string
68
+ skillHash: string
69
+ cliVersion: string | null
70
+ source: SkillSourceType
71
+ sourceRoot: string
72
+ mode: 'copy'
73
+ installedAt: string
38
74
  }
39
75
 
40
76
  function getCliRoot(): string {
@@ -54,25 +90,49 @@ export function formatMissingSkillSourceError(paths: string[]): string {
54
90
  + `Searched:\n- ${paths.join('\n- ')}`
55
91
  }
56
92
 
57
- export function resolveSkillSourceDir(): string {
93
+ export type SkillSourceType = 'override' | 'packaged' | 'repo-fallback'
94
+
95
+ export interface SkillSourceInfo {
96
+ name: string
97
+ root: string
98
+ skillMd: string
99
+ source: SkillSourceType
100
+ }
101
+
102
+ export function resolveSkillSource(): SkillSourceInfo {
58
103
  const override = process.env.ORIZU_SKILL_SOURCE_DIR
59
104
  if (override) {
60
105
  const resolvedOverride = resolve(override)
61
106
  if (!isSkillDir(resolvedOverride)) {
62
107
  throw new Error(`ORIZU_SKILL_SOURCE_DIR is not an Orizu skill directory: ${resolvedOverride}`)
63
108
  }
64
- return resolvedOverride
109
+ return {
110
+ name: SKILL_NAME,
111
+ root: resolvedOverride,
112
+ skillMd: resolve(resolvedOverride, 'SKILL.md'),
113
+ source: 'override',
114
+ }
65
115
  }
66
116
 
67
117
  const cliRoot = getCliRoot()
68
118
  const packagedSkill = resolve(cliRoot, 'vendor', 'skills', SKILL_NAME)
69
119
  if (isSkillDir(packagedSkill)) {
70
- return packagedSkill
120
+ return {
121
+ name: SKILL_NAME,
122
+ root: packagedSkill,
123
+ skillMd: resolve(packagedSkill, 'SKILL.md'),
124
+ source: 'packaged',
125
+ }
71
126
  }
72
127
 
73
128
  const repoSkill = resolve(cliRoot, '..', '..', 'skills', SKILL_NAME)
74
129
  if (isSkillDir(repoSkill)) {
75
- return repoSkill
130
+ return {
131
+ name: SKILL_NAME,
132
+ root: repoSkill,
133
+ skillMd: resolve(repoSkill, 'SKILL.md'),
134
+ source: 'repo-fallback',
135
+ }
76
136
  }
77
137
 
78
138
  const searchedPaths = [
@@ -83,6 +143,40 @@ export function resolveSkillSourceDir(): string {
83
143
  throw new Error(formatMissingSkillSourceError(searchedPaths))
84
144
  }
85
145
 
146
+ export function resolveSkillSourceDir(): string {
147
+ return resolveSkillSource().root
148
+ }
149
+
150
+ function listSkillFiles(root: string, dir: string, files: string[]): void {
151
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
152
+ const entryPath = join(dir, entry.name)
153
+ if (!shouldCopySkillPath(root, entryPath)) {
154
+ continue
155
+ }
156
+ if (entry.isDirectory()) {
157
+ listSkillFiles(root, entryPath, files)
158
+ } else if (entry.isFile()) {
159
+ files.push(entryPath)
160
+ }
161
+ }
162
+ }
163
+
164
+ export function computeSkillContentHash(root: string): string {
165
+ const files: string[] = []
166
+ listSkillFiles(root, root, files)
167
+ const hash = createHash('sha256')
168
+ const sorted = files
169
+ .map(file => relative(root, file).replace(/\\/g, '/'))
170
+ .sort()
171
+ for (const rel of sorted) {
172
+ hash.update(rel)
173
+ hash.update('\0')
174
+ hash.update(readFileSync(join(root, rel)))
175
+ hash.update('\0')
176
+ }
177
+ return `sha256:${hash.digest('hex')}`
178
+ }
179
+
86
180
  function resolveHome(options?: SkillInstallOptions): string {
87
181
  return resolve(options?.homeDir || process.env.HOME || homedir())
88
182
  }
@@ -102,6 +196,10 @@ export function getSkillInstallPath(
102
196
  return resolve(home, '.agents', 'skills', SKILL_NAME)
103
197
  }
104
198
 
199
+ if (target === 'agents-project') {
200
+ return resolve(cwd, '.agents', 'skills', SKILL_NAME)
201
+ }
202
+
105
203
  if (target === 'codex-project') {
106
204
  return resolve(cwd, '.codex', 'skills', SKILL_NAME)
107
205
  }
@@ -117,6 +215,69 @@ export function getSkillInstallPath(
117
215
  return resolve(cwd, 'AGENTS.md')
118
216
  }
119
217
 
218
+ export function getTargetForAgent(
219
+ agent: SkillInstallAgent,
220
+ scope: SkillInstallScope
221
+ ): SkillInstallTarget {
222
+ if (agent === 'claude') {
223
+ return scope === 'global' ? 'claude-user' : 'claude-project'
224
+ }
225
+ return scope === 'global' ? 'agent-user' : 'agents-project'
226
+ }
227
+
228
+ export function isSkillInstallAgent(value: string): value is SkillInstallAgent {
229
+ return (SKILL_INSTALL_AGENTS as readonly string[]).includes(value)
230
+ }
231
+
232
+ export function isProjectLevelTarget(target: SkillInstallTarget): boolean {
233
+ return PROJECT_LEVEL_TARGETS.includes(target)
234
+ }
235
+
236
+ const UNSTABLE_SOURCE_PATTERNS = [
237
+ /\/_npx\//,
238
+ /\/\.npm\//,
239
+ /\/npm-cache\//,
240
+ /\/\.bun\/install\/cache\//,
241
+ /\/bunx-/,
242
+ /\/dlx-/,
243
+ /\/\.pnpm-store\//,
244
+ /^\/tmp\//,
245
+ /^\/(private\/)?var\/folders\//,
246
+ /\/Temp\//,
247
+ ]
248
+
249
+ export function isStableSkillSourcePath(root: string): boolean {
250
+ const normalized = root.replace(/\\/g, '/')
251
+ return !UNSTABLE_SOURCE_PATTERNS.some(pattern => pattern.test(normalized))
252
+ }
253
+
254
+ export function resolveEffectiveInstallMode(
255
+ target: SkillInstallTarget,
256
+ sourceRoot: string,
257
+ mode: SkillInstallMode = 'auto'
258
+ ): 'link' | 'copy' | 'section' {
259
+ if (target === 'agents-md') {
260
+ return 'section'
261
+ }
262
+
263
+ if (isProjectLevelTarget(target)) {
264
+ if (mode === 'link') {
265
+ throw new Error(
266
+ `--mode link is not supported for project-level target '${target}': committed project files must not symlink to user-local package paths. Use --mode copy.`
267
+ )
268
+ }
269
+ return 'copy'
270
+ }
271
+
272
+ if (mode === 'link') {
273
+ return 'link'
274
+ }
275
+ if (mode === 'copy') {
276
+ return 'copy'
277
+ }
278
+ return isStableSkillSourcePath(sourceRoot) ? 'link' : 'copy'
279
+ }
280
+
120
281
  function shouldCopySkillPath(sourceRoot: string, sourcePath: string): boolean {
121
282
  const rel = relative(sourceRoot, sourcePath).replace(/\\/g, '/')
122
283
  if (!rel) {
@@ -130,7 +291,8 @@ function shouldCopySkillPath(sourceRoot: string, sourcePath: string): boolean {
130
291
  return !parts.some(part =>
131
292
  part === '.DS_Store' ||
132
293
  part === '__pycache__' ||
133
- part === '.pytest_cache'
294
+ part === '.pytest_cache' ||
295
+ part === SKILL_META_FILENAME
134
296
  ) && !rel.endsWith('.pyc') && !rel.endsWith('.pyo')
135
297
  }
136
298
 
@@ -175,6 +337,7 @@ function installAgentsMd(
175
337
  target: 'agents-md',
176
338
  path: targetPath,
177
339
  action: action === 'updated' ? 'would-update' : 'would-create',
340
+ mode: 'section',
178
341
  }
179
342
  }
180
343
 
@@ -185,7 +348,7 @@ function installAgentsMd(
185
348
  mkdirSync(dirname(targetPath), { recursive: true })
186
349
  if (!exists) {
187
350
  writeFileSync(targetPath, section, 'utf8')
188
- return { target: 'agents-md', path: targetPath, action: 'created' }
351
+ return { target: 'agents-md', path: targetPath, action: 'created', mode: 'section' }
189
352
  }
190
353
 
191
354
  if (hasManagedSection) {
@@ -193,53 +356,101 @@ function installAgentsMd(
193
356
  `${escapeRegExp(AGENTS_START_MARKER)}[\\s\\S]*?${escapeRegExp(AGENTS_END_MARKER)}\\n?`
194
357
  )
195
358
  writeFileSync(targetPath, existing.replace(pattern, section), 'utf8')
196
- return { target: 'agents-md', path: targetPath, action: 'updated' }
359
+ return { target: 'agents-md', path: targetPath, action: 'updated', mode: 'section' }
197
360
  }
198
361
 
199
362
  const existingPrefix = existing.trim().length === 0 ? '' : existing
200
363
  const separator = existingPrefix.endsWith('\n') ? '\n' : '\n\n'
201
364
  writeFileSync(targetPath, `${existingPrefix}${existingPrefix ? separator : ''}${section}`, 'utf8')
202
- return { target: 'agents-md', path: targetPath, action: 'updated' }
365
+ return { target: 'agents-md', path: targetPath, action: 'updated', mode: 'section' }
203
366
  }
204
367
 
205
368
  function escapeRegExp(value: string): string {
206
369
  return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
207
370
  }
208
371
 
372
+ function pathEntryExists(path: string): boolean {
373
+ try {
374
+ lstatSync(path)
375
+ return true
376
+ } catch {
377
+ return false
378
+ }
379
+ }
380
+
209
381
  export function targetNeedsOverwrite(
210
382
  target: SkillInstallTarget,
211
383
  options?: SkillInstallOptions
212
384
  ): boolean {
213
385
  const targetPath = getSkillInstallPath(target, options)
214
- if (!existsSync(targetPath)) {
215
- return false
386
+ if (target !== 'agents-md') {
387
+ return pathEntryExists(targetPath)
216
388
  }
217
389
 
218
- if (target !== 'agents-md') {
219
- return true
390
+ if (!existsSync(targetPath)) {
391
+ return false
220
392
  }
221
393
 
222
394
  const existing = readFileSync(targetPath, 'utf8')
223
395
  return existing.includes(AGENTS_START_MARKER) && existing.includes(AGENTS_END_MARKER)
224
396
  }
225
397
 
398
+ function writeSkillMeta(
399
+ targetDir: string,
400
+ source: SkillSourceInfo,
401
+ options?: SkillInstallOptions
402
+ ): void {
403
+ const meta: SkillInstallMeta = {
404
+ name: source.name,
405
+ skillHash: computeSkillContentHash(source.root),
406
+ cliVersion: options?.cliVersion || null,
407
+ source: source.source,
408
+ sourceRoot: source.root,
409
+ mode: 'copy',
410
+ installedAt: new Date().toISOString(),
411
+ }
412
+ writeFileSync(join(targetDir, SKILL_META_FILENAME), `${JSON.stringify(meta, null, 2)}\n`, 'utf8')
413
+ }
414
+
415
+ export function readSkillMeta(targetDir: string): SkillInstallMeta | null {
416
+ const metaPath = join(targetDir, SKILL_META_FILENAME)
417
+ if (!existsSync(metaPath)) {
418
+ return null
419
+ }
420
+ try {
421
+ const parsed = JSON.parse(readFileSync(metaPath, 'utf8')) as SkillInstallMeta
422
+ return parsed && typeof parsed === 'object' ? parsed : null
423
+ } catch {
424
+ return null
425
+ }
426
+ }
427
+
428
+ function linkSkillTree(sourceDir: string, targetDir: string) {
429
+ rmSync(targetDir, { recursive: true, force: true })
430
+ mkdirSync(dirname(targetDir), { recursive: true })
431
+ symlinkSync(sourceDir, targetDir, 'dir')
432
+ }
433
+
226
434
  export function installSkillTarget(
227
435
  target: SkillInstallTarget,
228
436
  options?: SkillInstallOptions
229
437
  ): SkillInstallResult {
230
- const sourceDir = resolveSkillSourceDir()
438
+ const source = resolveSkillSource()
231
439
  const targetPath = getSkillInstallPath(target, options)
232
- const exists = existsSync(targetPath)
233
440
 
234
441
  if (target === 'agents-md') {
235
- return installAgentsMd(sourceDir, targetPath, options)
442
+ return installAgentsMd(source.root, targetPath, options)
236
443
  }
237
444
 
445
+ const exists = pathEntryExists(targetPath)
446
+ const mode = resolveEffectiveInstallMode(target, source.root, options?.mode)
447
+
238
448
  if (options?.dryRun) {
239
449
  return {
240
450
  target,
241
451
  path: targetPath,
242
452
  action: exists ? 'would-update' : 'would-create',
453
+ mode,
243
454
  }
244
455
  }
245
456
 
@@ -247,11 +458,126 @@ export function installSkillTarget(
247
458
  throw new Error(`${targetPath} already exists. Pass --yes to replace it.`)
248
459
  }
249
460
 
250
- copySkillTree(sourceDir, targetPath)
461
+ if (mode === 'link') {
462
+ linkSkillTree(source.root, targetPath)
463
+ } else {
464
+ copySkillTree(source.root, targetPath)
465
+ writeSkillMeta(targetPath, source, options)
466
+ }
467
+
251
468
  return {
252
469
  target,
253
470
  path: targetPath,
254
471
  action: exists ? 'updated' : 'created',
472
+ mode,
473
+ }
474
+ }
475
+
476
+ export type SkillTargetState =
477
+ | 'current'
478
+ | 'stale'
479
+ | 'missing'
480
+ | 'broken-link'
481
+ | 'unmanaged'
482
+
483
+ export interface SkillTargetStatus {
484
+ target: SkillInstallTarget
485
+ path: string
486
+ state: SkillTargetState
487
+ mode: 'link' | 'copy' | 'section' | null
488
+ linkTarget: string | null
489
+ installedHash: string | null
490
+ sourceHash: string
491
+ meta: SkillInstallMeta | null
492
+ }
493
+
494
+ function getAgentsMdStatus(
495
+ targetPath: string,
496
+ source: SkillSourceInfo,
497
+ sourceHash: string
498
+ ): SkillTargetStatus {
499
+ const base = {
500
+ target: 'agents-md' as const,
501
+ path: targetPath,
502
+ mode: 'section' as const,
503
+ linkTarget: null,
504
+ installedHash: null,
505
+ sourceHash,
506
+ meta: null,
507
+ }
508
+
509
+ if (!existsSync(targetPath)) {
510
+ return { ...base, state: 'missing' }
511
+ }
512
+
513
+ const existing = readFileSync(targetPath, 'utf8')
514
+ const start = existing.indexOf(AGENTS_START_MARKER)
515
+ const end = existing.indexOf(AGENTS_END_MARKER)
516
+ if (start === -1 || end === -1) {
517
+ return { ...base, state: 'unmanaged' }
518
+ }
519
+
520
+ const actualSection = existing.slice(start, end + AGENTS_END_MARKER.length)
521
+ const expectedSection = buildAgentsSection(source.root).trimEnd()
522
+ return {
523
+ ...base,
524
+ state: actualSection.trimEnd() === expectedSection ? 'current' : 'stale',
525
+ }
526
+ }
527
+
528
+ export function getSkillTargetStatus(
529
+ target: SkillInstallTarget,
530
+ options?: SkillInstallOptions
531
+ ): SkillTargetStatus {
532
+ const source = resolveSkillSource()
533
+ const sourceHash = computeSkillContentHash(source.root)
534
+ const targetPath = getSkillInstallPath(target, options)
535
+
536
+ if (target === 'agents-md') {
537
+ return getAgentsMdStatus(targetPath, source, sourceHash)
538
+ }
539
+
540
+ const base = {
541
+ target,
542
+ path: targetPath,
543
+ linkTarget: null as string | null,
544
+ installedHash: null as string | null,
545
+ sourceHash,
546
+ meta: null as SkillInstallMeta | null,
547
+ }
548
+
549
+ if (!pathEntryExists(targetPath)) {
550
+ return { ...base, state: 'missing', mode: null }
551
+ }
552
+
553
+ const stats = lstatSync(targetPath)
554
+ if (stats.isSymbolicLink()) {
555
+ const linkTarget = readlinkSync(targetPath)
556
+ if (!existsSync(targetPath)) {
557
+ return { ...base, state: 'broken-link', mode: 'link', linkTarget }
558
+ }
559
+
560
+ const resolvedLink = realpathSync(targetPath)
561
+ const installedHash = computeSkillContentHash(resolvedLink)
562
+ const state = resolvedLink === realpathSync(source.root) || installedHash === sourceHash
563
+ ? 'current'
564
+ : 'stale'
565
+ return { ...base, state, mode: 'link', linkTarget, installedHash }
566
+ }
567
+
568
+ const installedHash = computeSkillContentHash(targetPath)
569
+ const meta = readSkillMeta(targetPath)
570
+ // A differing directory without our meta file was not installed by Orizu
571
+ // (or was hand-customized): treat it as unmanaged so `skills update` never
572
+ // silently overwrites it. Matching content is current regardless of meta.
573
+ const state =
574
+ installedHash === sourceHash ? 'current' : meta ? 'stale' : 'unmanaged'
575
+ return {
576
+ ...base,
577
+ state,
578
+ mode: 'copy',
579
+ installedHash,
580
+ meta,
255
581
  }
256
582
  }
257
583
 
@@ -0,0 +1,112 @@
1
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'
2
+ import { join, resolve } from 'path'
3
+
4
+ export const WORKSPACE_DIR_NAME = '.orizu'
5
+
6
+ export interface WorkspaceInitOptions {
7
+ cwd?: string
8
+ baseUrl?: string | null
9
+ cliVersion?: string | null
10
+ dryRun?: boolean
11
+ }
12
+
13
+ export interface WorkspaceInitResult {
14
+ root: string
15
+ state: 'created' | 'exists' | 'would-create'
16
+ gitignoreUpdated: boolean
17
+ actions: string[]
18
+ }
19
+
20
+ const WORKSPACE_README = `# Orizu workspace
21
+
22
+ Local Orizu artifacts for this repository. This folder is gitignored by
23
+ default: generated and server-derived content here is not guaranteed to stay
24
+ in sync with the Orizu server, so checking it in would risk drift. Safe,
25
+ user-authored configuration may move to a committed location once a sync model
26
+ exists.
27
+
28
+ - \`workspace.json\`: safe local metadata (no secrets; credentials stay in
29
+ \`~/.config/orizu\`).
30
+ - \`generated/\`: runner artifacts, exports, and other generated files.
31
+ `
32
+
33
+ export function getWorkspaceRoot(cwd?: string): string {
34
+ return resolve(cwd || process.cwd(), WORKSPACE_DIR_NAME)
35
+ }
36
+
37
+ export function workspaceExists(cwd?: string): boolean {
38
+ return existsSync(join(getWorkspaceRoot(cwd), 'workspace.json'))
39
+ }
40
+
41
+ function ensureGitignoreEntry(cwd: string, dryRun: boolean): { updated: boolean, action: string | null } {
42
+ const gitignorePath = resolve(cwd, '.gitignore')
43
+ const entry = `${WORKSPACE_DIR_NAME}/`
44
+
45
+ if (existsSync(gitignorePath)) {
46
+ const existing = readFileSync(gitignorePath, 'utf8')
47
+ const hasEntry = existing
48
+ .split('\n')
49
+ .some(line => line.trim() === entry || line.trim() === WORKSPACE_DIR_NAME)
50
+ if (hasEntry) {
51
+ return { updated: false, action: null }
52
+ }
53
+ if (!dryRun) {
54
+ const separator = existing.endsWith('\n') || existing.length === 0 ? '' : '\n'
55
+ writeFileSync(gitignorePath, `${existing}${separator}\n# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
56
+ }
57
+ return { updated: true, action: `append ${entry} to .gitignore` }
58
+ }
59
+
60
+ if (!dryRun) {
61
+ writeFileSync(gitignorePath, `# Local Orizu workspace (generated; not synced with the server)\n${entry}\n`, 'utf8')
62
+ }
63
+ return { updated: true, action: `create .gitignore with ${entry}` }
64
+ }
65
+
66
+ export function initOrizuWorkspace(options?: WorkspaceInitOptions): WorkspaceInitResult {
67
+ const cwd = resolve(options?.cwd || process.cwd())
68
+ const root = getWorkspaceRoot(cwd)
69
+ const dryRun = Boolean(options?.dryRun)
70
+ const exists = workspaceExists(cwd)
71
+ const actions: string[] = []
72
+
73
+ if (!exists) {
74
+ actions.push(`create ${WORKSPACE_DIR_NAME}/workspace.json, README.md, and generated/`)
75
+ }
76
+
77
+ const gitignore = ensureGitignoreEntry(cwd, dryRun || exists)
78
+ if (!exists && gitignore.action) {
79
+ actions.push(gitignore.action)
80
+ }
81
+
82
+ if (dryRun) {
83
+ return {
84
+ root,
85
+ state: exists ? 'exists' : 'would-create',
86
+ gitignoreUpdated: false,
87
+ actions,
88
+ }
89
+ }
90
+
91
+ if (exists) {
92
+ return { root, state: 'exists', gitignoreUpdated: false, actions: [] }
93
+ }
94
+
95
+ mkdirSync(join(root, 'generated'), { recursive: true })
96
+ writeFileSync(join(root, 'README.md'), WORKSPACE_README, 'utf8')
97
+ writeFileSync(
98
+ join(root, 'workspace.json'),
99
+ `${JSON.stringify(
100
+ {
101
+ version: 1,
102
+ server: options?.baseUrl || null,
103
+ createdByCliVersion: options?.cliVersion || null,
104
+ },
105
+ null,
106
+ 2
107
+ )}\n`,
108
+ 'utf8'
109
+ )
110
+
111
+ return { root, state: 'created', gitignoreUpdated: gitignore.updated, actions }
112
+ }
@@ -3,6 +3,7 @@ from .optimizer import (
3
3
  DatasetRow,
4
4
  NumThreadsPlan,
5
5
  PromptContext,
6
+ RetryableReflectionError,
6
7
  RowEvaluation,
7
8
  TextGepaConfig,
8
9
  TextGepaResult,
@@ -15,6 +16,7 @@ __all__ = [
15
16
  "DatasetRow",
16
17
  "NumThreadsPlan",
17
18
  "PromptContext",
19
+ "RetryableReflectionError",
18
20
  "RowEvaluation",
19
21
  "TextGepaConfig",
20
22
  "TextGepaResult",