@soederpop/luca 0.0.5 → 0.0.7

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.
Files changed (211) hide show
  1. package/CLAUDE.md +10 -1
  2. package/bun.lock +1 -1
  3. package/commands/build-bootstrap.ts +78 -0
  4. package/commands/build-scaffolds.ts +24 -2
  5. package/commands/try-all-challenges.ts +543 -0
  6. package/commands/try-challenge.ts +100 -0
  7. package/docs/README.md +52 -80
  8. package/docs/TABLE-OF-CONTENTS.md +82 -51
  9. package/docs/apis/clients/elevenlabs.md +232 -8
  10. package/docs/apis/clients/graph.md +59 -8
  11. package/docs/apis/clients/openai.md +362 -2
  12. package/docs/apis/clients/rest.md +122 -2
  13. package/docs/apis/clients/websocket.md +71 -17
  14. package/docs/apis/features/agi/assistant.md +9 -3
  15. package/docs/apis/features/agi/assistants-manager.md +2 -2
  16. package/docs/apis/features/agi/claude-code.md +153 -14
  17. package/docs/apis/features/agi/conversation-history.md +15 -3
  18. package/docs/apis/features/agi/conversation.md +133 -20
  19. package/docs/apis/features/agi/openai-codex.md +90 -12
  20. package/docs/apis/features/agi/skills-library.md +23 -5
  21. package/docs/apis/features/node/container-link.md +59 -0
  22. package/docs/apis/features/node/content-db.md +1 -1
  23. package/docs/apis/features/node/disk-cache.md +1 -1
  24. package/docs/apis/features/node/dns.md +1 -0
  25. package/docs/apis/features/node/docker.md +2 -1
  26. package/docs/apis/features/node/esbuild.md +4 -3
  27. package/docs/apis/features/node/file-manager.md +13 -4
  28. package/docs/apis/features/node/fs.md +726 -171
  29. package/docs/apis/features/node/git.md +1 -0
  30. package/docs/apis/features/node/google-auth.md +23 -4
  31. package/docs/apis/features/node/google-calendar.md +14 -2
  32. package/docs/apis/features/node/google-docs.md +15 -2
  33. package/docs/apis/features/node/google-drive.md +21 -3
  34. package/docs/apis/features/node/google-sheets.md +14 -2
  35. package/docs/apis/features/node/grep.md +2 -0
  36. package/docs/apis/features/node/helpers.md +29 -0
  37. package/docs/apis/features/node/ink.md +2 -2
  38. package/docs/apis/features/node/networking.md +39 -4
  39. package/docs/apis/features/node/os.md +28 -0
  40. package/docs/apis/features/node/postgres.md +26 -4
  41. package/docs/apis/features/node/proc.md +37 -28
  42. package/docs/apis/features/node/process-manager.md +33 -5
  43. package/docs/apis/features/node/repl.md +1 -1
  44. package/docs/apis/features/node/runpod.md +1 -0
  45. package/docs/apis/features/node/secure-shell.md +7 -0
  46. package/docs/apis/features/node/semantic-search.md +12 -5
  47. package/docs/apis/features/node/sqlite.md +26 -4
  48. package/docs/apis/features/node/telegram.md +30 -5
  49. package/docs/apis/features/node/tts.md +17 -2
  50. package/docs/apis/features/node/ui.md +1 -1
  51. package/docs/apis/features/node/vault.md +4 -9
  52. package/docs/apis/features/node/vm.md +3 -12
  53. package/docs/apis/features/node/window-manager.md +128 -20
  54. package/docs/apis/features/web/asset-loader.md +13 -1
  55. package/docs/apis/features/web/container-link.md +59 -0
  56. package/docs/apis/features/web/esbuild.md +4 -3
  57. package/docs/apis/features/web/helpers.md +29 -0
  58. package/docs/apis/features/web/network.md +16 -2
  59. package/docs/apis/features/web/speech.md +16 -2
  60. package/docs/apis/features/web/vault.md +4 -9
  61. package/docs/apis/features/web/vm.md +3 -12
  62. package/docs/apis/features/web/voice.md +18 -1
  63. package/docs/apis/servers/express.md +18 -2
  64. package/docs/apis/servers/mcp.md +29 -4
  65. package/docs/apis/servers/websocket.md +34 -6
  66. package/docs/bootstrap/CLAUDE.md +100 -0
  67. package/docs/bootstrap/SKILL.md +222 -0
  68. package/docs/bootstrap/templates/about-command.ts +41 -0
  69. package/docs/bootstrap/templates/docs-models.ts +22 -0
  70. package/docs/bootstrap/templates/docs-readme.md +43 -0
  71. package/docs/bootstrap/templates/example-feature.ts +53 -0
  72. package/docs/bootstrap/templates/health-endpoint.ts +15 -0
  73. package/docs/bootstrap/templates/luca-cli.ts +25 -0
  74. package/docs/challenges/caching-proxy.md +16 -0
  75. package/docs/challenges/content-db-round-trip.md +14 -0
  76. package/docs/challenges/custom-command.md +9 -0
  77. package/docs/challenges/file-watcher-pipeline.md +11 -0
  78. package/docs/challenges/grep-audit-report.md +15 -0
  79. package/docs/challenges/multi-feature-dashboard.md +14 -0
  80. package/docs/challenges/process-orchestrator.md +17 -0
  81. package/docs/challenges/rest-api-server-with-client.md +12 -0
  82. package/docs/challenges/script-runner-with-vm.md +11 -0
  83. package/docs/challenges/simple-rest-api.md +15 -0
  84. package/docs/challenges/websocket-serve-and-client.md +11 -0
  85. package/docs/challenges/yaml-config-system.md +14 -0
  86. package/docs/command-system-overhaul.md +94 -0
  87. package/docs/examples/assistant/CORE.md +18 -0
  88. package/docs/examples/assistant/hooks.ts +3 -0
  89. package/docs/examples/assistant/tools.ts +10 -0
  90. package/docs/examples/window-manager-layouts.md +180 -0
  91. package/docs/in-memory-fs.md +4 -0
  92. package/docs/models.ts +13 -10
  93. package/docs/philosophy.md +4 -3
  94. package/docs/reports/console-hmr-design.md +170 -0
  95. package/docs/reports/helper-semantic-search.md +72 -0
  96. package/docs/scaffolds/client.md +29 -20
  97. package/docs/scaffolds/command.md +64 -50
  98. package/docs/scaffolds/endpoint.md +31 -36
  99. package/docs/scaffolds/feature.md +28 -18
  100. package/docs/scaffolds/selector.md +91 -0
  101. package/docs/scaffolds/server.md +18 -9
  102. package/docs/selectors.md +115 -0
  103. package/docs/sessions/custom-command/attempt-log-2.md +195 -0
  104. package/docs/sessions/file-watcher-pipeline/attempt-log-1.md +728 -0
  105. package/docs/sessions/file-watcher-pipeline/attempt-log-2.md +555 -0
  106. package/docs/sessions/grep-audit-report/attempt-log-1.md +289 -0
  107. package/docs/sessions/multi-feature-dashboard/attempt-log-2.md +679 -0
  108. package/docs/sessions/rest-api-server-with-client/attempt-log-1.md +1 -0
  109. package/docs/sessions/rest-api-server-with-client/attempt-log-3.md +920 -0
  110. package/docs/sessions/simple-rest-api/attempt-log-1.md +593 -0
  111. package/docs/sessions/websocket-serve-and-client/attempt-log-2.md +995 -0
  112. package/docs/tutorials/00-bootstrap.md +148 -0
  113. package/docs/tutorials/07-endpoints.md +7 -7
  114. package/docs/tutorials/08-commands.md +153 -72
  115. package/luca.cli.ts +3 -0
  116. package/package.json +6 -5
  117. package/public/index.html +1430 -0
  118. package/scripts/examples/using-ollama.ts +2 -1
  119. package/scripts/update-introspection-data.ts +2 -2
  120. package/src/agi/endpoints/experts.ts +1 -1
  121. package/src/agi/features/assistant.ts +7 -0
  122. package/src/agi/features/assistants-manager.ts +5 -5
  123. package/src/agi/features/claude-code.ts +263 -3
  124. package/src/agi/features/conversation-history.ts +7 -1
  125. package/src/agi/features/conversation.ts +26 -3
  126. package/src/agi/features/openai-codex.ts +26 -2
  127. package/src/agi/features/openapi.ts +6 -1
  128. package/src/agi/features/skills-library.ts +9 -1
  129. package/src/bootstrap/generated.ts +540 -0
  130. package/src/cli/cli.ts +64 -21
  131. package/src/client.ts +23 -357
  132. package/src/clients/civitai/index.ts +1 -1
  133. package/src/clients/client-template.ts +1 -1
  134. package/src/clients/comfyui/index.ts +13 -2
  135. package/src/clients/elevenlabs/index.ts +2 -1
  136. package/src/clients/graph.ts +87 -0
  137. package/src/clients/openai/index.ts +10 -1
  138. package/src/clients/rest.ts +207 -0
  139. package/src/clients/websocket.ts +176 -0
  140. package/src/command.ts +281 -34
  141. package/src/commands/bootstrap.ts +181 -0
  142. package/src/commands/chat.ts +5 -4
  143. package/src/commands/describe.ts +225 -2
  144. package/src/commands/help.ts +35 -9
  145. package/src/commands/index.ts +3 -0
  146. package/src/commands/introspect.ts +92 -2
  147. package/src/commands/prompt.ts +5 -6
  148. package/src/commands/run.ts +33 -10
  149. package/src/commands/save-api-docs.ts +49 -0
  150. package/src/commands/scaffold.ts +169 -23
  151. package/src/commands/select.ts +94 -0
  152. package/src/commands/serve.ts +10 -1
  153. package/src/container.ts +15 -0
  154. package/src/endpoint.ts +19 -0
  155. package/src/graft.ts +181 -0
  156. package/src/introspection/generated.agi.ts +12458 -8968
  157. package/src/introspection/generated.node.ts +10573 -7145
  158. package/src/introspection/generated.web.ts +1 -1
  159. package/src/introspection/index.ts +26 -0
  160. package/src/node/container.ts +6 -7
  161. package/src/node/features/content-db.ts +49 -2
  162. package/src/node/features/disk-cache.ts +16 -9
  163. package/src/node/features/dns.ts +16 -3
  164. package/src/node/features/docker.ts +16 -4
  165. package/src/node/features/esbuild.ts +20 -0
  166. package/src/node/features/file-manager.ts +184 -29
  167. package/src/node/features/fs.ts +704 -248
  168. package/src/node/features/git.ts +21 -8
  169. package/src/node/features/grep.ts +23 -3
  170. package/src/node/features/helpers.ts +372 -43
  171. package/src/node/features/networking.ts +39 -4
  172. package/src/node/features/opener.ts +28 -15
  173. package/src/node/features/os.ts +76 -0
  174. package/src/node/features/port-exposer.ts +11 -1
  175. package/src/node/features/postgres.ts +17 -1
  176. package/src/node/features/proc.ts +4 -1
  177. package/src/node/features/python.ts +63 -14
  178. package/src/node/features/repl.ts +11 -7
  179. package/src/node/features/runpod.ts +16 -3
  180. package/src/node/features/secure-shell.ts +27 -2
  181. package/src/node/features/semantic-search.ts +12 -1
  182. package/src/node/features/ui.ts +5 -69
  183. package/src/node/features/vm.ts +17 -0
  184. package/src/node/features/window-manager.ts +68 -20
  185. package/src/node.ts +5 -0
  186. package/src/scaffolds/generated.ts +492 -290
  187. package/src/scaffolds/template.ts +9 -0
  188. package/src/schemas/base.ts +46 -5
  189. package/src/selector.ts +282 -0
  190. package/src/server.ts +11 -0
  191. package/src/servers/express.ts +27 -12
  192. package/src/servers/socket.ts +45 -11
  193. package/src/web/clients/socket.ts +4 -1
  194. package/src/web/container.ts +2 -1
  195. package/src/web/features/network.ts +7 -1
  196. package/src/web/features/voice-recognition.ts +16 -1
  197. package/test/clients-servers.test.ts +2 -1
  198. package/test/command.test.ts +267 -0
  199. package/test-integration/assistants-manager.test.ts +10 -20
  200. package/tmp/.cache/luca-disk-cache/content-v2/sha512/1b/b5/c75b28794f00f94c4d609a98978e9420e9b7146d204a7fbf5b0b30477292581705d207c0100dabaac27eef540aaaece3374af75104a93219d4ec8bfb44e7 +1 -0
  201. package/tmp/.cache/luca-disk-cache/content-v2/sha512/da/df/1d90ce4e042abeb035a197832c6d6893420a747a056be773eb00e4f745a037d505c8db13dde7d36b36b6b893addbb7df0f5fe9f0c13e665f20056447318b +1 -0
  202. package/tmp/.cache/luca-disk-cache/content-v2/sha512/ed/04/e1d0c2a58c2db29b3921ca2affb3ea4febe831c53b38ebc21019fb799823aba6ed5b4611873d2cd25d422d49955b852a9c326da0d678899bc1c2c2960901 +1 -0
  203. package/tmp/.cache/luca-disk-cache/index-v5/00/13/572aa4c9a94f99eda999695d050cdd0ca7fe2d23a50af03234d4c8ce0791 +2 -0
  204. package/tmp/.cache/luca-disk-cache/index-v5/75/a9/cb61dc0f0589e8ec10a9aca27b834bc73884c479941042d22a2b22324cd3 +2 -0
  205. package/tmp/.cache/luca-disk-cache/index-v5/9f/0f/8b1f915ee64cfff7667dd96acd7a5ac0a96aa91a346e19cefd45909a9c9c +2 -0
  206. package/docs/apis/features/node/launcher-app-command-listener.md +0 -145
  207. package/docs/examples/launcher-app-command-listener.md +0 -120
  208. package/docs/tasks/web-container-helper-discovery.md +0 -71
  209. package/docs/todos.md +0 -1
  210. package/scripts/test-command-listener.ts +0 -123
  211. package/src/node/features/launcher-app-command-listener.ts +0 -389
package/src/command.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import { Helper } from './helper.js'
2
2
  import type { Container, ContainerContext } from './container.js'
3
3
  import { Registry } from './registry.js'
4
- import { CommandStateSchema, CommandOptionsSchema, CommandEventsSchema } from './schemas/base.js'
4
+ import { CommandStateSchema, CommandOptionsSchema, CommandEventsSchema, type DispatchSource, type CommandRunResult } from './schemas/base.js'
5
5
  import { z } from 'zod'
6
6
  import { join } from 'path'
7
+ import { graftModule, isNativeHelperClass } from './graft.js'
8
+
9
+ export type { DispatchSource, CommandRunResult }
7
10
 
8
11
  export type CommandState = z.infer<typeof CommandStateSchema>
9
12
  export type CommandOptions = z.infer<typeof CommandOptionsSchema>
@@ -22,6 +25,24 @@ export interface CommandsInterface {
22
25
 
23
26
  export type CommandHandler<T = any> = (options: T, context: ContainerContext) => Promise<void>
24
27
 
28
+ /**
29
+ * Type helper for module-augmentation of AvailableCommands when using the
30
+ * SimpleCommand (module-based) pattern instead of a full class.
31
+ *
32
+ * @example
33
+ * ```typescript
34
+ * declare module '@soederpop/luca' {
35
+ * interface AvailableCommands {
36
+ * serve: SimpleCommand<typeof argsSchema>
37
+ * }
38
+ * }
39
+ * ```
40
+ */
41
+ export type SimpleCommand<Schema extends z.ZodType = z.ZodType> = typeof Command & {
42
+ argsSchema: Schema
43
+ positionals: string[]
44
+ }
45
+
25
46
  export class Command<
26
47
  T extends CommandState = CommandState,
27
48
  K extends CommandOptions = CommandOptions
@@ -34,35 +55,73 @@ export class Command<
34
55
 
35
56
  static commandDescription: string = ''
36
57
  static argsSchema: z.ZodType = CommandOptionsSchema
58
+ static positionals: string[] = []
59
+
60
+ /** Self-register a Command subclass from a static initialization block. */
61
+ static register: (SubClass: typeof Command, id?: string) => typeof Command
37
62
 
38
63
  override get initialState(): T {
39
64
  return ({ running: false } as unknown) as T
40
65
  }
41
66
 
67
+ /**
68
+ * Parse raw CLI argv against this command's argsSchema.
69
+ * Kept for backward compatibility with built-in commands.
70
+ */
42
71
  parseArgs(): any {
43
72
  const schema = (this.constructor as typeof Command).argsSchema
44
73
  return schema.parse(this.container.options)
45
74
  }
46
75
 
47
- async execute(): Promise<void> {
48
- // override in subclass
76
+ /**
77
+ * The user-defined command payload. Override this in class-based commands,
78
+ * or export a `run` function in module-based commands.
79
+ *
80
+ * Receives clean, named args (positionals already mapped) and the container context.
81
+ */
82
+ async run(_args: any, _context: ContainerContext): Promise<void> {
83
+ // override in subclass or grafted from module export
49
84
  }
50
85
 
51
- async run(): Promise<void> {
52
- // Intercept --help before the command executes
53
- if (this.container.argv.help) {
86
+ /**
87
+ * Internal dispatch normalizer. Maps positionals for CLI, passes through named
88
+ * args for headless, validates via argsSchema, captures output when headless.
89
+ *
90
+ * @param rawInput - Named args object. When omitted, reads from container.argv.
91
+ * @param source - Override the dispatch source. Defaults to constructor option.
92
+ */
93
+ async execute(rawInput?: Record<string, any>, source?: DispatchSource): Promise<CommandRunResult | void> {
94
+ const dispatchSource = source ?? (this._options as any).dispatchSource ?? 'cli'
95
+ const Cls = this.constructor as typeof Command
96
+
97
+ // Help intercept (CLI only) — must happen before schema validation
98
+ // so `luca somecommand --help` works even without required args
99
+ if (dispatchSource === 'cli' && (this.container as any).argv?.help) {
54
100
  const { formatCommandHelp } = await import('./commands/help.js')
55
101
  const ui = (this.container as any).feature('ui')
56
- const name = (this.constructor as typeof Command).shortcut?.replace('commands.', '') || 'unknown'
102
+ const name = Cls.shortcut?.replace('commands.', '') || 'unknown'
57
103
  console.log(formatCommandHelp(name, this.constructor, ui.colors))
58
104
  return
59
105
  }
60
106
 
107
+ // Build named args from raw input
108
+ const raw = rawInput ?? { ...(this.container as any).argv }
109
+ const named = this._normalizeInput(raw, dispatchSource)
110
+
111
+ // Validate against argsSchema
112
+ const parsed = Cls.argsSchema.parse(named)
113
+
114
+ // For headless dispatch, capture stdout/stderr
115
+ if (dispatchSource !== 'cli') {
116
+ return this._runCaptured(parsed)
117
+ }
118
+
119
+ // CLI path — lifecycle events + run
61
120
  this.state.set('running', true)
62
121
  this.emit('started')
63
122
 
64
123
  try {
65
- await this.execute()
124
+ await this.run(parsed, this.context)
66
125
  this.state.set('running', false)
67
126
  this.state.set('exitCode', 0)
68
127
  this.emit('completed', 0)
@@ -74,6 +133,109 @@ export class Command<
74
133
  }
75
134
  }
76
135
 
136
+ /**
137
+ * The public entry point for dispatching a command.
138
+ * Called by the CLI and other dispatch surfaces.
139
+ */
140
+ async dispatch(rawInput?: Record<string, any>, source?: DispatchSource): Promise<CommandRunResult | void> {
141
+ return this.execute(rawInput, source)
142
+ }
143
+
144
+ /**
145
+ * Map CLI positional args to named fields based on the command's positionals declaration.
146
+ * For non-CLI dispatch, args are already named — pass through.
147
+ */
148
+ private _normalizeInput(raw: Record<string, any>, source: DispatchSource): Record<string, any> {
149
+ if (source !== 'cli') return raw
150
+
151
+ const Cls = this.constructor as typeof Command
152
+ const positionals = Cls.positionals
153
+ if (!positionals.length) return raw
154
+
155
+ const result = { ...raw }
156
+
157
+ // Map raw._[1], raw._[2], etc. (skipping _[0] which is command name) to named fields
158
+ const posArgs: string[] = (raw._ || []).slice(1)
159
+ for (let i = 0; i < positionals.length; i++) {
160
+ const name = positionals[i]!
161
+ if (result[name] !== undefined) continue
162
+ if (posArgs[i] === undefined) continue
163
+
164
+ // Last positional collects all remaining args if the schema expects an array
165
+ if (i === positionals.length - 1 && posArgs.length > positionals.length) {
166
+ const isArray = this._schemaExpectsArray(Cls.argsSchema, name)
167
+ if (isArray) {
168
+ result[name] = posArgs.slice(i)
169
+ continue
170
+ }
171
+ }
172
+
173
+ result[name] = posArgs[i]
174
+ }
175
+
176
+ return result
177
+ }
178
+
179
+ /**
180
+ * Check whether a Zod schema expects an array type for a given field.
181
+ * Unwraps ZodObject → ZodOptional/ZodDefault/ZodNullable → ZodArray.
182
+ */
183
+ private _schemaExpectsArray(schema: z.ZodType, field: string): boolean {
184
+ try {
185
+ const shape = typeof (schema as any)?._def?.shape === 'function'
186
+ ? (schema as any)._def.shape()
187
+ : (schema as any)?._def?.shape
188
+ if (!shape || !shape[field]) return false
189
+ let inner = shape[field]
190
+ // Unwrap wrappers (optional, default, nullable)
191
+ while (inner) {
192
+ if (inner instanceof z.ZodArray) return true
193
+ if (inner._def?.innerType) { inner = inner._def.innerType; continue }
194
+ if (inner._def?.schema) { inner = inner._def.schema; continue }
195
+ break
196
+ }
197
+ return false
198
+ } catch {
199
+ return false
200
+ }
201
+ }
202
+
203
+ /**
204
+ * Run the command with stdout/stderr capture for headless dispatch.
205
+ * Returns a CommandRunResult with captured output.
206
+ *
207
+ * NOTE: Uses global console mutation — not safe for concurrent headless dispatches.
208
+ * A future iteration should pass a logger through context instead.
209
+ */
210
+ private async _runCaptured(args: any): Promise<CommandRunResult> {
211
+ const captured = { stdout: '', stderr: '' }
212
+ const origLog = console.log.bind(console)
213
+ const origErr = console.error.bind(console)
214
+
215
+ console.log = (...a: any[]) => { captured.stdout += a.join(' ') + '\n' }
216
+ console.error = (...a: any[]) => { captured.stderr += a.join(' ') + '\n' }
217
+
218
+ let exitCode = 0
219
+ try {
220
+ this.state.set('running', true)
221
+ this.emit('started')
222
+ await this.run(args, this.context)
223
+ this.state.set('exitCode', 0)
224
+ this.emit('completed', 0)
225
+ } catch (err: any) {
226
+ exitCode = 1
227
+ this.state.set('exitCode', 1)
228
+ this.emit('failed', err)
229
+ captured.stderr += (err?.message || String(err)) + '\n'
230
+ } finally {
231
+ console.log = origLog
232
+ console.error = origErr
233
+ this.state.set('running', false)
234
+ }
235
+
236
+ return { exitCode, stdout: captured.stdout, stderr: captured.stderr }
237
+ }
238
+
77
239
  static attach(container: Container<any> & CommandsInterface) {
78
240
  container.commands = commands
79
241
 
@@ -104,6 +266,10 @@ export class CommandsRegistry extends Registry<Command<any>> {
104
266
  override scope = 'commands'
105
267
  override baseClass = Command as any
106
268
 
269
+ /**
270
+ * Internal: register a command from a handler function.
271
+ * Used by built-in commands. External commands should use the module export pattern.
272
+ */
107
273
  registerHandler<T = any>(
108
274
  name: string,
109
275
  opts: {
@@ -112,27 +278,27 @@ export class CommandsRegistry extends Registry<Command<any>> {
112
278
  handler: CommandHandler<T>
113
279
  },
114
280
  ) {
115
- const handler = opts.handler
116
- const argsSchema = opts.argsSchema || CommandOptionsSchema
117
- const desc = opts.description || ''
118
-
119
- const CommandClass = class extends Command {
120
- static override shortcut = `commands.${name}` as const
121
- static override description = desc
122
- static override commandDescription = desc
123
- static override optionsSchema = argsSchema as any
124
- static override argsSchema = argsSchema
125
-
126
- override async execute() {
127
- await handler(this.parseArgs(), this.context)
128
- }
129
- }
130
-
131
- Object.defineProperty(CommandClass, 'name', { value: `${name}Command` })
281
+ const CommandClass = graftModule(
282
+ Command as any,
283
+ {
284
+ description: opts.description,
285
+ argsSchema: opts.argsSchema,
286
+ handler: opts.handler,
287
+ },
288
+ name,
289
+ 'commands',
290
+ ) as any
132
291
 
133
- return this.register(name, CommandClass as any)
292
+ return this.register(name, CommandClass)
134
293
  }
135
294
 
295
+ /**
296
+ * Discover and register commands from a directory.
297
+ * Detection order:
298
+ * 1. Default export is a class extending Command → register directly
299
+ * 2. Module exports a `run` function → graft as SimpleCommand
300
+ * 3. Module exports a `handler` function → legacy graft
301
+ */
136
302
  async discover(options: { directory: string }) {
137
303
  const { Glob } = globalThis.Bun || (await import('bun'))
138
304
  const glob = new Glob('*.ts')
@@ -140,19 +306,53 @@ export class CommandsRegistry extends Registry<Command<any>> {
140
306
  for await (const file of glob.scan({ cwd: options.directory })) {
141
307
  if (file === 'index.ts') continue
142
308
 
309
+ const name = file.replace(/\.ts$/, '')
310
+ if (this.has(name)) continue
311
+
143
312
  const mod = await import(join(options.directory, file))
313
+
314
+ // 1. Class-based: default export extends Command
315
+ if (isNativeHelperClass(mod.default, Command)) {
316
+ const ExportedClass = mod.default
317
+ if (!ExportedClass.shortcut || ExportedClass.shortcut === 'commands.base') {
318
+ ExportedClass.shortcut = `commands.${name}`
319
+ }
320
+ if (!ExportedClass.commandDescription && ExportedClass.description) {
321
+ ExportedClass.commandDescription = ExportedClass.description
322
+ }
323
+ this.register(name, ExportedClass)
324
+ continue
325
+ }
326
+
144
327
  const commandModule = mod.default || mod
145
328
 
146
- // Support export-based command files (like endpoints).
147
- // If the module exports a handler function, register it
148
- // using the filename as the command name.
149
- if (typeof commandModule.handler === 'function' && !this.has(file.replace(/\.ts$/, ''))) {
150
- const name = file.replace(/\.ts$/, '')
151
- this.registerHandler(name, {
152
- description: commandModule.description || '',
329
+ // 2. Module-based with `run` export (new SimpleCommand pattern)
330
+ if (typeof commandModule.run === 'function') {
331
+ const Grafted = graftModule(Command as any, commandModule, name, 'commands')
332
+ this.register(name, Grafted as any)
333
+ continue
334
+ }
335
+
336
+ // 3. Legacy: `handler` export
337
+ if (typeof commandModule.handler === 'function') {
338
+ const Grafted = graftModule(Command as any, {
339
+ description: commandModule.description,
153
340
  argsSchema: commandModule.argsSchema,
154
341
  handler: commandModule.handler,
155
- })
342
+ }, name, 'commands')
343
+ this.register(name, Grafted as any)
344
+ continue
345
+ }
346
+
347
+ // 4. Plain default-exported function: export default async function name(options, context)
348
+ if (typeof mod.default === 'function' && !isNativeHelperClass(mod.default, Command)) {
349
+ const Grafted = graftModule(Command as any, {
350
+ description: mod.description || '',
351
+ argsSchema: mod.argsSchema,
352
+ positionals: mod.positionals,
353
+ handler: mod.default,
354
+ }, name, 'commands')
355
+ this.register(name, Grafted as any)
156
356
  }
157
357
  }
158
358
  }
@@ -161,4 +361,51 @@ export class CommandsRegistry extends Registry<Command<any>> {
161
361
  export const commands = new CommandsRegistry()
162
362
  export const helperCache = new Map()
163
363
 
364
+ /**
365
+ * Self-register a Command subclass from a static initialization block.
366
+ * Mirrors Feature.register / Client.register / Server.register pattern.
367
+ *
368
+ * @example
369
+ * ```typescript
370
+ * export class DeployCommand extends Command {
371
+ * static override description = 'Deploy to production'
372
+ * static override argsSchema = deployArgsSchema
373
+ * static override positionals = ['environment']
374
+ * static { Command.register(this, 'deploy') }
375
+ *
376
+ * override async run(args, context) { ... }
377
+ * }
378
+ * ```
379
+ */
380
+ Command.register = function registerCommand(
381
+ SubClass: typeof Command,
382
+ id?: string,
383
+ ) {
384
+ const registryId = id ?? (SubClass.name
385
+ ? SubClass.name[0]!.toLowerCase() + SubClass.name.slice(1).replace(/Command$/, '')
386
+ : `command_${Date.now()}`)
387
+
388
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'shortcut')?.value ||
389
+ (SubClass as any).shortcut === 'commands.base') {
390
+ ;(SubClass as any).shortcut = `commands.${registryId}`
391
+ }
392
+
393
+ if (!(SubClass as any).commandDescription && (SubClass as any).description) {
394
+ ;(SubClass as any).commandDescription = (SubClass as any).description
395
+ }
396
+
397
+ commands.register(registryId, SubClass as any)
398
+
399
+ if (!Object.getOwnPropertyDescriptor(SubClass, 'attach')) {
400
+ ;(SubClass as any).attach = (container: any) => {
401
+ commands.register(registryId, SubClass as any)
402
+ return container
403
+ }
404
+ }
405
+
406
+ return SubClass
407
+ }
408
+
409
+ export { graftModule, isNativeHelperClass } from './graft.js'
410
+
164
411
  export default Command
@@ -0,0 +1,181 @@
1
+ import { z } from 'zod'
2
+ import { commands } from '../command.js'
3
+ import { CommandOptionsSchema } from '../schemas/base.js'
4
+ import type { ContainerContext } from '../container.js'
5
+ import { bootstrapFiles, bootstrapTemplates } from '../bootstrap/generated.js'
6
+ import { apiDocs } from './save-api-docs.js'
7
+ import { generateScaffold } from '../scaffolds/template.js'
8
+
9
+ declare module '../command.js' {
10
+ interface AvailableCommands {
11
+ bootstrap: ReturnType<typeof commands.registerHandler>
12
+ }
13
+ }
14
+
15
+ export const argsSchema = CommandOptionsSchema.extend({
16
+ output: z.string().default('.').describe('Output folder path (defaults to cwd)'),
17
+ })
18
+
19
+ async function bootstrap(options: z.infer<typeof argsSchema>, context: ContainerContext) {
20
+ const { container } = context
21
+ const args = container.argv._ as string[]
22
+ const outputDir = container.paths.resolve(args[1] || options.output)
23
+ const fs = container.feature('fs')
24
+ const ui = container.feature('ui')
25
+ const proc = container.feature('proc')
26
+
27
+ await fs.ensureFolder(outputDir)
28
+ const mkPath = (...segments: string[]) => container.paths.resolve(outputDir, ...segments)
29
+
30
+ ui.print.cyan('\n luca bootstrap\n')
31
+
32
+ // ── Check for AI coding tools ──────────────────────────────────
33
+ await checkToolAvailability(ui, proc)
34
+
35
+ // ── 1. .env (only if missing) ──────────────────────────────────
36
+ const envPath = mkPath('.env')
37
+ if (!fs.exists(envPath)) {
38
+ await writeFile(fs, ui, envPath, '', '.env')
39
+ } else {
40
+ ui.print.dim(' .env already exists, skipping')
41
+ }
42
+
43
+ // ── 2. CLAUDE.md ───────────────────────────────────────────────
44
+ await writeFile(fs, ui, mkPath('CLAUDE.md'), bootstrapFiles['CLAUDE'] || '', 'CLAUDE.md')
45
+
46
+ // ── 3. .claude/skills/luca-framework/ ──────────────────────────
47
+ const skillDir = mkPath('.claude', 'skills', 'luca-framework')
48
+ await fs.ensureFolder(skillDir)
49
+ await writeFile(fs, ui, container.paths.resolve(skillDir, 'SKILL.md'), bootstrapFiles['SKILL'] || '', '.claude/skills/luca-framework/SKILL.md')
50
+
51
+ ui.print.cyan(' Generating API docs...')
52
+ const apiDocsPath = container.paths.resolve(skillDir, 'references', 'api-docs')
53
+ await apiDocs({ _: [], outputPath: apiDocsPath }, context)
54
+
55
+ // ── 4. docs/ folder ────────────────────────────────────────────
56
+ await fs.ensureFolder(mkPath('docs'))
57
+ await writeFile(fs, ui, mkPath('docs', 'models.ts'), bootstrapTemplates['docs-models'] || '', 'docs/models.ts')
58
+ await writeFile(fs, ui, mkPath('docs', 'README.md'), bootstrapTemplates['docs-readme'] || '', 'docs/README.md')
59
+
60
+ // ── 5. commands/about.ts ────────────────────────────────────────
61
+ await fs.ensureFolder(mkPath('commands'))
62
+ await writeFile(fs, ui, mkPath('commands', 'about.ts'), bootstrapTemplates['about-command'] || '', 'commands/about.ts')
63
+
64
+ // ── 6. features/example.ts (scaffold-based) ────────────────────
65
+ await fs.ensureFolder(mkPath('features'))
66
+ const featureCode = generateScaffold('feature', 'example', 'An example feature demonstrating the luca feature pattern')
67
+ || bootstrapTemplates['example-feature'] || ''
68
+ await writeFile(fs, ui, mkPath('features', 'example.ts'), featureCode, 'features/example.ts')
69
+
70
+ // ── 7. endpoints/health.ts ─────────────────────────────────────
71
+ await fs.ensureFolder(mkPath('endpoints'))
72
+ await writeFile(fs, ui, mkPath('endpoints', 'health.ts'), bootstrapTemplates['health-endpoint'] || '', 'endpoints/health.ts')
73
+
74
+ // ── 8. luca.cli.ts ─────────────────────────────────────────────
75
+ await writeFile(fs, ui, mkPath('luca.cli.ts'), bootstrapTemplates['luca-cli'] || '', 'luca.cli.ts')
76
+
77
+ // ── 9. .claude/settings.json (permissions for AI coding tools) ──
78
+ const settingsPath = mkPath('.claude', 'settings.json')
79
+ const claudeSettings = {
80
+ permissions: {
81
+ allow: [
82
+ 'Bash(luca *)',
83
+ 'Bash(bun run *)',
84
+ 'Bash(bun test *)',
85
+ ],
86
+ },
87
+ }
88
+
89
+ if (!fs.exists(settingsPath)) {
90
+ await fs.ensureFolder(mkPath('.claude'))
91
+ await writeFile(fs, ui, settingsPath, JSON.stringify(claudeSettings, null, 2) + '\n', '.claude/settings.json')
92
+ } else {
93
+ // Merge luca permissions into existing settings
94
+ try {
95
+ const existing = JSON.parse(fs.readFile(settingsPath) as string)
96
+ const perms = existing.permissions || {}
97
+ const allow = new Set(perms.allow || [])
98
+ for (const rule of claudeSettings.permissions.allow) {
99
+ allow.add(rule)
100
+ }
101
+ existing.permissions = { ...perms, allow: [...allow] }
102
+ await writeFile(fs, ui, settingsPath, JSON.stringify(existing, null, 2) + '\n', '.claude/settings.json (merged)')
103
+ } catch {
104
+ ui.print.yellow(' ⚠ Could not parse existing .claude/settings.json, skipping merge')
105
+ }
106
+ }
107
+
108
+ // ── Summary ────────────────────────────────────────────────────
109
+ ui.print('')
110
+ ui.print.green(' ✓ Bootstrap complete!\n')
111
+ ui.print(' Your project is ready. Here\'s what to try:\n')
112
+ ui.print(' luca — see available commands')
113
+ ui.print(' luca about — project info + discovered helpers')
114
+ ui.print(' luca serve — start the API server (try /api/health)')
115
+ ui.print(' luca describe fs — learn about any built-in feature')
116
+ ui.print('')
117
+ ui.print(' Need to build something? Use scaffold:\n')
118
+ ui.print(' luca scaffold command deploy — add a CLI command')
119
+ ui.print(' luca scaffold feature cache — add a container feature')
120
+ ui.print(' luca scaffold endpoint users — add a REST route')
121
+ ui.print(' luca scaffold client github — add an API client')
122
+ ui.print(' luca scaffold server mqtt — add a server')
123
+ ui.print('')
124
+ ui.print.dim(' Run luca scaffold <type> --tutorial for a full guide on any type')
125
+ ui.print('')
126
+ }
127
+
128
+ // ── Helpers ──────────────────────────────────────────────────────────
129
+
130
+ async function writeFile(fs: any, ui: any, path: string, content: string, label: string) {
131
+ ui.print.cyan(` Writing ${label}...`)
132
+ await fs.writeFileAsync(path, content)
133
+ }
134
+
135
+ async function checkToolAvailability(ui: any, proc: any) {
136
+ const tools: { name: string; found: boolean; envKey?: string; envFound?: boolean }[] = []
137
+
138
+ for (const name of ['claude', 'codex']) {
139
+ let found = false
140
+ try {
141
+ const result = await proc.exec(`which ${name}`, { silent: true })
142
+ found = result.exitCode === 0 && result.stdout.trim().length > 0
143
+ } catch {
144
+ found = false
145
+ }
146
+ tools.push({ name, found })
147
+ }
148
+
149
+ const openaiKey = !!process.env.OPENAI_API_KEY
150
+ const hasAnyCodingTool = tools.some(t => t.found)
151
+
152
+ if (!hasAnyCodingTool) {
153
+ ui.print.yellow(' ┌─────────────────────────────────────────────────────────────┐')
154
+ ui.print.yellow(' │ No AI coding assistant detected (claude, codex) │')
155
+ ui.print.yellow(' │ │')
156
+ ui.print.yellow(' │ Luca works best with an AI coding assistant. │')
157
+ ui.print.yellow(' │ │')
158
+ ui.print.yellow(' │ Claude Code: https://docs.anthropic.com/en/docs/claude-code│')
159
+ ui.print.yellow(' │ Codex CLI: https://github.com/openai/codex │')
160
+ ui.print.yellow(' └─────────────────────────────────────────────────────────────┘')
161
+ ui.print('')
162
+ } else {
163
+ for (const t of tools) {
164
+ if (t.found) ui.print.green(` ✓ ${t.name} detected`)
165
+ }
166
+ }
167
+
168
+ if (!openaiKey) {
169
+ ui.print.dim(' ℹ OPENAI_API_KEY not set (only needed for codex/OpenAI features)')
170
+ } else {
171
+ ui.print.green(' ✓ OPENAI_API_KEY set')
172
+ }
173
+
174
+ ui.print('')
175
+ }
176
+
177
+ commands.registerHandler('bootstrap', {
178
+ description: 'Scaffold a new luca project with commands, features, endpoints, docs, and AI assistant configuration',
179
+ argsSchema,
180
+ handler: bootstrap,
181
+ })
@@ -12,7 +12,7 @@ declare module '../command.js' {
12
12
 
13
13
  export const argsSchema = CommandOptionsSchema.extend({
14
14
  model: z.string().optional().describe('Override the LLM model for the assistant'),
15
- folder: z.string().default('assistants').describe('Directory containing assistant definitions'),
15
+ local: z.boolean().default(false).describe('Whether to use a local API server'),
16
16
  resume: z.string().optional().describe('Thread ID or conversation ID to resume'),
17
17
  list: z.boolean().optional().describe('List recent conversations and exit'),
18
18
  historyMode: z.enum(['lifecycle', 'daily', 'persistent', 'session']).optional().describe('Override history persistence mode'),
@@ -23,14 +23,14 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
23
23
  const container = context.container as any
24
24
  const ui = container.feature('ui')
25
25
 
26
- const manager = container.feature('assistantsManager', { folder: options.folder })
27
- manager.discover()
26
+ const manager = container.feature('assistantsManager')
27
+ await manager.discover()
28
28
 
29
29
  const entries = manager.list()
30
30
 
31
31
  if (entries.length === 0) {
32
32
  console.error(ui.colors.red('No assistants found.'))
33
- console.error(ui.colors.dim(` Create an assistant directory in "${options.folder}/" with a CORE.md file.`))
33
+ console.error(ui.colors.dim(` Create a directory with a CORE.md file anywhere in the project.`))
34
34
  process.exit(1)
35
35
  }
36
36
 
@@ -68,6 +68,7 @@ export default async function chat(options: z.infer<typeof argsSchema>, context:
68
68
 
69
69
  const createOptions: Record<string, any> = { historyMode }
70
70
  if (options.model) createOptions.model = options.model
71
+ if (options.local) createOptions.local = options.local
71
72
 
72
73
  const assistant = manager.create(name, createOptions)
73
74