brainiac 0.0.9 → 0.0.10

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.
data/bin/brainiac CHANGED
@@ -220,6 +220,254 @@ def update_handler_config(name, enabled)
220
220
  puts " Restart the server for changes to take effect: brainiac restart"
221
221
  end
222
222
 
223
+ # After installing a plugin, load it and run its setup if it provides one.
224
+ # Only runs if the plugin defines .cli and responds to a "setup" subcommand via .configured?
225
+ def run_plugin_setup(name, local_path: nil)
226
+ gem_name = "brainiac-#{name}"
227
+ begin
228
+ if local_path
229
+ lib_path = File.join(local_path, "lib")
230
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
231
+ end
232
+
233
+ begin
234
+ require gem_name
235
+ rescue LoadError
236
+ require gem_name.tr("-", "_")
237
+ end
238
+
239
+ pascal = name.split(/[-_]/).map(&:capitalize).join
240
+ mod = if defined?(Brainiac::Plugins) && Brainiac::Plugins.const_defined?(pascal)
241
+ Brainiac::Plugins.const_get(pascal)
242
+ else
243
+ Brainiac::Plugins.constants.find { |c| c.to_s.downcase == name.downcase }&.then { |c| Brainiac::Plugins.const_get(c) }
244
+ end
245
+
246
+ return unless mod
247
+
248
+ # If plugin reports it's not configured, run setup automatically
249
+ if mod.respond_to?(:configured?) && !mod.configured? && mod.respond_to?(:cli)
250
+ puts ""
251
+ mod.cli(["setup"])
252
+ end
253
+ rescue LoadError
254
+ # Plugin couldn't be loaded — skip setup (will be caught on first use)
255
+ nil
256
+ rescue StandardError => e
257
+ puts " Note: Auto-setup skipped (#{e.message})"
258
+ end
259
+ end
260
+
261
+ # Collect help text from installed plugins for the help command.
262
+ def collect_plugin_help_lines
263
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
264
+ plugins_config = File.exist?(plugins_file) ? JSON.parse(File.read(plugins_file)) : { "plugins" => [] }
265
+
266
+ (plugins_config["plugins"] || []).filter_map do |pentry|
267
+ pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
268
+ load_plugin_help(pname, pentry)
269
+ end
270
+ end
271
+
272
+ def load_plugin_help(pname, pentry)
273
+ if pentry.is_a?(Hash) && pentry["path"]
274
+ lib_path = File.join(pentry["path"], "lib")
275
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
276
+ end
277
+
278
+ # Try to load just the metadata file (lightweight, no runtime deps)
279
+ metadata_loaded = try_require_metadata(pname, pentry)
280
+
281
+ # Fall back to loading the full plugin if metadata file doesn't exist
282
+ unless metadata_loaded
283
+ gem_name = "brainiac-#{pname}"
284
+ begin
285
+ require gem_name
286
+ rescue LoadError
287
+ require gem_name.tr("-", "_")
288
+ end
289
+ end
290
+
291
+ mod = resolve_plugin_module_for(pname)
292
+ if mod.respond_to?(:help_text)
293
+ mod.help_text
294
+ elsif mod.respond_to?(:cli)
295
+ " #{pname.ljust(28)} Run 'brainiac #{pname} help' for commands"
296
+ end
297
+ rescue LoadError
298
+ " #{pname.ljust(28)} (could not load — reinstall with: brainiac install #{pname})"
299
+ rescue StandardError
300
+ nil
301
+ end
302
+
303
+ def try_require_metadata(pname, pentry)
304
+ if pentry.is_a?(Hash) && pentry["path"]
305
+ metadata_file = File.join(pentry["path"], "lib", "brainiac", "plugins", pname, "metadata.rb")
306
+ if File.exist?(metadata_file)
307
+ load metadata_file
308
+ return true
309
+ end
310
+ end
311
+
312
+ # Try gem path via standard require
313
+ begin
314
+ require "brainiac/plugins/#{pname}/metadata"
315
+ true
316
+ rescue LoadError
317
+ false
318
+ end
319
+ end
320
+
321
+ # Load only the plugin's CLI module (metadata + cli) without the full server runtime.
322
+ # Returns true if successfully loaded, false if no CLI file exists.
323
+ def try_require_plugin_cli(pname, pentry)
324
+ plugin_dir = (File.join(pentry["path"], "lib", "brainiac", "plugins", pname) if pentry.is_a?(Hash) && pentry["path"])
325
+
326
+ # Load metadata first (defines module + help_text/configured?)
327
+ try_require_metadata(pname, pentry)
328
+
329
+ # Load CLI file
330
+ if plugin_dir
331
+ cli_file = File.join(plugin_dir, "cli.rb")
332
+ if File.exist?(cli_file)
333
+ load cli_file
334
+ return true
335
+ end
336
+ end
337
+
338
+ # Try gem path
339
+ begin
340
+ require "brainiac/plugins/#{pname}/cli"
341
+ true
342
+ rescue LoadError
343
+ false
344
+ end
345
+ end
346
+
347
+ def resolve_plugin_module_for(name)
348
+ return nil unless defined?(Brainiac::Plugins)
349
+
350
+ pascal = name.split(/[-_]/).map(&:capitalize).join
351
+ if Brainiac::Plugins.const_defined?(pascal)
352
+ Brainiac::Plugins.const_get(pascal)
353
+ else
354
+ match = Brainiac::Plugins.constants.find { |c| c.to_s.downcase == name.downcase }
355
+ match ? Brainiac::Plugins.const_get(match) : nil
356
+ end
357
+ end
358
+
359
+ # Generate the plugin development SKILL.md content for the scaffold.
360
+ # Uses single-quoted heredoc to avoid interpolation issues with example code.
361
+ def generate_plugin_skill_md(plugin_name, pascal_name)
362
+ <<~SKILL.gsub("__PLUGIN_NAME__", plugin_name).gsub("__PASCAL_NAME__", pascal_name)
363
+ ---
364
+ name: brainiac-plugin-development
365
+ description: |
366
+ How to develop Brainiac plugins. Covers the plugin contract, file structure,
367
+ hooks, CLI commands, and architecture constraints.
368
+ triggers:
369
+ - plugin contract
370
+ - plugin development
371
+ - register app
372
+ - brainiac hooks
373
+ ---
374
+
375
+ # Brainiac Plugin Development
376
+
377
+ This is a Brainiac plugin gem. Plugins extend Brainiac with new communication
378
+ channels, integrations, or features without modifying core.
379
+
380
+ ## Plugin Contract
381
+
382
+ A valid Brainiac plugin MUST:
383
+
384
+ 1. **Gem named `brainiac-<name>`**
385
+ 2. **Entry file at `lib/brainiac_<name>.rb`** — loaded by RubyGems
386
+ 3. **Module at `Brainiac::Plugins::<Name>`** — PascalCase
387
+ 4. **Implement `.register(app)`** — receives Sinatra::Application at server startup
388
+
389
+ Optional but recommended:
390
+
391
+ | Method | File | Purpose |
392
+ |--------|------|---------|
393
+ | `.register(app)` | main module | **Required.** Define routes, hooks, threads |
394
+ | `.configured?` | `metadata.rb` | Returns false → auto-runs setup on install |
395
+ | `.help_text` | `metadata.rb` | One-liner for `brainiac help` |
396
+ | `.cli(args)` | `cli.rb` | CLI subcommands via `brainiac <plugin> ...` |
397
+
398
+ ## Critical Architecture: CLI vs Server Runtime
399
+
400
+ Plugins are loaded in TWO contexts:
401
+
402
+ 1. **Server context** — full plugin via `.register(app)`. Has `LOG`, `AGENT_REGISTRY`, etc.
403
+ 2. **CLI context** — only `metadata.rb` + `cli.rb`. NO server runtime.
404
+
405
+ Rules:
406
+ - `metadata.rb` requires only `version.rb`
407
+ - `cli.rb` uses only stdlib (`json`, `net/http`, `fileutils`)
408
+ - Everything else loaded only by `.register(app)`
409
+
410
+ ## Available Hooks
411
+
412
+ Subscribe in `.register(app)` via `Brainiac.on(:event)`:
413
+
414
+ | Hook | When |
415
+ |------|------|
416
+ | `:server_started` | All plugins loaded |
417
+ | `:pre_dispatch` | Before agent CLI spawned |
418
+ | `:agent_completed` | Agent session finished |
419
+ | `:agent_crashed` | Agent process crashed |
420
+ | `:build_brain_context` | Building prompt context |
421
+ | `:pr_merged` | GitHub PR merged |
422
+ | `:pr_review_received` | PR review submitted |
423
+ | `:pr_synchronized` | PR updated |
424
+ | `:production_deployed` | Deploy succeeded |
425
+ | `:create_work_item` | Create card/issue/ticket |
426
+ | `:detect_cli_provider` | Detect CLI provider |
427
+ | `:detect_effort` | Detect effort level |
428
+
429
+ ## Channel Prompts
430
+
431
+ For communication channel plugins:
432
+
433
+ ```ruby
434
+ Brainiac.register_channel_prompt(:my_channel, PROMPT_TEXT, pre_post_check: CHECK_TEXT)
435
+ ```
436
+
437
+ ## Core Functions (Server Context)
438
+
439
+ | Function | Purpose |
440
+ |----------|---------|
441
+ | `agent_display_name(key)` | Display name for agent |
442
+ | `agent_env_for(name)` | Env vars hash for agent |
443
+ | `AGENT_REGISTRY` | All agents |
444
+ | `PROJECTS` | All projects |
445
+ | `LOG` | Logger |
446
+ | `BRAINIAC_DIR` | ~/.brainiac/ path |
447
+ | `register_session(key, pid, **)` | Track active session |
448
+ | `session_active?(key)` | Check if session running |
449
+ | `build_brain_context(...)` | Build brain context |
450
+ | `render_prompt(template, vars, ...)` | Compose full prompt |
451
+ | `detect_model(config, text:)` | Detect model from tags |
452
+ | `parse_inline_tags(text)` | Parse inline tags |
453
+ | `reload_projects!` | Reload projects.json |
454
+ | `brain_push(message:)` | Push brain to git |
455
+
456
+ ## Testing
457
+
458
+ ```bash
459
+ rake test # Minitest
460
+ rake rubocop # Linter
461
+ rake # Both
462
+ ```
463
+
464
+ ## Reference Implementations
465
+
466
+ - `brainiac-discord` — Communication channel (gateway, messages, delivery, reactions)
467
+ - `brainiac-fizzy` — Card management (webhooks, hooks, duplicate detection, planning)
468
+ SKILL
469
+ end
470
+
223
471
  def start_server(daemon: false)
224
472
  # Resolve the real path of the brainiac script (follows symlinks)
225
473
  receiver_path = File.join(BRAINIAC_ROOT, "receiver.rb")
@@ -632,7 +880,7 @@ when "setup"
632
880
  puts ""
633
881
 
634
882
  # Create directory structure
635
- dirs = %w[brain/knowledge brain/persona brain/memory handlers plans tmp/discord/draft tmp/discord/posted]
883
+ dirs = %w[brain/knowledge brain/persona brain/memory handlers plans tmp/notify/draft tmp/notify/posted]
636
884
  dirs.each do |dir|
637
885
  path = File.join(BRAINIAC_DIR, dir)
638
886
  FileUtils.mkdir_p(path)
@@ -675,9 +923,7 @@ when "setup"
675
923
  puts ""
676
924
 
677
925
  available_handlers = {
678
-
679
926
  "github" => "GitHub (PR reviews, CI failure handling, deploy tracking)",
680
- "discord" => "Discord (conversational agent access via bot)",
681
927
  "zoho" => "Zoho Mail (email notifications and triage)"
682
928
  }
683
929
 
@@ -707,6 +953,42 @@ when "setup"
707
953
  puts " 4. Initialize brain: brainiac brain init"
708
954
  puts " 5. Start server: brainiac server"
709
955
 
956
+ # Shell completion setup
957
+ puts ""
958
+ completion_source = File.join(BRAINIAC_ROOT, "bin", "brainiac-completion.bash")
959
+ completion_target = File.join(BRAINIAC_DIR, "brainiac-completion.bash")
960
+ if File.exist?(completion_source)
961
+ FileUtils.cp(completion_source, completion_target)
962
+
963
+ # Detect shell rc file
964
+ shell = ENV.fetch("SHELL", "/bin/bash")
965
+ rc_file = case shell
966
+ when /zsh/ then File.expand_path("~/.zshrc")
967
+ when /fish/ then nil # fish uses different mechanism
968
+ else File.expand_path("~/.bashrc")
969
+ end
970
+
971
+ source_line = "[[ -r \"#{completion_target}\" ]] && source \"#{completion_target}\""
972
+
973
+ if rc_file && File.exist?(rc_file) && !File.read(rc_file).include?("brainiac-completion")
974
+ print "Add shell completion to #{rc_file}? [Y/n] "
975
+ answer = $stdin.gets&.strip&.downcase
976
+ if answer == "n"
977
+ puts " To add manually:"
978
+ puts " #{source_line}"
979
+ else
980
+ File.open(rc_file, "a") { |f| f.puts("\n# Brainiac CLI completion\n#{source_line}") }
981
+ puts "✓ Added completion to #{rc_file}"
982
+ end
983
+ elsif rc_file && !File.exist?(rc_file)
984
+ puts " Shell completion installed to: #{completion_target}"
985
+ puts " Add to your shell rc file:"
986
+ puts " #{source_line}"
987
+ else
988
+ puts "✓ Shell completion already configured"
989
+ end
990
+ end
991
+
710
992
  when "register", "add"
711
993
  OptionParser.new do |opts|
712
994
  opts.banner = "Usage: brainiac register [options]"
@@ -785,7 +1067,7 @@ when "handler", "handlers"
785
1067
  handlers = config["handlers"] || {}
786
1068
 
787
1069
  # Built-in handlers
788
- builtins = %w[github discord zoho]
1070
+ builtins = %w[github zoho]
789
1071
  puts "Built-in handlers:"
790
1072
  builtins.each do |name|
791
1073
  enabled = handlers.fetch(name, true)
@@ -896,165 +1178,6 @@ when "status"
896
1178
  when "path"
897
1179
  puts BRAINIAC_DIR
898
1180
 
899
- when "discord"
900
- discord_cmd = ARGV.shift
901
- discord_config_file = File.join(BRAINIAC_DIR, "discord.json")
902
- agent_registry_file = File.join(BRAINIAC_DIR, "agents.json")
903
-
904
- case discord_cmd
905
- when "config"
906
- if File.exist?(discord_config_file)
907
- puts File.read(discord_config_file)
908
- else
909
- puts "No Discord config found at #{discord_config_file}"
910
- puts "Create one with: brainiac discord map <channel-id> <project>"
911
- puts "Or copy discord.json.example to #{discord_config_file}"
912
- end
913
-
914
- when "map"
915
- channel_id = ARGV[0]
916
- project_key = ARGV[1]
917
-
918
- unless channel_id && project_key
919
- puts "Usage: brainiac discord map <channel-id> <project-key>"
920
- exit 1
921
- end
922
-
923
- config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : { "channel_mappings" => {} }
924
- config["channel_mappings"] ||= {}
925
- config["channel_mappings"][channel_id] = { "project" => project_key }
926
-
927
- ensure_brainiac_dir
928
- File.write(discord_config_file, JSON.pretty_generate(config))
929
- puts "✓ Mapped channel #{channel_id} → project '#{project_key}'"
930
-
931
- when "default"
932
- project_key = ARGV[0]
933
-
934
- unless project_key
935
- puts "Usage: brainiac discord default <project-key>"
936
- exit 1
937
- end
938
-
939
- config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : { "channel_mappings" => {} }
940
- config["default_project"] = project_key
941
-
942
- ensure_brainiac_dir
943
- File.write(discord_config_file, JSON.pretty_generate(config))
944
- puts "✓ Default project: #{project_key}"
945
-
946
- when "token"
947
- agent_key = ARGV[0]
948
- token = ARGV[1]
949
-
950
- unless agent_key && token
951
- puts "Usage: brainiac discord token <agent-key> <bot-token>"
952
- puts " Sets the DISCORD_BOT_TOKEN env var for an agent in the registry."
953
- puts " Example: brainiac discord token galen Bot_TOKEN_HERE"
954
- exit 1
955
- end
956
-
957
- registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
958
- registry[agent_key] ||= {}
959
- registry[agent_key]["env"] ||= {}
960
- registry[agent_key]["env"]["DISCORD_BOT_TOKEN"] = token
961
-
962
- ensure_brainiac_dir
963
- File.write(agent_registry_file, JSON.pretty_generate(registry))
964
- puts "✓ Set DISCORD_BOT_TOKEN for '#{agent_key}'"
965
-
966
- when "status"
967
- config = load_config
968
- server_url = config["server_url"] || "http://localhost:4567"
969
- begin
970
- uri = URI("#{server_url}/api/discord")
971
- response = Net::HTTP.get_response(uri)
972
- data = JSON.parse(response.body)
973
- if data["enabled"]
974
- bots = data["bots"] || {}
975
- if bots.empty?
976
- puts "Discord: enabled but no bots configured"
977
- else
978
- puts "Discord bots:"
979
- bots.each do |agent, info|
980
- puts " #{agent}: #{info["status"]} (user_id: #{info["user_id"] || "n/a"})"
981
- end
982
- end
983
- puts "Default project: #{data.dig("config", "default_project") || "none"}"
984
- puts "Channel mappings: #{data.dig("config", "channel_mappings")}"
985
- else
986
- puts "Discord: disabled (#{data["reason"]})"
987
- end
988
- rescue StandardError => e
989
- puts "Could not reach server at #{server_url}: #{e.message}"
990
- puts "Is the server running? Check with: brainiac status"
991
- end
992
-
993
- when "agents"
994
- registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
995
- agents_with_tokens = registry.select { |_k, v| v.is_a?(Hash) && (v.dig("env", "DISCORD_BOT_TOKEN") || v["discord_bot_token"]) }
996
- if agents_with_tokens.empty?
997
- puts "No agents have Discord bot tokens configured."
998
- puts "Add one with: brainiac discord token <agent-key> <bot-token>"
999
- else
1000
- puts "Agents with Discord bots:"
1001
- agents_with_tokens.each do |key, entry|
1002
- display = entry["display_name"] || key.capitalize
1003
- token = entry.dig("env", "DISCORD_BOT_TOKEN") || entry["discord_bot_token"]
1004
- token_preview = "#{token[0..10]}..."
1005
- puts " #{display} (#{key}): #{token_preview}"
1006
- end
1007
- end
1008
-
1009
- when "owner"
1010
- discord_id = ARGV[0]
1011
- config = File.exist?(discord_config_file) ? JSON.parse(File.read(discord_config_file)) : {}
1012
- if discord_id
1013
- config["owner_discord_id"] = discord_id
1014
- ensure_brainiac_dir
1015
- File.write(discord_config_file, JSON.pretty_generate(config))
1016
- puts "✓ Owner set to #{discord_id}"
1017
- elsif config["owner_discord_id"]
1018
- puts "Owner: #{config["owner_discord_id"]}"
1019
- else
1020
- puts "No owner set."
1021
- puts "Usage: brainiac discord owner <discord-user-id>"
1022
- end
1023
-
1024
- else
1025
- puts <<~HELP
1026
- Usage: brainiac discord <command>
1027
-
1028
- Commands:
1029
- config Show Discord config
1030
- default <project> Set default project for all channels
1031
- map <channel-id> <project> Map a specific channel to a project
1032
- owner [<discord-user-id>] Set/show machine owner (for version notifications)
1033
- token <agent-key> <bot-token> Set Discord bot token for an agent
1034
- agents List agents with Discord bot tokens
1035
- status Check Discord bot status (via server API)
1036
-
1037
- Each agent gets its own Discord bot. Users @mention @Galen or @GLaDOS
1038
- directly in Discord — no shared bot needed.
1039
-
1040
- Setup:
1041
- 1. Create a Discord bot per agent at https://discord.com/developers/applications
1042
- 2. Enable MESSAGE CONTENT intent in each bot's settings
1043
- 3. Invite each bot with permissions: Send Messages, Create Public Threads,
1044
- Send Messages in Threads, Add Reactions, Read Message History
1045
- 4. Register each bot token:
1046
- brainiac discord token galen "BOT_TOKEN_FOR_GALEN"
1047
- brainiac discord token glados "BOT_TOKEN_FOR_GLADOS"
1048
- 5. Set a default project:
1049
- brainiac discord default marketplace
1050
- 6. Start the server (all bots connect automatically):
1051
- brainiac server
1052
-
1053
- Config file: #{discord_config_file}
1054
- Agent registry: #{agent_registry_file}
1055
- HELP
1056
- end
1057
-
1058
1181
  when "work-items", "card-map"
1059
1182
  work_items_file = File.join(BRAINIAC_DIR, "work_items.json")
1060
1183
  cm_cmd = ARGV.shift
@@ -1185,7 +1308,8 @@ when "cron"
1185
1308
  project = nil
1186
1309
  model = nil
1187
1310
  effort = nil
1188
- discord_channel_id = nil
1311
+ notify_channel = nil
1312
+ notify_target = nil
1189
1313
  forum_title = nil
1190
1314
  repeat_count = nil
1191
1315
  prompt = nil
@@ -1198,10 +1322,11 @@ when "cron"
1198
1322
  opts.on("-p", "--project KEY", "Project key") { |v| project = v }
1199
1323
  opts.on("-m", "--model MODEL", "Model to use (opus, sonnet, haiku, auto)") { |v| model = v }
1200
1324
  opts.on("-e", "--effort LEVEL", "Effort level (low, medium, high, xhigh, max)") { |v| effort = v }
1201
- opts.on("-d", "--discord CHANNEL_ID", "Discord channel ID to post results") { |v| discord_channel_id = v }
1325
+ opts.on("-d", "--notify TARGET", "Notification target (channel ID, card number, etc.)") { |v| notify_target = v }
1326
+ opts.on("-c", "--channel CHANNEL", "Notification channel (discord, fizzy — default: discord)") { |v| notify_channel = v }
1202
1327
  opts.on("-t", "--title TITLE", "Forum post title (for forum channels)") { |v| forum_title = v }
1203
1328
  opts.on("-r", "--repeat COUNT", Integer, "Number of times to repeat (for recurring schedules)") { |v| repeat_count = v }
1204
- opts.on("--script PATH", "Run script directly (no agent, output to Discord)") { |v| script = v }
1329
+ opts.on("--script PATH", "Run script directly (no agent, output posted via notify)") { |v| script = v }
1205
1330
  end.parse!
1206
1331
 
1207
1332
  prompt = ARGV.join(" ") unless script
@@ -1241,7 +1366,9 @@ when "cron"
1241
1366
  payload[:script] = script if script
1242
1367
  payload[:model] = model if model
1243
1368
  payload[:effort] = effort if effort
1244
- payload[:discord_channel_id] = discord_channel_id if discord_channel_id
1369
+ payload[:discord_channel_id] = notify_target if notify_target && (!notify_channel || notify_channel == "discord")
1370
+ payload[:notify_channel] = notify_channel if notify_channel
1371
+ payload[:notify_target] = notify_target if notify_target
1245
1372
  payload[:forum_title] = forum_title if forum_title
1246
1373
  payload[:repeat_count] = repeat_count if repeat_count
1247
1374
  req.body = payload.to_json
@@ -1256,8 +1383,8 @@ when "cron"
1256
1383
  puts " Project: #{project}"
1257
1384
  puts " Model: #{model || "default"}" unless script
1258
1385
  puts " Effort: #{effort || "default"}" unless script
1259
- puts " Discord: #{discord_channel_id || "none"}"
1260
- puts " Forum title: #{forum_title || "auto"}" if discord_channel_id
1386
+ puts " Notify: #{notify_target ? "#{notify_channel || "discord"} → #{notify_target}" : "none"}"
1387
+ puts " Forum title: #{forum_title || "auto"}" if notify_target && forum_title
1261
1388
  puts " Repeat: #{repeat_count ? "#{repeat_count} times" : "unlimited"}"
1262
1389
  puts script ? " Script: #{script}" : " Prompt: #{prompt}"
1263
1390
  else
@@ -1301,7 +1428,9 @@ when "cron"
1301
1428
  puts " #{status} #{id}"
1302
1429
  puts " Schedule: #{schedule_display}"
1303
1430
  puts " Agent: #{job["script"] ? "none (script mode)" : job["agent"]} | Project: #{job["project"]}"
1304
- puts " Discord: #{job["discord_channel_id"]}" if job["discord_channel_id"]
1431
+ notify_ch = job["notify_channel"] || (job["discord_channel_id"] ? "discord" : nil)
1432
+ notify_tgt = job["notify_target"] || job["discord_channel_id"]
1433
+ puts " Notify: #{notify_ch} → #{notify_tgt}" if notify_tgt
1305
1434
  puts job["script"] ? " Script: #{job["script"]}" : " Prompt: #{job["prompt"]}"
1306
1435
  puts " Last run: #{last_run}"
1307
1436
  puts
@@ -1390,22 +1519,23 @@ when "cron"
1390
1519
  when "update"
1391
1520
  id = ARGV[0]
1392
1521
  schedule = nil
1393
- discord_channel_id = nil
1522
+ notify_target = nil
1523
+ notify_channel = nil
1394
1524
  forum_title = nil
1395
1525
 
1396
1526
  OptionParser.new do |opts|
1397
1527
  opts.banner = "Usage: brainiac cron update <job-id> [options]"
1398
1528
  opts.on("-s", "--schedule EXPR", "New cron expression") { |v| schedule = v }
1399
- opts.on("-c", "--channel CHANNEL_ID", "New Discord channel ID") { |v| discord_channel_id = v }
1529
+ opts.on("-d", "--notify TARGET", "New notification target") { |v| notify_target = v }
1530
+ opts.on("-c", "--channel CHANNEL", "New notification channel (discord, fizzy)") { |v| notify_channel = v }
1400
1531
  opts.on("-t", "--title TITLE", "New forum post title") { |v| forum_title = v }
1401
1532
  end.parse!
1402
1533
 
1403
- unless id && (schedule || discord_channel_id || forum_title)
1534
+ unless id && (schedule || notify_target || notify_channel || forum_title)
1404
1535
  puts "Error: Missing required arguments"
1405
1536
  puts "Usage: brainiac cron update <job-id> -s \"42 13 * * 1-5\""
1406
- puts " or: brainiac cron update <job-id> -c \"1423854179880927274\""
1537
+ puts " or: brainiac cron update <job-id> -d \"channel-id\" -c discord"
1407
1538
  puts " or: brainiac cron update <job-id> -t \"Daily Update\""
1408
- puts " or: brainiac cron update <job-id> -s \"42 13 * * 1-5\" -c \"1423854179880927274\""
1409
1539
  exit 1
1410
1540
  end
1411
1541
 
@@ -1413,7 +1543,9 @@ when "cron"
1413
1543
  req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json")
1414
1544
  payload = { id: id }
1415
1545
  payload[:schedule] = schedule if schedule
1416
- payload[:discord_channel_id] = discord_channel_id if discord_channel_id
1546
+ payload[:discord_channel_id] = notify_target if notify_target
1547
+ payload[:notify_channel] = notify_channel if notify_channel
1548
+ payload[:notify_target] = notify_target if notify_target
1417
1549
  payload[:forum_title] = forum_title if forum_title
1418
1550
  req.body = payload.to_json
1419
1551
 
@@ -1423,7 +1555,7 @@ when "cron"
1423
1555
  if data["success"]
1424
1556
  puts "✓ Updated cron job: #{id}"
1425
1557
  puts " New schedule: #{schedule}" if schedule
1426
- puts " New Discord channel: #{discord_channel_id}" if discord_channel_id
1558
+ puts " New notify: #{notify_channel || "discord"} → #{notify_target}" if notify_target
1427
1559
  puts " New forum title: #{forum_title}" if forum_title
1428
1560
  else
1429
1561
  puts "Error: #{data["error"]}"
@@ -2229,6 +2361,9 @@ when "install"
2229
2361
  puts "✓ Registered plugin '#{name}' from #{local_path}"
2230
2362
  puts " Restart the server to activate: brainiac restart"
2231
2363
  puts " Note: Source changes take effect on next server restart."
2364
+
2365
+ # Auto-run setup if plugin defines it and isn't already configured
2366
+ run_plugin_setup(name, local_path: local_path)
2232
2367
  else
2233
2368
  # RubyGems mode
2234
2369
  puts "Installing #{gem_name}..."
@@ -2259,6 +2394,9 @@ when "install"
2259
2394
  puts ""
2260
2395
  puts "✓ Installed plugin '#{name}' (#{gem_name})"
2261
2396
  puts " Restart the server to activate: brainiac restart"
2397
+
2398
+ # Auto-run setup if plugin defines it and isn't already configured
2399
+ run_plugin_setup(name)
2262
2400
  end
2263
2401
 
2264
2402
  when "uninstall"
@@ -2356,151 +2494,643 @@ when "plugins"
2356
2494
  puts " brainiac plugins List installed plugins"
2357
2495
  end
2358
2496
 
2497
+ when "plugin"
2498
+ plugin_cmd = ARGV.shift
2499
+
2500
+ case plugin_cmd
2501
+ when "new"
2502
+ plugin_name = ARGV.shift
2503
+ unless plugin_name
2504
+ puts "Usage: brainiac plugin new <name>"
2505
+ puts ""
2506
+ puts "Generates a new Brainiac plugin gem skeleton."
2507
+ puts ""
2508
+ puts "Example:"
2509
+ puts " brainiac plugin new slack"
2510
+ puts " → Creates ~/Code/brainiac-slack/ with full gem structure"
2511
+ exit 1
2512
+ end
2513
+
2514
+ plugin_name = plugin_name.downcase.gsub(/[^a-z0-9-]/, "-").sub(/^brainiac-/, "")
2515
+ gem_name = "brainiac-#{plugin_name}"
2516
+ pascal_name = plugin_name.split(/[-_]/).map(&:capitalize).join
2517
+ snake_name = plugin_name.tr("-", "_")
2518
+
2519
+ # Determine output directory
2520
+ output_dir = ARGV.shift || File.join(Dir.pwd, gem_name)
2521
+ output_dir = File.expand_path(output_dir)
2522
+
2523
+ if Dir.exist?(output_dir) && !Dir.empty?(output_dir)
2524
+ puts "Error: Directory already exists and is not empty: #{output_dir}"
2525
+ exit 1
2526
+ end
2527
+
2528
+ puts "Creating #{gem_name} at #{output_dir}..."
2529
+
2530
+ # Create directory structure
2531
+ dirs = [
2532
+ "lib/brainiac/plugins/#{plugin_name}",
2533
+ "test",
2534
+ "skills/#{plugin_name}"
2535
+ ]
2536
+ dirs.each { |d| FileUtils.mkdir_p(File.join(output_dir, d)) }
2537
+
2538
+ # version.rb
2539
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "version.rb"), <<~RUBY)
2540
+ # frozen_string_literal: true
2541
+
2542
+ module Brainiac
2543
+ module Plugins
2544
+ module #{pascal_name}
2545
+ VERSION = "0.0.1"
2546
+ end
2547
+ end
2548
+ end
2549
+ RUBY
2550
+
2551
+ # metadata.rb
2552
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "metadata.rb"), <<~RUBY)
2553
+ # frozen_string_literal: true
2554
+
2555
+ # Lightweight metadata — loaded by `brainiac help` without the full plugin runtime.
2556
+
2557
+ require_relative "version"
2558
+
2559
+ module Brainiac
2560
+ module Plugins
2561
+ module #{pascal_name}
2562
+ def self.configured?
2563
+ # TODO: Return true when the plugin has been configured
2564
+ false
2565
+ end
2566
+
2567
+ def self.help_text
2568
+ " brainiac #{plugin_name} <command>#{" " * [1, 20 - plugin_name.length].max}Manage #{pascal_name} integration"
2569
+ end
2570
+ end
2571
+ end
2572
+ end
2573
+ RUBY
2574
+
2575
+ # cli.rb
2576
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "cli.rb"), <<~RUBY)
2577
+ # frozen_string_literal: true
2578
+
2579
+ require "json"
2580
+
2581
+ module Brainiac
2582
+ module Plugins
2583
+ module #{pascal_name}
2584
+ module Cli
2585
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
2586
+
2587
+ class << self
2588
+ def run(args)
2589
+ command = args.shift
2590
+
2591
+ case command
2592
+ when "config"
2593
+ cmd_config
2594
+ when "setup"
2595
+ cmd_setup
2596
+ when "status"
2597
+ cmd_status
2598
+ else
2599
+ print_help
2600
+ end
2601
+ end
2602
+
2603
+ private
2604
+
2605
+ def cmd_config
2606
+ puts "TODO: Show #{plugin_name} configuration"
2607
+ end
2608
+
2609
+ def cmd_setup
2610
+ puts "#{pascal_name} Setup"
2611
+ puts "#{"=" * (pascal_name.length + 6)}"
2612
+ puts ""
2613
+ puts "TODO: Walk user through configuration"
2614
+ end
2615
+
2616
+ def cmd_status
2617
+ puts "TODO: Show #{plugin_name} status"
2618
+ end
2619
+
2620
+ def print_help
2621
+ puts <<~HELP
2622
+ Usage: brainiac #{plugin_name} <command>
2623
+
2624
+ Commands:
2625
+ config Show #{pascal_name} config
2626
+ setup Interactive setup guide
2627
+ status Check #{pascal_name} status via server API
2628
+
2629
+ Config file: #{File.join("~/.brainiac", "#{plugin_name}.json")}
2630
+ HELP
2631
+ end
2632
+ end
2633
+ end
2634
+
2635
+ def self.cli(args)
2636
+ Cli.run(args)
2637
+ end
2638
+
2639
+ # Subcommand names for bash completion.
2640
+ def self.completions
2641
+ %w[config setup status]
2642
+ end
2643
+ end
2644
+ end
2645
+ end
2646
+ RUBY
2647
+
2648
+ # Main plugin module
2649
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", "#{plugin_name}.rb"), <<~RUBY)
2650
+ # frozen_string_literal: true
2651
+
2652
+ require_relative "#{plugin_name}/version"
2653
+ require_relative "#{plugin_name}/metadata"
2654
+ require_relative "#{plugin_name}/cli"
2655
+
2656
+ module Brainiac
2657
+ module Plugins
2658
+ module #{pascal_name}
2659
+ class << self
2660
+ # Called by Brainiac plugin system during server startup.
2661
+ #
2662
+ # @param app [Sinatra::Application] The running Brainiac server
2663
+ def register(app)
2664
+ # TODO: Load config, register hooks, define routes, start threads
2665
+ setup_routes(app)
2666
+
2667
+ LOG.info "[#{pascal_name}] Plugin registered"
2668
+ end
2669
+
2670
+ private
2671
+
2672
+ def setup_routes(app)
2673
+ app.get "/api/#{plugin_name}" do
2674
+ content_type :json
2675
+ { enabled: true }.to_json
2676
+ end
2677
+
2678
+ # TODO: Define webhook route
2679
+ # app.post "/#{plugin_name}" do
2680
+ # content_type :json
2681
+ # { status: "received" }.to_json
2682
+ # end
2683
+ end
2684
+ end
2685
+ end
2686
+ end
2687
+ end
2688
+ RUBY
2689
+
2690
+ # Entry point (loaded by RubyGems)
2691
+ File.write(File.join(output_dir, "lib", "brainiac_#{snake_name}.rb"), <<~RUBY)
2692
+ # frozen_string_literal: true
2693
+
2694
+ # Brainiac #{pascal_name} Plugin — entry point loaded by RubyGems.
2695
+ require_relative "brainiac/plugins/#{plugin_name}"
2696
+ RUBY
2697
+
2698
+ # Gemspec
2699
+ File.write(File.join(output_dir, "#{gem_name}.gemspec"), <<~RUBY)
2700
+ # frozen_string_literal: true
2701
+
2702
+ require_relative "lib/brainiac/plugins/#{plugin_name}/version"
2703
+
2704
+ Gem::Specification.new do |s|
2705
+ s.name = "#{gem_name}"
2706
+ s.version = Brainiac::Plugins::#{pascal_name}::VERSION
2707
+ s.summary = "#{pascal_name} plugin for Brainiac"
2708
+ s.description = "#{pascal_name} integration for Brainiac — TODO: describe what this plugin does."
2709
+ s.authors = ["Your Name"]
2710
+ s.homepage = "https://github.com/yourorg/#{gem_name}"
2711
+ s.license = "MIT"
2712
+ s.required_ruby_version = ">= 3.4"
2713
+
2714
+ s.files = Dir["lib/**/*.rb", "README.md", "LICENSE"]
2715
+ s.require_paths = ["lib"]
2716
+
2717
+ s.add_dependency "brainiac", ">= #{BRAINIAC_VERSION}"
2718
+
2719
+ s.add_development_dependency "minitest", "~> 5.25"
2720
+ s.add_development_dependency "rake", "~> 13.0"
2721
+ s.add_development_dependency "rubocop", "~> 1.75"
2722
+ s.add_development_dependency "rubocop-performance", "~> 1.25"
2723
+
2724
+ s.metadata["rubygems_mfa_required"] = "true"
2725
+ end
2726
+ RUBY
2727
+
2728
+ # Gemfile
2729
+ File.write(File.join(output_dir, "Gemfile"), <<~RUBY)
2730
+ # frozen_string_literal: true
2731
+
2732
+ source "https://rubygems.org"
2733
+
2734
+ gemspec
2735
+ RUBY
2736
+
2737
+ # Rakefile
2738
+ File.write(File.join(output_dir, "Rakefile"), <<~RUBY)
2739
+ # frozen_string_literal: true
2740
+
2741
+ require "rake/testtask"
2742
+ require "rubocop/rake_task"
2743
+
2744
+ Rake::TestTask.new(:test) do |t|
2745
+ t.libs << "test"
2746
+ t.libs << "lib"
2747
+ t.test_files = FileList["test/**/test_*.rb"]
2748
+ end
2749
+
2750
+ RuboCop::RakeTask.new(:rubocop)
2751
+
2752
+ task default: %i[test rubocop]
2753
+ RUBY
2754
+
2755
+ # .rubocop.yml
2756
+ File.write(File.join(output_dir, ".rubocop.yml"), <<~YAML)
2757
+ plugins:
2758
+ - rubocop-performance
2759
+
2760
+ AllCops:
2761
+ TargetRubyVersion: 3.4
2762
+ NewCops: enable
2763
+ SuggestExtensions: false
2764
+ Exclude:
2765
+ - "vendor/**/*"
2766
+ - "tmp/**/*"
2767
+
2768
+ Metrics/MethodLength:
2769
+ Max: 75
2770
+
2771
+ Metrics/AbcSize:
2772
+ Max: 90
2773
+
2774
+ Metrics/ModuleLength:
2775
+ Max: 600
2776
+
2777
+ Metrics/ParameterLists:
2778
+ Max: 10
2779
+ CountKeywordArgs: false
2780
+
2781
+ Metrics/BlockLength:
2782
+ Exclude:
2783
+ - "test/**/*"
2784
+ - "*.gemspec"
2785
+
2786
+ Style/TopLevelMethodDefinition:
2787
+ Enabled: false
2788
+
2789
+ Style/StringLiterals:
2790
+ EnforcedStyle: double_quotes
2791
+
2792
+ Style/FrozenStringLiteralComment:
2793
+ Enabled: false
2794
+
2795
+ Style/ClassAndModuleChildren:
2796
+ Enabled: false
2797
+
2798
+ Style/Documentation:
2799
+ Enabled: false
2800
+
2801
+ Gemspec/DevelopmentDependencies:
2802
+ Enabled: false
2803
+
2804
+ Layout/LineLength:
2805
+ Max: 150
2806
+
2807
+ Naming/PredicateMethod:
2808
+ Enabled: false
2809
+
2810
+ Naming/FileName:
2811
+ Exclude:
2812
+ - "lib/brainiac/plugins/**/*"
2813
+ YAML
2814
+
2815
+ # Test helper
2816
+ File.write(File.join(output_dir, "test", "test_helper.rb"), <<~RUBY)
2817
+ # frozen_string_literal: true
2818
+
2819
+ require "minitest/autorun"
2820
+ require "json"
2821
+ require "fileutils"
2822
+ require "tmpdir"
2823
+
2824
+ TEST_BRAINIAC_DIR = Dir.mktmpdir("brainiac-#{plugin_name}-test")
2825
+ ENV["BRAINIAC_DIR"] = TEST_BRAINIAC_DIR
2826
+
2827
+ unless defined?(LOG)
2828
+ LOG = Class.new do
2829
+ def info(_msg) = nil
2830
+ def warn(_msg) = nil
2831
+ def error(_msg) = nil
2832
+ def debug(_msg) = nil
2833
+ def debug? = false
2834
+ end.new
2835
+ end
2836
+
2837
+ module Brainiac
2838
+ @hooks = Hash.new { |h, k| h[k] = [] }
2839
+ @channel_prompts = {}
2840
+ @channel_pre_post_checks = {}
2841
+
2842
+ class << self
2843
+ def on(event, &block) = @hooks[event] << block
2844
+
2845
+ def emit(event, **ctx)
2846
+ @hooks[event].filter_map do |h|
2847
+ h.call(ctx)
2848
+ rescue StandardError
2849
+ nil
2850
+ end
2851
+ end
2852
+
2853
+ def register_channel_prompt(channel, prompt, pre_post_check: nil)
2854
+ @channel_prompts[channel] = prompt
2855
+ @channel_pre_post_checks[channel] = pre_post_check if pre_post_check
2856
+ end
2857
+
2858
+ attr_reader :hooks, :channel_prompts, :channel_pre_post_checks
2859
+
2860
+ def reset_hooks!
2861
+ @hooks = Hash.new { |h, k| h[k] = [] }
2862
+ @channel_prompts = {}
2863
+ @channel_pre_post_checks = {}
2864
+ end
2865
+ end
2866
+
2867
+ module Plugins; end
2868
+ end
2869
+
2870
+ AGENT_REGISTRY = {}.freeze
2871
+ PROJECTS = {}.freeze
2872
+
2873
+ require_relative "../lib/brainiac_#{snake_name}"
2874
+
2875
+ Minitest.after_run { FileUtils.rm_rf(TEST_BRAINIAC_DIR) }
2876
+ RUBY
2877
+
2878
+ # Test file
2879
+ File.write(File.join(output_dir, "test", "test_#{snake_name}.rb"), <<~RUBY)
2880
+ # frozen_string_literal: true
2881
+
2882
+ require_relative "test_helper"
2883
+
2884
+ class Test#{pascal_name}Plugin < Minitest::Test
2885
+ def test_register_method_exists
2886
+ assert_respond_to Brainiac::Plugins::#{pascal_name}, :register
2887
+ end
2888
+
2889
+ def test_version_defined
2890
+ assert_match(/\\A\\d+\\.\\d+\\.\\d+\\z/, Brainiac::Plugins::#{pascal_name}::VERSION)
2891
+ end
2892
+
2893
+ def test_cli_method_exists
2894
+ assert_respond_to Brainiac::Plugins::#{pascal_name}, :cli
2895
+ end
2896
+
2897
+ def test_configured_returns_boolean
2898
+ result = Brainiac::Plugins::#{pascal_name}.configured?
2899
+ assert_includes [true, false], result
2900
+ end
2901
+
2902
+ def test_help_text_defined
2903
+ text = Brainiac::Plugins::#{pascal_name}.help_text
2904
+ assert_kind_of String, text
2905
+ assert_includes text, "brainiac #{plugin_name}"
2906
+ end
2907
+ end
2908
+ RUBY
2909
+
2910
+ # README
2911
+ File.write(File.join(output_dir, "README.md"), <<~MD)
2912
+ # #{gem_name}
2913
+
2914
+ #{pascal_name} plugin for [Brainiac](https://github.com/stowzilla/brainiac).
2915
+
2916
+ ## Installation
2917
+
2918
+ ```bash
2919
+ brainiac install #{plugin_name}
2920
+ brainiac restart
2921
+ ```
2922
+
2923
+ Or for local development:
2924
+
2925
+ ```bash
2926
+ brainiac install #{plugin_name} --path #{output_dir}
2927
+ brainiac restart
2928
+ ```
2929
+
2930
+ ## Configuration
2931
+
2932
+ ```bash
2933
+ brainiac #{plugin_name} setup
2934
+ ```
2935
+
2936
+ ## Development
2937
+
2938
+ ```bash
2939
+ bundle install
2940
+ rake test
2941
+ rake rubocop
2942
+ ```
2943
+
2944
+ ## License
2945
+
2946
+ MIT
2947
+ MD
2948
+
2949
+ # Plugin development SKILL.md — stamped into the scaffold so agents
2950
+ # working on this plugin know the contract without needing brain access.
2951
+ File.write(File.join(output_dir, "skills", plugin_name, "SKILL.md"),
2952
+ generate_plugin_skill_md(plugin_name, pascal_name))
2953
+
2954
+ puts ""
2955
+ puts "✓ Created #{gem_name} at #{output_dir}"
2956
+ puts ""
2957
+ puts "Next steps:"
2958
+ puts " cd #{output_dir}"
2959
+ puts " bundle install"
2960
+ puts " rake test # Verify scaffold works"
2961
+ puts " # Edit lib/brainiac/plugins/#{plugin_name}.rb to add your logic"
2962
+ puts " brainiac install #{plugin_name} --path #{output_dir}"
2963
+ puts " brainiac restart"
2964
+
2965
+ else
2966
+ puts "Usage: brainiac plugin <command>"
2967
+ puts ""
2968
+ puts "Commands:"
2969
+ puts " new <name> Generate a new plugin gem skeleton"
2970
+ puts ""
2971
+ puts "Managing installed plugins:"
2972
+ puts " brainiac install <name> Install a plugin"
2973
+ puts " brainiac uninstall <name> Remove a plugin"
2974
+ puts " brainiac plugins List installed plugins"
2975
+ end
2976
+
2359
2977
  when "version", "--version", "-v"
2360
2978
  puts "brainiac #{BRAINIAC_VERSION}"
2361
2979
 
2362
2980
  when "help", "--help", "-h", nil
2981
+ # Load installed plugin help dynamically
2982
+ plugin_help_lines = collect_plugin_help_lines
2983
+
2984
+ plugin_section = if plugin_help_lines.any?
2985
+ "\n Installed Plugins:\n#{plugin_help_lines.join("\n")}\n"
2986
+ else
2987
+ ""
2988
+ end
2989
+
2363
2990
  puts <<~HELP
2364
- Brainiac CLI - Manage projects with Brainiac server
2365
-
2366
- Usage:
2367
- brainiac setup Bootstrap a new machine (deps, dirs, configs)
2368
- brainiac server [options] Start the Brainiac webhook server (foreground)
2369
- brainiac stop Stop the running server
2370
- brainiac restart Restart the server
2371
- brainiac logs Tail the server log
2372
- brainiac status Check if server is running
2373
- brainiac register [options] Register current directory as a project
2374
- brainiac unregister <key> Unregister a project
2375
- brainiac list List all registered projects
2376
- brainiac projects list List all registered projects (alias)
2377
- brainiac projects default <key> Set the default project
2378
- brainiac show <key> Show project configuration
2379
- brainiac install <plugin> Install a Brainiac plugin (gem-based handler)
2380
- brainiac uninstall <plugin> Remove an installed plugin
2381
- brainiac plugins List installed plugins
2382
- brainiac brain <command> Manage agent long-term memory (brain)
2383
- brainiac discord <command> Manage the Discord bot
2384
- brainiac cron <command> Manage scheduled agent tasks
2385
- brainiac provider <command> Manage CLI providers
2386
- brainiac role <command> Manage agent roles
2387
- brainiac agent <command> Manage agent registry (env, list, show)
2388
- brainiac config Configure Brainiac CLI
2389
- brainiac path Show Brainiac config directory
2390
- brainiac version Show version
2391
- brainiac help Show this help message
2392
-
2393
- Server Options:
2394
- -d, --daemon Run server in background (detached)
2395
-
2396
- Plugin Commands:
2397
- install <name> Install a plugin (gem install brainiac-<name>)
2398
- uninstall <name> Remove a plugin
2399
- plugins List installed plugins
2400
-
2401
- Card Map Commands:
2402
- card-map list Show all card map entries
2403
- card-map clean Remove stale entries (missing worktrees)
2404
-
2405
- Brain Commands:
2406
- brain init [agent] Initialize brain (knowledge + persona)
2407
- brain status [agent] Show brain status and files
2408
- brain search [--persona] <q> Search knowledge or persona
2409
- brain list List knowledge and all personas
2410
- brain path Show knowledge directory
2411
-
2412
- Cron Commands:
2413
- cron add -s <schedule> -p <project> "<prompt>"
2414
- Add a scheduled task
2415
- cron list List all cron jobs
2416
- cron remove <job-id> Remove a cron job
2417
- cron enable <job-id> Enable a cron job
2418
- cron disable <job-id> Disable a cron job
2419
- cron update <job-id> -s <schedule>
2420
- Update a cron job's schedule
2421
-
2422
- Discord Commands:
2423
- discord config Show Discord config
2424
- discord default <proj> Set default project for all channels
2425
- discord map <ch> <proj> Map a channel to a project
2426
- discord owner [<id>] Set/show machine owner (version notifications)
2427
- discord token <agent> <token> Set Discord bot token for an agent
2428
- discord agents List agents with Discord bot tokens
2429
- discord status Check bot status via server API
2430
-
2431
- Provider Commands:
2432
- provider list List configured CLI providers
2433
- provider show <name> Show provider configuration
2434
- provider add <name> Create a new provider config
2435
-
2436
- Role Commands:
2437
- role list List configured roles and assignments
2438
- role show <name> Show role definition
2439
- role create <name> Create a new role file
2440
- role assign <agent> <role> Assign a role to an agent (additive)
2441
- role unassign <agent> <role> Remove a role from an agent
2442
-
2443
- Agent Commands:
2444
- agent list List all agents in the registry
2445
- agent <name> show Show agent configuration (tokens redacted)
2446
- agent <name> env <KEY> <VALUE> Set an env var for an agent
2447
- agent <name> env List env vars for an agent
2448
- agent <name> env --delete <KEY> Remove an env var from an agent
2449
-
2450
- Examples:
2451
- # Start the server in the foreground (like rails server)
2452
- brainiac server
2453
- brainiac s
2454
- #{" "}
2455
- # Start the server in background (detached)
2456
- brainiac server --daemon
2457
- brainiac server -d
2458
- #{" "}
2459
- # Tail the server logs
2460
- brainiac logs
2461
- #{" "}
2462
- # Restart the server
2463
- brainiac restart
2464
- #{" "}
2465
- # Stop the server
2466
- brainiac stop
2467
- #{" "}
2468
- # Check server status
2469
- brainiac status
2470
- #{" "}
2471
- # Register current directory
2472
- cd ~/Code/my-project && brainiac register
2473
- #{" "}
2474
- # Register with specific path
2475
- brainiac register --path ~/Code/my-project
2476
- #{" "}
2477
- # List all projects
2478
- brainiac list
2479
- brainiac projects list # alternative syntax
2480
- #{" "}
2481
- # Set default project
2482
- brainiac projects default my-project
2483
- #{" "}
2484
- # Unregister a project
2485
- brainiac unregister my-project
2486
- #{" "}
2487
- # Show project details
2488
- brainiac show my-project
2489
- #{" "}
2490
- # Initialize brain for your agent
2491
- brainiac brain init Galen
2492
- #{" "}
2493
- # Search the brain
2494
- brainiac brain search "ruby conventions"
2495
-
2496
- Configuration:
2497
- Projects are stored in: #{BRAINIAC_DIR}/projects.json
2498
- Server config: #{BRAINIAC_DIR}/config.json
2499
- Brain storage: #{BRAINIAC_DIR}/brain/
2991
+ Brainiac CLI - Manage projects with Brainiac server
2992
+
2993
+ Usage:
2994
+ brainiac setup Bootstrap a new machine (deps, dirs, configs)
2995
+ brainiac server [options] Start the Brainiac webhook server (foreground)
2996
+ brainiac stop Stop the running server
2997
+ brainiac restart Restart the server
2998
+ brainiac logs Tail the server log
2999
+ brainiac status Check if server is running
3000
+ brainiac register [options] Register current directory as a project
3001
+ brainiac unregister <key> Unregister a project
3002
+ brainiac list List all registered projects
3003
+ brainiac projects list List all registered projects (alias)
3004
+ brainiac projects default <key> Set the default project
3005
+ brainiac show <key> Show project configuration
3006
+ brainiac plugin new <name> Generate a new plugin gem skeleton
3007
+ brainiac install <plugin> Install a Brainiac plugin (gem-based handler)
3008
+ brainiac uninstall <plugin> Remove an installed plugin
3009
+ brainiac plugins List installed plugins
3010
+ brainiac brain <command> Manage agent long-term memory (brain)
3011
+ brainiac cron <command> Manage scheduled agent tasks
3012
+ brainiac provider <command> Manage CLI providers
3013
+ brainiac role <command> Manage agent roles
3014
+ brainiac agent <command> Manage agent registry (env, list, show)
3015
+ brainiac config Configure Brainiac CLI
3016
+ brainiac path Show Brainiac config directory
3017
+ brainiac version Show version
3018
+ brainiac help Show this help message
3019
+ #{plugin_section}
3020
+ Server Options:
3021
+ -d, --daemon Run server in background (detached)
3022
+
3023
+ Plugin Commands:
3024
+ plugin new <name> Generate a new plugin gem skeleton
3025
+ install <name> Install a plugin (gem install brainiac-<name>)
3026
+ uninstall <name> Remove a plugin
3027
+ plugins List installed plugins
3028
+
3029
+ Card Map Commands:
3030
+ card-map list Show all card map entries
3031
+ card-map clean Remove stale entries (missing worktrees)
3032
+
3033
+ Brain Commands:
3034
+ brain init [agent] Initialize brain (knowledge + persona)
3035
+ brain status [agent] Show brain status and files
3036
+ brain search [--persona] <q> Search knowledge or persona
3037
+ brain list List knowledge and all personas
3038
+ brain path Show knowledge directory
3039
+
3040
+ Cron Commands:
3041
+ cron add -s <schedule> -p <project> "<prompt>"
3042
+ Add a scheduled task
3043
+ cron list List all cron jobs
3044
+ cron remove <job-id> Remove a cron job
3045
+ cron enable <job-id> Enable a cron job
3046
+ cron disable <job-id> Disable a cron job
3047
+ cron update <job-id> -s <schedule>
3048
+ Update a cron job's schedule
3049
+
3050
+ Provider Commands:
3051
+ provider list List configured CLI providers
3052
+ provider show <name> Show provider configuration
3053
+ provider add <name> Create a new provider config
3054
+
3055
+ Role Commands:
3056
+ role list List configured roles and assignments
3057
+ role show <name> Show role definition
3058
+ role create <name> Create a new role file
3059
+ role assign <agent> <role> Assign a role to an agent (additive)
3060
+ role unassign <agent> <role> Remove a role from an agent
3061
+
3062
+ Agent Commands:
3063
+ agent list List all agents in the registry
3064
+ agent <name> show Show agent configuration (tokens redacted)
3065
+ agent <name> env <KEY> <VALUE> Set an env var for an agent
3066
+ agent <name> env List env vars for an agent
3067
+ agent <name> env --delete <KEY> Remove an env var from an agent
3068
+
3069
+ Examples:
3070
+ # Start the server in the foreground (like rails server)
3071
+ brainiac server
3072
+ brainiac s
3073
+ #{" "}
3074
+ # Register current directory
3075
+ cd ~/Code/my-project && brainiac register
3076
+ #{" "}
3077
+ # Install a plugin
3078
+ brainiac install discord
3079
+ #{" "}
3080
+ # List all projects
3081
+ brainiac list
3082
+
3083
+ Configuration:
3084
+ Projects are stored in: #{BRAINIAC_DIR}/projects.json
3085
+ Server config: #{BRAINIAC_DIR}/config.json
3086
+ Brain storage: #{BRAINIAC_DIR}/brain/
2500
3087
  HELP
2501
3088
 
2502
3089
  else
2503
- puts "Unknown command: #{subcommand}"
2504
- puts "Run 'brainiac help' for usage information."
2505
- exit 1
3090
+ # Check if the subcommand matches an installed plugin (e.g. "brainiac discord ...")
3091
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
3092
+ plugins_config = File.exist?(plugins_file) ? JSON.parse(File.read(plugins_file)) : { "plugins" => [] }
3093
+ (plugins_config["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
3094
+ plugin_entry = (plugins_config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == subcommand }
3095
+
3096
+ if plugin_entry
3097
+ # Delegate to the plugin's CLI module
3098
+ gem_name = "brainiac-#{subcommand}"
3099
+ begin
3100
+ if plugin_entry.is_a?(Hash) && plugin_entry["path"]
3101
+ lib_path = File.join(plugin_entry["path"], "lib")
3102
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
3103
+ end
3104
+
3105
+ # Try lightweight CLI loading first (metadata + cli only, no server runtime)
3106
+ cli_loaded = try_require_plugin_cli(subcommand, plugin_entry)
3107
+
3108
+ # Fall back to full gem load if no standalone CLI file exists
3109
+ unless cli_loaded
3110
+ begin
3111
+ require gem_name
3112
+ rescue LoadError
3113
+ require gem_name.tr("-", "_")
3114
+ end
3115
+ end
3116
+
3117
+ # Resolve the plugin module and call .cli(ARGV)
3118
+ mod = resolve_plugin_module_for(subcommand)
3119
+
3120
+ if mod.respond_to?(:cli)
3121
+ mod.cli(ARGV)
3122
+ else
3123
+ puts "Plugin '#{subcommand}' is installed but does not provide CLI commands."
3124
+ puts "It runs as a server plugin — use 'brainiac server' to activate it."
3125
+ end
3126
+ rescue LoadError => e
3127
+ puts "Plugin '#{subcommand}' is registered but could not be loaded: #{e.message}"
3128
+ puts "Try reinstalling: brainiac uninstall #{subcommand} && brainiac install #{subcommand}"
3129
+ exit 1
3130
+ end
3131
+ else
3132
+ puts "Unknown command: #{subcommand}"
3133
+ puts "Run 'brainiac help' for usage information."
3134
+ exit 1
3135
+ end
2506
3136
  end