@soederpop/luca 0.0.20 → 0.0.21

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@soederpop/luca",
3
- "version": "0.0.20",
3
+ "version": "0.0.21",
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:24:16.832Z
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.
@@ -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
 
@@ -17,11 +17,11 @@ export const argsSchema = CommandOptionsSchema.extend({
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
25
  })
26
26
 
27
27
  const CLI_TARGETS = new Set(['claude', 'codex'])
@@ -72,9 +72,10 @@ interface PreparedPrompt {
72
72
  resolvedPath: string
73
73
  promptContent: string
74
74
  filename: string
75
+ agentOptions: Record<string, any>
75
76
  }
76
77
 
77
- async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: string, container: any, options: z.infer<typeof argsSchema>): Promise<RunStats> {
78
+ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: string, container: any, options: z.infer<typeof argsSchema>, agentOptions: Record<string, any> = {}): Promise<RunStats> {
78
79
  const ui = container.feature('ui')
79
80
  const featureName = target === 'claude' ? 'claudeCode' : 'openaiCodex'
80
81
  const feature = container.feature(featureName)
@@ -118,7 +119,7 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
118
119
  })
119
120
  }
120
121
 
121
- const runOptions: Record<string, any> = { streaming: true }
122
+ const runOptions: Record<string, any> = { streaming: true, ...agentOptions }
122
123
 
123
124
  if (options['in-folder']) {
124
125
  runOptions.cwd = container.paths.resolve(options['in-folder'])
@@ -129,6 +130,9 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
129
130
  if (options.chrome) runOptions.chrome = true
130
131
  }
131
132
 
133
+ // CLI flags override agentOptions from frontmatter
134
+ if (options.model) runOptions.model = options.model
135
+
132
136
  const startTime = Date.now()
133
137
  const sessionId = await feature.start(promptContent, runOptions)
134
138
  const session = await feature.waitForSession(sessionId)
@@ -143,7 +147,7 @@ async function runClaudeOrCodex(target: 'claude' | 'codex', promptContent: strin
143
147
  return { collectedEvents, durationMs: Date.now() - startTime, outputTokens }
144
148
  }
145
149
 
146
- async function runAssistant(name: string, promptContent: string, options: z.infer<typeof argsSchema>, container: any): Promise<RunStats> {
150
+ async function runAssistant(name: string, promptContent: string, options: z.infer<typeof argsSchema>, container: any, agentOptions: Record<string, any> = {}): Promise<RunStats> {
147
151
  const ui = container.feature('ui')
148
152
  const manager = container.feature('assistantsManager')
149
153
  await manager.discover()
@@ -156,7 +160,8 @@ async function runAssistant(name: string, promptContent: string, options: z.infe
156
160
  process.exit(1)
157
161
  }
158
162
 
159
- const createOptions: Record<string, any> = {}
163
+ const createOptions: Record<string, any> = { ...agentOptions }
164
+ // CLI flags override agentOptions from frontmatter
160
165
  if (options.model) createOptions.model = options.model
161
166
 
162
167
  const assistant = manager.create(name, createOptions)
@@ -311,9 +316,11 @@ async function runParallel(
311
316
  })
312
317
  }
313
318
 
314
- // Start all sessions
319
+ // Start all sessions — merge per-prompt agentOptions with shared runOptions
315
320
  for (let i = 0; i < prepared.length; i++) {
316
- const id = await feature.start(prepared[i].promptContent, runOptions)
321
+ const perPromptOptions = { ...prepared[i].agentOptions, ...runOptions }
322
+ if (options.model) perPromptOptions.model = options.model
323
+ const id = await feature.start(prepared[i].promptContent, perPromptOptions)
317
324
  sessionMap.set(id, i)
318
325
  }
319
326
 
@@ -348,12 +355,11 @@ async function runParallel(
348
355
  process.exit(1)
349
356
  }
350
357
 
351
- const createOptions: Record<string, any> = {}
352
- if (options.model) createOptions.model = options.model
353
-
354
358
  const lineBuffers: string[] = prepared.map(() => '')
355
359
 
356
360
  const assistants = prepared.map((p, i) => {
361
+ const createOptions: Record<string, any> = { ...p.agentOptions }
362
+ if (options.model) createOptions.model = options.model
357
363
  const assistant = manager.create(target, createOptions)
358
364
 
359
365
  assistant.on('chunk', (text: string) => {
@@ -526,24 +532,6 @@ async function runParallel(
526
532
  // Wait for sessions to fully settle
527
533
  await sessionPromise
528
534
 
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
535
  // Post-completion: out-files
548
536
  if (options['out-file']) {
549
537
  const base = options['out-file']
@@ -573,26 +561,123 @@ async function runParallel(
573
561
  }
574
562
  }
575
563
 
576
- function updateFrontmatter(fileContent: string, updates: Record<string, any>, container: any): string {
564
+ interface InputDef {
565
+ description?: string
566
+ required?: boolean
567
+ default?: any
568
+ type?: string
569
+ choices?: string[]
570
+ }
571
+
572
+ function parseInputDefs(meta: Record<string, any>): Record<string, InputDef> | null {
573
+ if (!meta?.inputs || typeof meta.inputs !== 'object') return null
574
+ const defs: Record<string, InputDef> = {}
575
+ for (const [key, val] of Object.entries(meta.inputs)) {
576
+ if (typeof val === 'object' && val !== null) {
577
+ defs[key] = val as InputDef
578
+ } else {
579
+ // Shorthand: `topic: "What to write about"` means description-only, required
580
+ defs[key] = { description: typeof val === 'string' ? val : String(val) }
581
+ }
582
+ }
583
+ return Object.keys(defs).length ? defs : null
584
+ }
585
+
586
+ async function resolveInputs(
587
+ inputDefs: Record<string, InputDef>,
588
+ options: z.infer<typeof argsSchema>,
589
+ container: any,
590
+ ): Promise<Record<string, any>> {
591
+ const { fs, paths } = container
577
592
  const yaml = container.feature('yaml')
593
+ const ui = container.feature('ui')
594
+
595
+ // Layer 1: inputs-file (lowest priority of supplied values)
596
+ let fileInputs: Record<string, any> = {}
597
+ if (options['inputs-file']) {
598
+ const filePath = paths.resolve(options['inputs-file'])
599
+ const raw = fs.readFile(filePath) as string
600
+ if (filePath.endsWith('.json')) {
601
+ fileInputs = JSON.parse(raw)
602
+ } else {
603
+ fileInputs = yaml.parse(raw) || {}
604
+ }
605
+ }
606
+
607
+ // Layer 2: CLI flags (highest priority) — any unknown option that matches an input name
608
+ const cliInputs: Record<string, any> = {}
609
+ const argv = container.argv as Record<string, any>
610
+ for (const key of Object.keys(inputDefs)) {
611
+ if (argv[key] !== undefined) {
612
+ cliInputs[key] = argv[key]
613
+ }
614
+ }
578
615
 
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)}`
616
+ // Merge: CLI > file > defaults
617
+ const supplied: Record<string, any> = {}
618
+ for (const [key, def] of Object.entries(inputDefs)) {
619
+ if (cliInputs[key] !== undefined) {
620
+ supplied[key] = cliInputs[key]
621
+ } else if (fileInputs[key] !== undefined) {
622
+ supplied[key] = fileInputs[key]
623
+ } else if (def.default !== undefined) {
624
+ supplied[key] = def.default
587
625
  }
588
626
  }
589
627
 
590
- // No existing frontmatter — prepend one
591
- const newYaml = yaml.stringify(updates).trimEnd()
592
- return `---\n${newYaml}\n---\n\n${fileContent}`
628
+ // Find missing required inputs
629
+ const missing: string[] = []
630
+ for (const [key, def] of Object.entries(inputDefs)) {
631
+ const isRequired = def.required !== false // default to required
632
+ if (isRequired && supplied[key] === undefined) {
633
+ missing.push(key)
634
+ }
635
+ }
636
+
637
+ if (missing.length === 0) return supplied
638
+
639
+ // In parallel mode, we can't run an interactive wizard
640
+ if ((options as any).parallel) {
641
+ console.error(`Missing required inputs for parallel mode (use --inputs-file or CLI flags): ${missing.join(', ')}`)
642
+ process.exit(1)
643
+ }
644
+
645
+ // Build wizard questions for missing inputs
646
+ const questions = missing.map((key) => {
647
+ const def = inputDefs[key]
648
+ const q: Record<string, any> = {
649
+ name: key,
650
+ message: def.description || key,
651
+ }
652
+
653
+ // Auto-infer type
654
+ if (def.choices?.length) {
655
+ q.type = 'list'
656
+ q.choices = def.choices
657
+ } else if (def.type) {
658
+ q.type = def.type
659
+ } else {
660
+ q.type = 'input'
661
+ }
662
+
663
+ if (def.default !== undefined) {
664
+ q.default = def.default
665
+ }
666
+
667
+ return q
668
+ })
669
+
670
+ const answers = await ui.wizard(questions, supplied)
671
+ return { ...supplied, ...answers }
672
+ }
673
+
674
+ function substituteInputs(content: string, inputs: Record<string, any>): string {
675
+ return content.replace(/\{\{(\w+)\}\}/g, (match, key) => {
676
+ return inputs[key] !== undefined ? String(inputs[key]) : match
677
+ })
593
678
  }
594
679
 
595
- async function executePromptFile(resolvedPath: string, container: any): Promise<string> {
680
+ async function executePromptFile(resolvedPath: string, container: any, inputs?: Record<string, any>): Promise<string> {
596
681
  if (!container.docs.isLoaded) await container.docs.load()
597
682
  const doc = await container.docs.parseMarkdownAtPath(resolvedPath)
598
683
  const vm = container.feature('vm')
@@ -608,6 +693,7 @@ async function executePromptFile(resolvedPath: string, container: any): Promise<
608
693
 
609
694
  const shared = vm.createContext({
610
695
  ...container.context,
696
+ INPUTS: inputs || {},
611
697
  console: captureConsole,
612
698
  setTimeout, clearTimeout, setInterval, clearInterval,
613
699
  fetch, URL, URLSearchParams,
@@ -669,24 +755,40 @@ async function preparePrompt(
669
755
 
670
756
  let content = fs.readFile(resolvedPath) as string
671
757
 
672
- // Check repeatable gate
673
- if (!options['repeat-anyway'] && content.startsWith('---')) {
758
+ // Parse frontmatter for input definitions and agentOptions
759
+ let resolvedInputs: Record<string, any> = {}
760
+ let agentOptions: Record<string, any> = {}
761
+ let hasInputDefs = false
762
+ if (content.startsWith('---')) {
674
763
  const fmEnd = content.indexOf('\n---', 3)
675
764
  if (fmEnd !== -1) {
676
765
  const yaml = container.feature('yaml')
677
766
  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
767
+ const inputDefs = parseInputDefs(meta)
768
+ if (inputDefs) {
769
+ hasInputDefs = true
770
+ resolvedInputs = await resolveInputs(inputDefs, options, container)
771
+ }
772
+ if (meta.agentOptions && typeof meta.agentOptions === 'object') {
773
+ agentOptions = { ...meta.agentOptions }
681
774
  }
682
775
  }
683
776
  }
684
777
 
778
+ if (options['inputs-file'] && !hasInputDefs) {
779
+ console.warn(`Warning: --inputs-file was provided but ${filePath} does not define any inputs in its frontmatter`)
780
+ }
781
+
685
782
  let promptContent: string
686
783
  if (options['preserve-frontmatter']) {
687
784
  promptContent = content
688
785
  } else {
689
- promptContent = await executePromptFile(resolvedPath, container)
786
+ promptContent = await executePromptFile(resolvedPath, container, resolvedInputs)
787
+ }
788
+
789
+ // Substitute {{key}} placeholders with resolved input values
790
+ if (Object.keys(resolvedInputs).length) {
791
+ promptContent = substituteInputs(promptContent, resolvedInputs)
690
792
  }
691
793
 
692
794
  // Exclude sections by heading name
@@ -709,6 +811,7 @@ async function preparePrompt(
709
811
  resolvedPath,
710
812
  promptContent,
711
813
  filename: paths.basename(resolvedPath),
814
+ agentOptions,
712
815
  }
713
816
  }
714
817
 
@@ -751,6 +854,28 @@ export default async function prompt(options: z.infer<typeof argsSchema>, contex
751
854
  process.exit(1)
752
855
  }
753
856
 
857
+ if (options['dry-run']) {
858
+ const ui = container.feature('ui')
859
+ console.log(ui.colors.bold('\n── Dry Run (Parallel) ──\n'))
860
+ console.log(ui.colors.bold('Target:'), target)
861
+ console.log(ui.colors.bold('Prompts:'), prepared.length)
862
+ for (const p of prepared) {
863
+ console.log(ui.colors.bold(`\n── ${p.filename} ──`))
864
+ console.log(ui.colors.dim(` Path: ${p.resolvedPath}`))
865
+ console.log(ui.colors.dim(` Length: ${p.promptContent.length} chars`))
866
+ if (Object.keys(p.agentOptions).length) {
867
+ console.log(ui.colors.dim(' Agent options:'))
868
+ for (const [key, val] of Object.entries(p.agentOptions)) {
869
+ const display = typeof val === 'object' ? JSON.stringify(val) : val
870
+ console.log(ui.colors.dim(` ${key}: ${display}`))
871
+ }
872
+ }
873
+ console.log('')
874
+ process.stdout.write(ui.markdown(p.promptContent))
875
+ }
876
+ return
877
+ }
878
+
754
879
  if (prepared.length > 1) {
755
880
  await runParallel(target, prepared, options, container)
756
881
  return
@@ -767,28 +892,43 @@ export default async function prompt(options: z.infer<typeof argsSchema>, contex
767
892
  }
768
893
 
769
894
  const ui = container.feature('ui')
895
+
896
+ if (options['dry-run']) {
897
+ const runOptions: Record<string, any> = { ...p.agentOptions }
898
+ if (options.model) runOptions.model = options.model
899
+ if (options['in-folder']) runOptions.cwd = container.paths.resolve(options['in-folder'])
900
+ if (options['out-file']) runOptions.outFile = options['out-file']
901
+ if (options['include-output']) runOptions.includeOutput = true
902
+ if (options['exclude-sections']) runOptions.excludeSections = options['exclude-sections']
903
+ if (CLI_TARGETS.has(target)) {
904
+ runOptions.permissionMode = options['permission-mode']
905
+ if (options.chrome) runOptions.chrome = true
906
+ }
907
+
908
+ console.log(ui.colors.bold('\n── Dry Run ──\n'))
909
+ console.log(ui.colors.bold('Target:'), target)
910
+ console.log(ui.colors.bold('Prompt file:'), p.resolvedPath)
911
+ console.log(ui.colors.bold('Prompt length:'), `${p.promptContent.length} chars`)
912
+ if (Object.keys(runOptions).length) {
913
+ console.log(ui.colors.bold('Options:'))
914
+ for (const [key, val] of Object.entries(runOptions)) {
915
+ const display = typeof val === 'object' ? JSON.stringify(val) : val
916
+ console.log(` ${key}: ${display}`)
917
+ }
918
+ }
919
+ console.log(ui.colors.bold('\n── Prompt Content ──\n'))
920
+ process.stdout.write(ui.markdown(p.promptContent))
921
+ return
922
+ }
923
+
770
924
  process.stdout.write(ui.markdown(p.promptContent))
771
925
 
772
926
  let stats: RunStats
773
927
 
774
928
  if (CLI_TARGETS.has(target)) {
775
- stats = await runClaudeOrCodex(target as 'claude' | 'codex', p.promptContent, container, options)
929
+ stats = await runClaudeOrCodex(target as 'claude' | 'codex', p.promptContent, container, options, p.agentOptions)
776
930
  } 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)
931
+ stats = await runAssistant(target, p.promptContent, options, container, p.agentOptions)
792
932
  }
793
933
 
794
934
  if (options['out-file'] && stats.collectedEvents.length) {