@soederpop/luca 0.0.20 → 0.0.22

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.
@@ -26,6 +26,13 @@ luca describe clients # index of all available clients
26
26
  luca describe servers # index of all available servers
27
27
  ```
28
28
 
29
+ You can even learn about features in the browser container, or a specific platform (server, node are the same, browser,web are the same)
30
+
31
+ ```shell
32
+ luca describe features --platform=web
33
+ luca describe features --platform=server
34
+ ```
35
+
29
36
  ### Learn about specific helpers
30
37
 
31
38
  ```shell
@@ -230,6 +237,15 @@ This is useful inside commands and scripts where you need introspection data pro
230
237
 
231
238
  ---
232
239
 
240
+ ## Server development troubleshooting
241
+
242
+ - You can use `container.proc.findPidsByPort(3000)` which will return an array of numbers.
243
+ - You can use `container.proc.kill(pid)` to kill that process
244
+ - You can combine these two functions in `luca eval` if a server you're developing won't start because a previous instance is running (common inside e.g. claude code sessions )
245
+ - `luca serve --force` will also replace the running process with the current one
246
+ - `luca serve --any-port` will open on any port
247
+
248
+
233
249
  ## Reference
234
250
 
235
251
  See `references/api-docs/` for the full pre-generated API reference for every built-in feature, client, and server.
package/luca.cli.ts CHANGED
@@ -1,3 +1,16 @@
1
- export async function main(container) {
1
+ export async function main(container: any) {
2
2
  container.addContext('luca', container)
3
+
4
+ try {
5
+ container.onMissingCommand(handleMissingCommand)
6
+ } catch(error) {
7
+
8
+ }
9
+
10
+
11
+ async function handleMissingCommand({ words, phrase } : { words: string[], phrase: string }) {
12
+ const { ui } = container
13
+
14
+ ui.print.red('oh shit ' + phrase)
15
+ }
3
16
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soederpop/luca",
3
- "version": "0.0.20",
3
+ "version": "0.0.22",
4
4
  "website": "https://luca.soederpop.com",
5
5
  "description": "lightweight universal conversational architecture AKA Le Ultimate Component Architecture AKA Last Universal Common Ancestor, part AI part Human",
6
6
  "author": "jon soeder aka the people's champ <jon@soederpop.com>",
@@ -1,5 +1,5 @@
1
1
  // Auto-generated bootstrap content
2
- // Generated at: 2026-03-21T01:13:43.856Z
2
+ // Generated at: 2026-03-21T05:25:04.090Z
3
3
  // Source: docs/bootstrap/*.md, docs/bootstrap/templates/*
4
4
  //
5
5
  // Do not edit manually. Run: luca build-bootstrap
@@ -33,6 +33,13 @@ luca describe clients # index of all available clients
33
33
  luca describe servers # index of all available servers
34
34
  \`\`\`
35
35
 
36
+ You can even learn about features in the browser container, or a specific platform (server, node are the same, browser,web are the same)
37
+
38
+ \`\`\`shell
39
+ luca describe features --platform=web
40
+ luca describe features --platform=server
41
+ \`\`\`
42
+
36
43
  ### Learn about specific helpers
37
44
 
38
45
  \`\`\`shell
@@ -237,6 +244,15 @@ This is useful inside commands and scripts where you need introspection data pro
237
244
 
238
245
  ---
239
246
 
247
+ ## Server development troubleshooting
248
+
249
+ - You can use \`container.proc.findPidsByPort(3000)\` which will return an array of numbers.
250
+ - You can use \`container.proc.kill(pid)\` to kill that process
251
+ - You can combine these two functions in \`luca eval\` if a server you're developing won't start because a previous instance is running (common inside e.g. claude code sessions )
252
+ - \`luca serve --force\` will also replace the running process with the current one
253
+ - \`luca serve --any-port\` will open on any port
254
+
255
+
240
256
  ## Reference
241
257
 
242
258
  See \`references/api-docs/\` for the full pre-generated API reference for every built-in feature, client, and server.
package/src/cli/cli.ts CHANGED
@@ -19,7 +19,7 @@ import { join } from 'path'
19
19
  async function main() {
20
20
  const profile = process.env.LUCA_PROFILE === '1'
21
21
  const t = (label?: string) => {
22
- if (!profile) return () => {}
22
+ if (!profile) return () => { }
23
23
  const start = performance.now()
24
24
  return (suffix?: string) => {
25
25
  const ms = (performance.now() - start).toFixed(1)
@@ -59,8 +59,8 @@ async function main() {
59
59
  const afterUser = new Set(container.commands.available as string[])
60
60
  const userCommands = new Set([...afterUser].filter((n) => !builtinCommands.has(n) && !projectCommands.has(n)))
61
61
 
62
- // Store command sources for help display
63
- ;(container as any)._commandSources = { builtinCommands, projectCommands, userCommands }
62
+ // Store command sources for help display
63
+ ; (container as any)._commandSources = { builtinCommands, projectCommands, userCommands }
64
64
 
65
65
  // Load generated introspection data if present
66
66
  done = t('loadProjectIntrospection')
@@ -82,9 +82,33 @@ async function main() {
82
82
  await cmd.dispatch()
83
83
  } else if (commandName) {
84
84
  // not a known command — treat as implicit `run`
85
- container.argv._.splice(0, 0, 'run')
86
- const cmd = container.command('run' as any)
87
- await cmd.dispatch()
85
+ //
86
+ if (resolveScript(commandName, container)) {
87
+ container.argv._.splice(0, 0, 'run')
88
+ const cmd = container.command('run' as any)
89
+ await cmd.dispatch()
90
+ } else {
91
+
92
+ // @ts-ignore TODO come up with a typesafe way to do this
93
+ if (container.state.get('missingCommandHandler')) {
94
+ // @ts-ignore TODO come up with a typesafe way to do this
95
+ const missingCommandHandler = container.state.get('missingCommandHandler') as any
96
+
97
+ if (typeof missingCommandHandler === 'function') {
98
+ await missingCommandHandler({
99
+ words: container.argv._,
100
+ phrase: container.argv._.join(' ')
101
+ }).catch((err: any) => {
102
+ console.error(`Missing command handler error: ${err.message}`, err)
103
+ })
104
+ }
105
+ } else {
106
+ container.argv._.splice(0, 0, 'help')
107
+ const cmd = container.command('help' as any)
108
+ await cmd.dispatch()
109
+ }
110
+ }
111
+
88
112
  } else {
89
113
  container.argv._.splice(0, 0, 'help')
90
114
  const cmd = container.command('help' as any)
@@ -94,6 +118,21 @@ async function main() {
94
118
  tTotal()
95
119
  }
96
120
 
121
+ function resolveScript(ref: string, container: any) {
122
+ const candidates = [
123
+ ref,
124
+ `${ref}.ts`,
125
+ `${ref}.js`,
126
+ `${ref}.md`,
127
+ ]
128
+
129
+ for (const candidate of candidates) {
130
+ const resolved = container.paths.resolve(candidate)
131
+ if (container.fs.exists(resolved)) return resolved
132
+ }
133
+
134
+ return null
135
+ }
97
136
 
98
137
  async function loadCliModule() {
99
138
  const modulePath = container.paths.resolve('luca.cli.ts')
@@ -187,8 +187,20 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
187
187
  output: process.stdout,
188
188
  })
189
189
 
190
+ let rlClosed = false
191
+ rl.on('close', () => { rlClosed = true })
192
+
193
+ function ensureRl() {
194
+ if (rlClosed) {
195
+ rl = readline.createInterface({ input: process.stdin, output: process.stdout })
196
+ rlClosed = false
197
+ rl.on('close', () => { rlClosed = true })
198
+ }
199
+ }
200
+
190
201
  function prompt(): Promise<string> {
191
202
  return new Promise((resolve) => {
203
+ ensureRl()
192
204
  rl.question(ui.colors.dim(`\n${name} > `), (answer: string) => resolve(answer.trim()))
193
205
  })
194
206
  }
@@ -247,6 +259,8 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
247
259
  console.log()
248
260
  console.log(ui.colors.dim(` Back in chat with ${ui.colors.cyan(name)}.`))
249
261
  rl = readline.createInterface({ input: process.stdin, output: process.stdout })
262
+ rlClosed = false
263
+ rl.on('close', () => { rlClosed = true })
250
264
  continue
251
265
  }
252
266
 
@@ -12,18 +12,26 @@ declare module '../command.js' {
12
12
 
13
13
  export const argsSchema = CommandOptionsSchema.extend({
14
14
  model: z.string().optional().describe('Override the LLM model (assistant mode only)'),
15
- 'preserve-frontmatter': z.boolean().default(false).describe('Keep YAML frontmatter in the prompt instead of stripping it'),
15
+ 'preserve-frontmatter': z.boolean().default(false).describe('Keep YAML frontmatter in the prompt instead of stripping it before sending to the agent.'),
16
16
  'permission-mode': z.enum(['default', 'acceptEdits', 'bypassPermissions', 'plan']).default('acceptEdits').describe('Permission mode for CLI agents (default: acceptEdits)'),
17
17
  'in-folder': z.string().optional().describe('Run the CLI agent in this directory (resolved via container.paths)'),
18
18
  'out-file': z.string().optional().describe('Save session output as a markdown file'),
19
19
  'include-output': z.boolean().default(false).describe('Include tool call outputs in the markdown (requires --out-file)'),
20
- 'dont-touch-file': z.boolean().default(false).describe('Do not update the prompt file frontmatter with run stats'),
21
- 'repeat-anyway': z.boolean().default(false).describe('Run even if repeatable is false and the prompt has already been run'),
20
+ 'inputs-file': z.string().optional().describe('Path to a JSON or YAML file supplying input values'),
22
21
  'parallel': z.boolean().default(false).describe('Run multiple prompt files in parallel with side-by-side terminal UI'),
23
22
  'exclude-sections': z.string().optional().describe('Comma-separated list of section headings to exclude from the prompt'),
24
23
  'chrome': z.boolean().default(false).describe('Launch Claude Code with a Chrome browser tool'),
24
+ 'dry-run': z.boolean().default(false).describe('Display the resolved prompt and options without running the assistant'),
25
+ 'local': z.boolean().default(false).describe('Use local models for assistant mode'),
25
26
  })
26
27
 
28
+ function normalizeTarget(raw: string): string {
29
+ const lower = raw.toLowerCase().replace(/[-_]/g, '')
30
+ if (/claude/.test(lower)) return 'claude'
31
+ if (/codex/.test(lower) || /openai/.test(lower)) return 'codex'
32
+ return raw
33
+ }
34
+
27
35
  const CLI_TARGETS = new Set(['claude', 'codex'])
28
36
 
29
37
  function formatSessionMarkdown(events: any[], includeOutput: boolean): string {
@@ -72,9 +80,10 @@ interface PreparedPrompt {
72
80
  resolvedPath: string
73
81
  promptContent: string
74
82
  filename: string
83
+ agentOptions: Record<string, any>
75
84
  }
76
85
 
77
- async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: string, container: any, options: z.infer<typeof argsSchema>): Promise<RunStats> {
86
+ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: string, container: any, options: z.infer<typeof argsSchema>, agentOptions: Record<string, any> = {}): Promise<RunStats> {
78
87
  const ui = container.feature('ui')
79
88
  const featureName = target === 'claude' ? 'claudeCode' : 'openaiCodex'
80
89
  const feature = container.feature(featureName)
@@ -118,7 +127,7 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
118
127
  })
119
128
  }
120
129
 
121
- const runOptions: Record<string, any> = { streaming: true }
130
+ const runOptions: Record<string, any> = { streaming: true, ...agentOptions }
122
131
 
123
132
  if (options['in-folder']) {
124
133
  runOptions.cwd = container.paths.resolve(options['in-folder'])
@@ -129,6 +138,9 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
129
138
  if (options.chrome) runOptions.chrome = true
130
139
  }
131
140
 
141
+ // CLI flags override agentOptions from frontmatter
142
+ if (options.model) runOptions.model = options.model
143
+
132
144
  const startTime = Date.now()
133
145
  const sessionId = await feature.start(promptContent, runOptions)
134
146
  const session = await feature.waitForSession(sessionId)
@@ -143,7 +155,7 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
143
155
  return { collectedEvents, durationMs: Date.now() - startTime, outputTokens }
144
156
  }
145
157
 
146
- async function runAssistant(name: string, promptContent: string, options: z.infer<typeof argsSchema>, container: any): Promise<RunStats> {
158
+ async function runAssistant(name: string, promptContent: string, options: z.infer<typeof argsSchema>, container: any, agentOptions: Record<string, any> = {}): Promise<RunStats> {
147
159
  const ui = container.feature('ui')
148
160
  const manager = container.feature('assistantsManager')
149
161
  await manager.discover()
@@ -156,8 +168,10 @@ async function runAssistant(name: string, promptContent: string, options: z.infe
156
168
  process.exit(1)
157
169
  }
158
170
 
159
- const createOptions: Record<string, any> = {}
171
+ const createOptions: Record<string, any> = { ...agentOptions }
172
+ // CLI flags override agentOptions from frontmatter
160
173
  if (options.model) createOptions.model = options.model
174
+ if (options.local) createOptions.local = true
161
175
 
162
176
  const assistant = manager.create(name, createOptions)
163
177
  let isFirstChunk = true
@@ -311,9 +325,11 @@ async function runParallel(
311
325
  })
312
326
  }
313
327
 
314
- // Start all sessions
328
+ // Start all sessions — merge per-prompt agentOptions with shared runOptions
315
329
  for (let i = 0; i < prepared.length; i++) {
316
- const id = await feature.start(prepared[i].promptContent, runOptions)
330
+ const perPromptOptions = { ...prepared[i].agentOptions, ...runOptions }
331
+ if (options.model) perPromptOptions.model = options.model
332
+ const id = await feature.start(prepared[i].promptContent, perPromptOptions)
317
333
  sessionMap.set(id, i)
318
334
  }
319
335
 
@@ -348,12 +364,12 @@ async function runParallel(
348
364
  process.exit(1)
349
365
  }
350
366
 
351
- const createOptions: Record<string, any> = {}
352
- if (options.model) createOptions.model = options.model
353
-
354
367
  const lineBuffers: string[] = prepared.map(() => '')
355
368
 
356
369
  const assistants = prepared.map((p, i) => {
370
+ const createOptions: Record<string, any> = { ...p.agentOptions }
371
+ if (options.model) createOptions.model = options.model
372
+ if (options.local) createOptions.local = true
357
373
  const assistant = manager.create(target, createOptions)
358
374
 
359
375
  assistant.on('chunk', (text: string) => {
@@ -526,24 +542,6 @@ async function runParallel(
526
542
  // Wait for sessions to fully settle
527
543
  await sessionPromise
528
544
 
529
- // Post-completion: update frontmatter
530
- if (!options['dont-touch-file']) {
531
- for (let i = 0; i < promptStates.length; i++) {
532
- const ps = promptStates[i]
533
- if (ps.status === 'error') continue
534
- const rawContent = fs.readFile(prepared[i].resolvedPath) as string
535
- const updates: Record<string, any> = {
536
- lastRanAt: Date.now(),
537
- durationMs: ps.durationMs,
538
- }
539
- if (ps.outputTokens > 0) {
540
- updates.outputTokens = ps.outputTokens
541
- }
542
- const updated = updateFrontmatter(rawContent, updates, container)
543
- await Bun.write(prepared[i].resolvedPath, updated)
544
- }
545
- }
546
-
547
545
  // Post-completion: out-files
548
546
  if (options['out-file']) {
549
547
  const base = options['out-file']
@@ -573,26 +571,123 @@ async function runParallel(
573
571
  }
574
572
  }
575
573
 
576
- function updateFrontmatter(fileContent: string, updates: Record<string, any>, container: any): string {
574
+ interface InputDef {
575
+ description?: string
576
+ required?: boolean
577
+ default?: any
578
+ type?: string
579
+ choices?: string[]
580
+ }
581
+
582
+ function parseInputDefs(meta: Record<string, any>): Record<string, InputDef> | null {
583
+ if (!meta?.inputs || typeof meta.inputs !== 'object') return null
584
+ const defs: Record<string, InputDef> = {}
585
+ for (const [key, val] of Object.entries(meta.inputs)) {
586
+ if (typeof val === 'object' && val !== null) {
587
+ defs[key] = val as InputDef
588
+ } else {
589
+ // Shorthand: `topic: "What to write about"` means description-only, required
590
+ defs[key] = { description: typeof val === 'string' ? val : String(val) }
591
+ }
592
+ }
593
+ return Object.keys(defs).length ? defs : null
594
+ }
595
+
596
+ async function resolveInputs(
597
+ inputDefs: Record<string, InputDef>,
598
+ options: z.infer<typeof argsSchema>,
599
+ container: any,
600
+ ): Promise<Record<string, any>> {
601
+ const { fs, paths } = container
577
602
  const yaml = container.feature('yaml')
603
+ const ui = container.feature('ui')
604
+
605
+ // Layer 1: inputs-file (lowest priority of supplied values)
606
+ let fileInputs: Record<string, any> = {}
607
+ if (options['inputs-file']) {
608
+ const filePath = paths.resolve(options['inputs-file'])
609
+ const raw = fs.readFile(filePath) as string
610
+ if (filePath.endsWith('.json')) {
611
+ fileInputs = JSON.parse(raw)
612
+ } else {
613
+ fileInputs = yaml.parse(raw) || {}
614
+ }
615
+ }
616
+
617
+ // Layer 2: CLI flags (highest priority) — any unknown option that matches an input name
618
+ const cliInputs: Record<string, any> = {}
619
+ const argv = container.argv as Record<string, any>
620
+ for (const key of Object.keys(inputDefs)) {
621
+ if (argv[key] !== undefined) {
622
+ cliInputs[key] = argv[key]
623
+ }
624
+ }
625
+
626
+ // Merge: CLI > file > defaults
627
+ const supplied: Record<string, any> = {}
628
+ for (const [key, def] of Object.entries(inputDefs)) {
629
+ if (cliInputs[key] !== undefined) {
630
+ supplied[key] = cliInputs[key]
631
+ } else if (fileInputs[key] !== undefined) {
632
+ supplied[key] = fileInputs[key]
633
+ } else if (def.default !== undefined) {
634
+ supplied[key] = def.default
635
+ }
636
+ }
578
637
 
579
- if (fileContent.startsWith('---')) {
580
- const endIndex = fileContent.indexOf('\n---', 3)
581
- if (endIndex !== -1) {
582
- const existingYaml = fileContent.slice(4, endIndex)
583
- const meta = yaml.parse(existingYaml) || {}
584
- Object.assign(meta, updates)
585
- const newYaml = yaml.stringify(meta).trimEnd()
586
- return `---\n${newYaml}\n---${fileContent.slice(endIndex + 4)}`
638
+ // Find missing required inputs
639
+ const missing: string[] = []
640
+ for (const [key, def] of Object.entries(inputDefs)) {
641
+ const isRequired = def.required !== false // default to required
642
+ if (isRequired && supplied[key] === undefined) {
643
+ missing.push(key)
587
644
  }
588
645
  }
589
646
 
590
- // No existing frontmatter prepend one
591
- const newYaml = yaml.stringify(updates).trimEnd()
592
- return `---\n${newYaml}\n---\n\n${fileContent}`
647
+ if (missing.length === 0) return supplied
648
+
649
+ // In parallel mode, we can't run an interactive wizard
650
+ if ((options as any).parallel) {
651
+ console.error(`Missing required inputs for parallel mode (use --inputs-file or CLI flags): ${missing.join(', ')}`)
652
+ process.exit(1)
653
+ }
654
+
655
+ // Build wizard questions for missing inputs
656
+ const questions = missing.map((key) => {
657
+ const def = inputDefs[key]
658
+ const q: Record<string, any> = {
659
+ name: key,
660
+ message: def.description || key,
661
+ }
662
+
663
+ // Auto-infer type
664
+ if (def.choices?.length) {
665
+ q.type = 'list'
666
+ q.choices = def.choices
667
+ } else if (def.type) {
668
+ q.type = def.type
669
+ } else {
670
+ q.type = 'input'
671
+ }
672
+
673
+ if (def.default !== undefined) {
674
+ q.default = def.default
675
+ }
676
+
677
+ return q
678
+ })
679
+
680
+ const answers = await ui.wizard(questions, supplied)
681
+ return { ...supplied, ...answers }
593
682
  }
594
683
 
595
- async function executePromptFile(resolvedPath: string, container: any): Promise<string> {
684
+ function substituteInputs(content: string, inputs: Record<string, any>): string {
685
+ return content.replace(/\{\{(\w+)\}\}/g, (match, key) => {
686
+ return inputs[key] !== undefined ? String(inputs[key]) : match
687
+ })
688
+ }
689
+
690
+ async function executePromptFile(resolvedPath: string, container: any, inputs?: Record<string, any>): Promise<string> {
596
691
  if (!container.docs.isLoaded) await container.docs.load()
597
692
  const doc = await container.docs.parseMarkdownAtPath(resolvedPath)
598
693
  const vm = container.feature('vm')
@@ -608,6 +703,7 @@ async function executePromptFile(resolvedPath: string, container: any): Promise<
608
703
 
609
704
  const shared = vm.createContext({
610
705
  ...container.context,
706
+ INPUTS: inputs || {},
611
707
  console: captureConsole,
612
708
  setTimeout, clearTimeout, setInterval, clearInterval,
613
709
  fetch, URL, URLSearchParams,
@@ -669,24 +765,40 @@ async function preparePrompt(
669
765
 
670
766
  let content = fs.readFile(resolvedPath) as string
671
767
 
672
- // Check repeatable gate
673
- if (!options['repeat-anyway'] && content.startsWith('---')) {
768
+ // Parse frontmatter for input definitions and agentOptions
769
+ let resolvedInputs: Record<string, any> = {}
770
+ let agentOptions: Record<string, any> = {}
771
+ let hasInputDefs = false
772
+ if (content.startsWith('---')) {
674
773
  const fmEnd = content.indexOf('\n---', 3)
675
774
  if (fmEnd !== -1) {
676
775
  const yaml = container.feature('yaml')
677
776
  const meta = yaml.parse(content.slice(4, fmEnd)) || {}
678
- if (meta.repeatable === false && meta.lastRanAt) {
679
- console.error(`${filePath}: already run (lastRanAt: ${new Date(meta.lastRanAt).toLocaleString()}) and repeatable is false. Skipping.`)
680
- return null
777
+ const inputDefs = parseInputDefs(meta)
778
+ if (inputDefs) {
779
+ hasInputDefs = true
780
+ resolvedInputs = await resolveInputs(inputDefs, options, container)
781
+ }
782
+ if (meta.agentOptions && typeof meta.agentOptions === 'object') {
783
+ agentOptions = { ...meta.agentOptions }
681
784
  }
682
785
  }
683
786
  }
684
787
 
788
+ if (options['inputs-file'] && !hasInputDefs) {
789
+ console.warn(`Warning: --inputs-file was provided but ${filePath} does not define any inputs in its frontmatter`)
790
+ }
791
+
685
792
  let promptContent: string
686
793
  if (options['preserve-frontmatter']) {
687
794
  promptContent = content
688
795
  } else {
689
- promptContent = await executePromptFile(resolvedPath, container)
796
+ promptContent = await executePromptFile(resolvedPath, container, resolvedInputs)
797
+ }
798
+
799
+ // Substitute {{key}} placeholders with resolved input values
800
+ if (Object.keys(resolvedInputs).length) {
801
+ promptContent = substituteInputs(promptContent, resolvedInputs)
690
802
  }
691
803
 
692
804
  // Exclude sections by heading name
@@ -709,6 +821,7 @@ async function preparePrompt(
709
821
  resolvedPath,
710
822
  promptContent,
711
823
  filename: paths.basename(resolvedPath),
824
+ agentOptions,
712
825
  }
713
826
  }
714
827
 
@@ -728,6 +841,9 @@ export default async function prompt(options: z.infer<typeof argsSchema>, contex
728
841
  }
729
842
  }
730
843
 
844
+ // Normalize target aliases (e.g. claude-code → claude, openai-codex → codex)
845
+ if (target) target = normalizeTarget(target)
846
+
731
847
  if (!target || allPaths.length === 0) {
732
848
  console.error('Usage: luca prompt [claude|codex|assistant-name] <path/to/prompt.md> [more paths...]')
733
849
  process.exit(1)
@@ -751,6 +867,28 @@ export default async function prompt(options: z.infer<typeof argsSchema>, contex
751
867
  process.exit(1)
752
868
  }
753
869
 
870
+ if (options['dry-run']) {
871
+ const ui = container.feature('ui')
872
+ console.log(ui.colors.bold('\n── Dry Run (Parallel) ──\n'))
873
+ console.log(ui.colors.bold('Target:'), target)
874
+ console.log(ui.colors.bold('Prompts:'), prepared.length)
875
+ for (const p of prepared) {
876
+ console.log(ui.colors.bold(`\n── ${p.filename} ──`))
877
+ console.log(ui.colors.dim(` Path: ${p.resolvedPath}`))
878
+ console.log(ui.colors.dim(` Length: ${p.promptContent.length} chars`))
879
+ if (Object.keys(p.agentOptions).length) {
880
+ console.log(ui.colors.dim(' Agent options:'))
881
+ for (const [key, val] of Object.entries(p.agentOptions)) {
882
+ const display = typeof val === 'object' ? JSON.stringify(val) : val
883
+ console.log(ui.colors.dim(` ${key}: ${display}`))
884
+ }
885
+ }
886
+ console.log('')
887
+ process.stdout.write(ui.markdown(p.promptContent))
888
+ }
889
+ return
890
+ }
891
+
754
892
  if (prepared.length > 1) {
755
893
  await runParallel(target, prepared, options, container)
756
894
  return
@@ -767,28 +905,43 @@ export default async function prompt(options: z.infer<typeof argsSchema>, contex
767
905
  }
768
906
 
769
907
  const ui = container.feature('ui')
908
+
909
+ if (options['dry-run']) {
910
+ const runOptions: Record<string, any> = { ...p.agentOptions }
911
+ if (options.model) runOptions.model = options.model
912
+ if (options['in-folder']) runOptions.cwd = container.paths.resolve(options['in-folder'])
913
+ if (options['out-file']) runOptions.outFile = options['out-file']
914
+ if (options['include-output']) runOptions.includeOutput = true
915
+ if (options['exclude-sections']) runOptions.excludeSections = options['exclude-sections']
916
+ if (CLI_TARGETS.has(target)) {
917
+ runOptions.permissionMode = options['permission-mode']
918
+ if (options.chrome) runOptions.chrome = true
919
+ }
920
+
921
+ console.log(ui.colors.bold('\n── Dry Run ──\n'))
922
+ console.log(ui.colors.bold('Target:'), target)
923
+ console.log(ui.colors.bold('Prompt file:'), p.resolvedPath)
924
+ console.log(ui.colors.bold('Prompt length:'), `${p.promptContent.length} chars`)
925
+ if (Object.keys(runOptions).length) {
926
+ console.log(ui.colors.bold('Options:'))
927
+ for (const [key, val] of Object.entries(runOptions)) {
928
+ const display = typeof val === 'object' ? JSON.stringify(val) : val
929
+ console.log(` ${key}: ${display}`)
930
+ }
931
+ }
932
+ console.log(ui.colors.bold('\n── Prompt Content ──\n'))
933
+ process.stdout.write(ui.markdown(p.promptContent))
934
+ return
935
+ }
936
+
770
937
  process.stdout.write(ui.markdown(p.promptContent))
771
938
 
772
939
  let stats: RunStats
773
940
 
774
941
  if (CLI_TARGETS.has(target)) {
775
- stats = await runClaudeOrCodex(target as 'claude' | 'codex', p.promptContent, container, options)
942
+ stats = await runClaudeOrCodex(target as 'claude' | 'codex', p.promptContent, container, options, p.agentOptions)
776
943
  } else {
777
- stats = await runAssistant(target, p.promptContent, options, container)
778
- }
779
-
780
- // Update prompt file frontmatter with run stats
781
- if (!options['dont-touch-file']) {
782
- const rawContent = fs.readFile(p.resolvedPath) as string
783
- const updates: Record<string, any> = {
784
- lastRanAt: Date.now(),
785
- durationMs: stats.durationMs,
786
- }
787
- if (stats.outputTokens > 0) {
788
- updates.outputTokens = stats.outputTokens
789
- }
790
- const updated = updateFrontmatter(rawContent, updates, container)
791
- await Bun.write(p.resolvedPath, updated)
944
+ stats = await runAssistant(target, p.promptContent, options, container, p.agentOptions)
792
945
  }
793
946
 
794
947
  if (options['out-file'] && stats.collectedEvents.length) {