brainiac 0.0.9 → 0.0.11

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"]}"
@@ -2145,6 +2277,96 @@ when "completions"
2145
2277
  puts File.read(completion_file)
2146
2278
  end
2147
2279
 
2280
+ when "update", "upgrade"
2281
+ name = ARGV.shift
2282
+
2283
+ if name
2284
+ # Update a specific plugin: brainiac update <plugin_name>
2285
+ name = name.sub(/^brainiac-/, "")
2286
+ gem_name = "brainiac-#{name}"
2287
+
2288
+ # Verify plugin is installed
2289
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
2290
+ plugins_config = if File.exist?(plugins_file)
2291
+ JSON.parse(File.read(plugins_file))
2292
+ else
2293
+ { "plugins" => [] }
2294
+ end
2295
+
2296
+ plugin_entry = (plugins_config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == name }
2297
+
2298
+ if plugin_entry && plugin_entry.is_a?(Hash) && plugin_entry["path"]
2299
+ puts "Plugin '#{name}' is installed from a local path: #{plugin_entry["path"]}"
2300
+ puts "Local plugins are updated via git pull in their source directory."
2301
+ exit 0
2302
+ end
2303
+
2304
+ unless plugin_entry
2305
+ puts "Plugin '#{name}' is not installed."
2306
+ puts " Install it first: brainiac install #{name}"
2307
+ exit 1
2308
+ end
2309
+
2310
+ # Get current version before update
2311
+ current_version = begin
2312
+ spec = Gem::Specification.find_by_name(gem_name)
2313
+ spec.version.to_s
2314
+ rescue Gem::MissingSpecError
2315
+ nil
2316
+ end
2317
+
2318
+ puts "Updating #{gem_name}..."
2319
+ system("gem install #{gem_name}")
2320
+ unless $CHILD_STATUS.success?
2321
+ puts ""
2322
+ puts "Failed to update #{gem_name}."
2323
+ exit 1
2324
+ end
2325
+
2326
+ # Show version change
2327
+ Gem::Specification.reset
2328
+ new_version = begin
2329
+ spec = Gem::Specification.find_by_name(gem_name)
2330
+ spec.version.to_s
2331
+ rescue Gem::MissingSpecError
2332
+ "unknown"
2333
+ end
2334
+
2335
+ if current_version && current_version == new_version
2336
+ puts "✓ #{gem_name} is already at the latest version (#{new_version})"
2337
+ else
2338
+ puts "✓ Updated #{gem_name}: #{current_version || "new"} → #{new_version}"
2339
+ puts " Restart the server to activate: brainiac restart"
2340
+ end
2341
+ else
2342
+ # Update brainiac itself: brainiac update
2343
+ current_version = BRAINIAC_VERSION
2344
+
2345
+ puts "Updating brainiac gem..."
2346
+ system("gem install brainiac")
2347
+ unless $CHILD_STATUS.success?
2348
+ puts ""
2349
+ puts "Failed to update brainiac."
2350
+ exit 1
2351
+ end
2352
+
2353
+ # Show version change
2354
+ Gem::Specification.reset
2355
+ new_version = begin
2356
+ spec = Gem::Specification.find_by_name("brainiac")
2357
+ spec.version.to_s
2358
+ rescue Gem::MissingSpecError
2359
+ "unknown"
2360
+ end
2361
+
2362
+ if current_version == new_version
2363
+ puts "✓ brainiac is already at the latest version (#{new_version})"
2364
+ else
2365
+ puts "✓ Updated brainiac: #{current_version} → #{new_version}"
2366
+ puts " Restart the server to apply: brainiac restart"
2367
+ end
2368
+ end
2369
+
2148
2370
  when "install"
2149
2371
  name = ARGV.shift
2150
2372
  unless name
@@ -2229,6 +2451,9 @@ when "install"
2229
2451
  puts "✓ Registered plugin '#{name}' from #{local_path}"
2230
2452
  puts " Restart the server to activate: brainiac restart"
2231
2453
  puts " Note: Source changes take effect on next server restart."
2454
+
2455
+ # Auto-run setup if plugin defines it and isn't already configured
2456
+ run_plugin_setup(name, local_path: local_path)
2232
2457
  else
2233
2458
  # RubyGems mode
2234
2459
  puts "Installing #{gem_name}..."
@@ -2259,6 +2484,9 @@ when "install"
2259
2484
  puts ""
2260
2485
  puts "✓ Installed plugin '#{name}' (#{gem_name})"
2261
2486
  puts " Restart the server to activate: brainiac restart"
2487
+
2488
+ # Auto-run setup if plugin defines it and isn't already configured
2489
+ run_plugin_setup(name)
2262
2490
  end
2263
2491
 
2264
2492
  when "uninstall"
@@ -2356,151 +2584,646 @@ when "plugins"
2356
2584
  puts " brainiac plugins List installed plugins"
2357
2585
  end
2358
2586
 
2587
+ when "plugin"
2588
+ plugin_cmd = ARGV.shift
2589
+
2590
+ case plugin_cmd
2591
+ when "new"
2592
+ plugin_name = ARGV.shift
2593
+ unless plugin_name
2594
+ puts "Usage: brainiac plugin new <name>"
2595
+ puts ""
2596
+ puts "Generates a new Brainiac plugin gem skeleton."
2597
+ puts ""
2598
+ puts "Example:"
2599
+ puts " brainiac plugin new slack"
2600
+ puts " → Creates ~/Code/brainiac-slack/ with full gem structure"
2601
+ exit 1
2602
+ end
2603
+
2604
+ plugin_name = plugin_name.downcase.gsub(/[^a-z0-9-]/, "-").sub(/^brainiac-/, "")
2605
+ gem_name = "brainiac-#{plugin_name}"
2606
+ pascal_name = plugin_name.split(/[-_]/).map(&:capitalize).join
2607
+ snake_name = plugin_name.tr("-", "_")
2608
+
2609
+ # Determine output directory
2610
+ output_dir = ARGV.shift || File.join(Dir.pwd, gem_name)
2611
+ output_dir = File.expand_path(output_dir)
2612
+
2613
+ if Dir.exist?(output_dir) && !Dir.empty?(output_dir)
2614
+ puts "Error: Directory already exists and is not empty: #{output_dir}"
2615
+ exit 1
2616
+ end
2617
+
2618
+ puts "Creating #{gem_name} at #{output_dir}..."
2619
+
2620
+ # Create directory structure
2621
+ dirs = [
2622
+ "lib/brainiac/plugins/#{plugin_name}",
2623
+ "test",
2624
+ "skills/#{plugin_name}"
2625
+ ]
2626
+ dirs.each { |d| FileUtils.mkdir_p(File.join(output_dir, d)) }
2627
+
2628
+ # version.rb
2629
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "version.rb"), <<~RUBY)
2630
+ # frozen_string_literal: true
2631
+
2632
+ module Brainiac
2633
+ module Plugins
2634
+ module #{pascal_name}
2635
+ VERSION = "0.0.1"
2636
+ end
2637
+ end
2638
+ end
2639
+ RUBY
2640
+
2641
+ # metadata.rb
2642
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "metadata.rb"), <<~RUBY)
2643
+ # frozen_string_literal: true
2644
+
2645
+ # Lightweight metadata — loaded by `brainiac help` without the full plugin runtime.
2646
+
2647
+ require_relative "version"
2648
+
2649
+ module Brainiac
2650
+ module Plugins
2651
+ module #{pascal_name}
2652
+ def self.configured?
2653
+ # TODO: Return true when the plugin has been configured
2654
+ false
2655
+ end
2656
+
2657
+ def self.help_text
2658
+ " brainiac #{plugin_name} <command>#{" " * [1, 20 - plugin_name.length].max}Manage #{pascal_name} integration"
2659
+ end
2660
+ end
2661
+ end
2662
+ end
2663
+ RUBY
2664
+
2665
+ # cli.rb
2666
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", plugin_name, "cli.rb"), <<~RUBY)
2667
+ # frozen_string_literal: true
2668
+
2669
+ require "json"
2670
+
2671
+ module Brainiac
2672
+ module Plugins
2673
+ module #{pascal_name}
2674
+ module Cli
2675
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
2676
+
2677
+ class << self
2678
+ def run(args)
2679
+ command = args.shift
2680
+
2681
+ case command
2682
+ when "config"
2683
+ cmd_config
2684
+ when "setup"
2685
+ cmd_setup
2686
+ when "status"
2687
+ cmd_status
2688
+ else
2689
+ print_help
2690
+ end
2691
+ end
2692
+
2693
+ private
2694
+
2695
+ def cmd_config
2696
+ puts "TODO: Show #{plugin_name} configuration"
2697
+ end
2698
+
2699
+ def cmd_setup
2700
+ puts "#{pascal_name} Setup"
2701
+ puts "#{"=" * (pascal_name.length + 6)}"
2702
+ puts ""
2703
+ puts "TODO: Walk user through configuration"
2704
+ end
2705
+
2706
+ def cmd_status
2707
+ puts "TODO: Show #{plugin_name} status"
2708
+ end
2709
+
2710
+ def print_help
2711
+ puts <<~HELP
2712
+ Usage: brainiac #{plugin_name} <command>
2713
+
2714
+ Commands:
2715
+ config Show #{pascal_name} config
2716
+ setup Interactive setup guide
2717
+ status Check #{pascal_name} status via server API
2718
+
2719
+ Config file: #{File.join("~/.brainiac", "#{plugin_name}.json")}
2720
+ HELP
2721
+ end
2722
+ end
2723
+ end
2724
+
2725
+ def self.cli(args)
2726
+ Cli.run(args)
2727
+ end
2728
+
2729
+ # Subcommand names for bash completion.
2730
+ def self.completions
2731
+ %w[config setup status]
2732
+ end
2733
+ end
2734
+ end
2735
+ end
2736
+ RUBY
2737
+
2738
+ # Main plugin module
2739
+ File.write(File.join(output_dir, "lib", "brainiac", "plugins", "#{plugin_name}.rb"), <<~RUBY)
2740
+ # frozen_string_literal: true
2741
+
2742
+ require_relative "#{plugin_name}/version"
2743
+ require_relative "#{plugin_name}/metadata"
2744
+ require_relative "#{plugin_name}/cli"
2745
+
2746
+ module Brainiac
2747
+ module Plugins
2748
+ module #{pascal_name}
2749
+ class << self
2750
+ # Called by Brainiac plugin system during server startup.
2751
+ #
2752
+ # @param app [Sinatra::Application] The running Brainiac server
2753
+ def register(app)
2754
+ # TODO: Load config, register hooks, define routes, start threads
2755
+ setup_routes(app)
2756
+
2757
+ LOG.info "[#{pascal_name}] Plugin registered"
2758
+ end
2759
+
2760
+ private
2761
+
2762
+ def setup_routes(app)
2763
+ app.get "/api/#{plugin_name}" do
2764
+ content_type :json
2765
+ { enabled: true }.to_json
2766
+ end
2767
+
2768
+ # TODO: Define webhook route
2769
+ # app.post "/#{plugin_name}" do
2770
+ # content_type :json
2771
+ # { status: "received" }.to_json
2772
+ # end
2773
+ end
2774
+ end
2775
+ end
2776
+ end
2777
+ end
2778
+ RUBY
2779
+
2780
+ # Entry point (loaded by RubyGems)
2781
+ File.write(File.join(output_dir, "lib", "brainiac_#{snake_name}.rb"), <<~RUBY)
2782
+ # frozen_string_literal: true
2783
+
2784
+ # Brainiac #{pascal_name} Plugin — entry point loaded by RubyGems.
2785
+ require_relative "brainiac/plugins/#{plugin_name}"
2786
+ RUBY
2787
+
2788
+ # Gemspec
2789
+ File.write(File.join(output_dir, "#{gem_name}.gemspec"), <<~RUBY)
2790
+ # frozen_string_literal: true
2791
+
2792
+ require_relative "lib/brainiac/plugins/#{plugin_name}/version"
2793
+
2794
+ Gem::Specification.new do |s|
2795
+ s.name = "#{gem_name}"
2796
+ s.version = Brainiac::Plugins::#{pascal_name}::VERSION
2797
+ s.summary = "#{pascal_name} plugin for Brainiac"
2798
+ s.description = "#{pascal_name} integration for Brainiac — TODO: describe what this plugin does."
2799
+ s.authors = ["Your Name"]
2800
+ s.homepage = "https://github.com/yourorg/#{gem_name}"
2801
+ s.license = "MIT"
2802
+ s.required_ruby_version = ">= 3.4"
2803
+
2804
+ s.files = Dir["lib/**/*.rb", "README.md", "LICENSE"]
2805
+ s.require_paths = ["lib"]
2806
+
2807
+ s.add_dependency "brainiac", ">= #{BRAINIAC_VERSION}"
2808
+
2809
+ s.add_development_dependency "minitest", "~> 5.25"
2810
+ s.add_development_dependency "rake", "~> 13.0"
2811
+ s.add_development_dependency "rubocop", "~> 1.75"
2812
+ s.add_development_dependency "rubocop-performance", "~> 1.25"
2813
+
2814
+ s.metadata["rubygems_mfa_required"] = "true"
2815
+ end
2816
+ RUBY
2817
+
2818
+ # Gemfile
2819
+ File.write(File.join(output_dir, "Gemfile"), <<~RUBY)
2820
+ # frozen_string_literal: true
2821
+
2822
+ source "https://rubygems.org"
2823
+
2824
+ gemspec
2825
+ RUBY
2826
+
2827
+ # Rakefile
2828
+ File.write(File.join(output_dir, "Rakefile"), <<~RUBY)
2829
+ # frozen_string_literal: true
2830
+
2831
+ require "rake/testtask"
2832
+ require "rubocop/rake_task"
2833
+
2834
+ Rake::TestTask.new(:test) do |t|
2835
+ t.libs << "test"
2836
+ t.libs << "lib"
2837
+ t.test_files = FileList["test/**/test_*.rb"]
2838
+ end
2839
+
2840
+ RuboCop::RakeTask.new(:rubocop)
2841
+
2842
+ task default: %i[test rubocop]
2843
+ RUBY
2844
+
2845
+ # .rubocop.yml
2846
+ File.write(File.join(output_dir, ".rubocop.yml"), <<~YAML)
2847
+ plugins:
2848
+ - rubocop-performance
2849
+
2850
+ AllCops:
2851
+ TargetRubyVersion: 3.4
2852
+ NewCops: enable
2853
+ SuggestExtensions: false
2854
+ Exclude:
2855
+ - "vendor/**/*"
2856
+ - "tmp/**/*"
2857
+
2858
+ Metrics/MethodLength:
2859
+ Max: 75
2860
+
2861
+ Metrics/AbcSize:
2862
+ Max: 90
2863
+
2864
+ Metrics/ModuleLength:
2865
+ Max: 600
2866
+
2867
+ Metrics/ParameterLists:
2868
+ Max: 10
2869
+ CountKeywordArgs: false
2870
+
2871
+ Metrics/BlockLength:
2872
+ Exclude:
2873
+ - "test/**/*"
2874
+ - "*.gemspec"
2875
+
2876
+ Style/TopLevelMethodDefinition:
2877
+ Enabled: false
2878
+
2879
+ Style/StringLiterals:
2880
+ EnforcedStyle: double_quotes
2881
+
2882
+ Style/FrozenStringLiteralComment:
2883
+ Enabled: false
2884
+
2885
+ Style/ClassAndModuleChildren:
2886
+ Enabled: false
2887
+
2888
+ Style/Documentation:
2889
+ Enabled: false
2890
+
2891
+ Gemspec/DevelopmentDependencies:
2892
+ Enabled: false
2893
+
2894
+ Layout/LineLength:
2895
+ Max: 150
2896
+
2897
+ Naming/PredicateMethod:
2898
+ Enabled: false
2899
+
2900
+ Naming/FileName:
2901
+ Exclude:
2902
+ - "lib/brainiac/plugins/**/*"
2903
+ YAML
2904
+
2905
+ # Test helper
2906
+ File.write(File.join(output_dir, "test", "test_helper.rb"), <<~RUBY)
2907
+ # frozen_string_literal: true
2908
+
2909
+ require "minitest/autorun"
2910
+ require "json"
2911
+ require "fileutils"
2912
+ require "tmpdir"
2913
+
2914
+ TEST_BRAINIAC_DIR = Dir.mktmpdir("brainiac-#{plugin_name}-test")
2915
+ ENV["BRAINIAC_DIR"] = TEST_BRAINIAC_DIR
2916
+
2917
+ unless defined?(LOG)
2918
+ LOG = Class.new do
2919
+ def info(_msg) = nil
2920
+ def warn(_msg) = nil
2921
+ def error(_msg) = nil
2922
+ def debug(_msg) = nil
2923
+ def debug? = false
2924
+ end.new
2925
+ end
2926
+
2927
+ module Brainiac
2928
+ @hooks = Hash.new { |h, k| h[k] = [] }
2929
+ @channel_prompts = {}
2930
+ @channel_pre_post_checks = {}
2931
+
2932
+ class << self
2933
+ def on(event, &block) = @hooks[event] << block
2934
+
2935
+ def emit(event, **ctx)
2936
+ @hooks[event].filter_map do |h|
2937
+ h.call(ctx)
2938
+ rescue StandardError
2939
+ nil
2940
+ end
2941
+ end
2942
+
2943
+ def register_channel_prompt(channel, prompt, pre_post_check: nil)
2944
+ @channel_prompts[channel] = prompt
2945
+ @channel_pre_post_checks[channel] = pre_post_check if pre_post_check
2946
+ end
2947
+
2948
+ attr_reader :hooks, :channel_prompts, :channel_pre_post_checks
2949
+
2950
+ def reset_hooks!
2951
+ @hooks = Hash.new { |h, k| h[k] = [] }
2952
+ @channel_prompts = {}
2953
+ @channel_pre_post_checks = {}
2954
+ end
2955
+ end
2956
+
2957
+ module Plugins; end
2958
+ end
2959
+
2960
+ AGENT_REGISTRY = {}.freeze
2961
+ PROJECTS = {}.freeze
2962
+
2963
+ require_relative "../lib/brainiac_#{snake_name}"
2964
+
2965
+ Minitest.after_run { FileUtils.rm_rf(TEST_BRAINIAC_DIR) }
2966
+ RUBY
2967
+
2968
+ # Test file
2969
+ File.write(File.join(output_dir, "test", "test_#{snake_name}.rb"), <<~RUBY)
2970
+ # frozen_string_literal: true
2971
+
2972
+ require_relative "test_helper"
2973
+
2974
+ class Test#{pascal_name}Plugin < Minitest::Test
2975
+ def test_register_method_exists
2976
+ assert_respond_to Brainiac::Plugins::#{pascal_name}, :register
2977
+ end
2978
+
2979
+ def test_version_defined
2980
+ assert_match(/\\A\\d+\\.\\d+\\.\\d+\\z/, Brainiac::Plugins::#{pascal_name}::VERSION)
2981
+ end
2982
+
2983
+ def test_cli_method_exists
2984
+ assert_respond_to Brainiac::Plugins::#{pascal_name}, :cli
2985
+ end
2986
+
2987
+ def test_configured_returns_boolean
2988
+ result = Brainiac::Plugins::#{pascal_name}.configured?
2989
+ assert_includes [true, false], result
2990
+ end
2991
+
2992
+ def test_help_text_defined
2993
+ text = Brainiac::Plugins::#{pascal_name}.help_text
2994
+ assert_kind_of String, text
2995
+ assert_includes text, "brainiac #{plugin_name}"
2996
+ end
2997
+ end
2998
+ RUBY
2999
+
3000
+ # README
3001
+ File.write(File.join(output_dir, "README.md"), <<~MD)
3002
+ # #{gem_name}
3003
+
3004
+ #{pascal_name} plugin for [Brainiac](https://github.com/stowzilla/brainiac).
3005
+
3006
+ ## Installation
3007
+
3008
+ ```bash
3009
+ brainiac install #{plugin_name}
3010
+ brainiac restart
3011
+ ```
3012
+
3013
+ Or for local development:
3014
+
3015
+ ```bash
3016
+ brainiac install #{plugin_name} --path #{output_dir}
3017
+ brainiac restart
3018
+ ```
3019
+
3020
+ ## Configuration
3021
+
3022
+ ```bash
3023
+ brainiac #{plugin_name} setup
3024
+ ```
3025
+
3026
+ ## Development
3027
+
3028
+ ```bash
3029
+ bundle install
3030
+ rake test
3031
+ rake rubocop
3032
+ ```
3033
+
3034
+ ## License
3035
+
3036
+ MIT
3037
+ MD
3038
+
3039
+ # Plugin development SKILL.md — stamped into the scaffold so agents
3040
+ # working on this plugin know the contract without needing brain access.
3041
+ File.write(File.join(output_dir, "skills", plugin_name, "SKILL.md"),
3042
+ generate_plugin_skill_md(plugin_name, pascal_name))
3043
+
3044
+ puts ""
3045
+ puts "✓ Created #{gem_name} at #{output_dir}"
3046
+ puts ""
3047
+ puts "Next steps:"
3048
+ puts " cd #{output_dir}"
3049
+ puts " bundle install"
3050
+ puts " rake test # Verify scaffold works"
3051
+ puts " # Edit lib/brainiac/plugins/#{plugin_name}.rb to add your logic"
3052
+ puts " brainiac install #{plugin_name} --path #{output_dir}"
3053
+ puts " brainiac restart"
3054
+
3055
+ else
3056
+ puts "Usage: brainiac plugin <command>"
3057
+ puts ""
3058
+ puts "Commands:"
3059
+ puts " new <name> Generate a new plugin gem skeleton"
3060
+ puts ""
3061
+ puts "Managing installed plugins:"
3062
+ puts " brainiac install <name> Install a plugin"
3063
+ puts " brainiac uninstall <name> Remove a plugin"
3064
+ puts " brainiac plugins List installed plugins"
3065
+ end
3066
+
2359
3067
  when "version", "--version", "-v"
2360
3068
  puts "brainiac #{BRAINIAC_VERSION}"
2361
3069
 
2362
3070
  when "help", "--help", "-h", nil
3071
+ # Load installed plugin help dynamically
3072
+ plugin_help_lines = collect_plugin_help_lines
3073
+
3074
+ plugin_section = if plugin_help_lines.any?
3075
+ "\n Installed Plugins:\n#{plugin_help_lines.join("\n")}\n"
3076
+ else
3077
+ ""
3078
+ end
3079
+
2363
3080
  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/
3081
+ Brainiac CLI - Manage projects with Brainiac server
3082
+
3083
+ Usage:
3084
+ brainiac setup Bootstrap a new machine (deps, dirs, configs)
3085
+ brainiac server [options] Start the Brainiac webhook server (foreground)
3086
+ brainiac stop Stop the running server
3087
+ brainiac restart Restart the server
3088
+ brainiac logs Tail the server log
3089
+ brainiac status Check if server is running
3090
+ brainiac register [options] Register current directory as a project
3091
+ brainiac unregister <key> Unregister a project
3092
+ brainiac list List all registered projects
3093
+ brainiac projects list List all registered projects (alias)
3094
+ brainiac projects default <key> Set the default project
3095
+ brainiac show <key> Show project configuration
3096
+ brainiac plugin new <name> Generate a new plugin gem skeleton
3097
+ brainiac update Update brainiac to the latest version
3098
+ brainiac update <plugin> Update an installed plugin to the latest version
3099
+ brainiac install <plugin> Install a Brainiac plugin (gem-based handler)
3100
+ brainiac uninstall <plugin> Remove an installed plugin
3101
+ brainiac plugins List installed plugins
3102
+ brainiac brain <command> Manage agent long-term memory (brain)
3103
+ brainiac cron <command> Manage scheduled agent tasks
3104
+ brainiac provider <command> Manage CLI providers
3105
+ brainiac role <command> Manage agent roles
3106
+ brainiac agent <command> Manage agent registry (env, list, show)
3107
+ brainiac config Configure Brainiac CLI
3108
+ brainiac path Show Brainiac config directory
3109
+ brainiac version Show version
3110
+ brainiac help Show this help message
3111
+ #{plugin_section}
3112
+ Server Options:
3113
+ -d, --daemon Run server in background (detached)
3114
+
3115
+ Plugin Commands:
3116
+ plugin new <name> Generate a new plugin gem skeleton
3117
+ install <name> Install a plugin (gem install brainiac-<name>)
3118
+ uninstall <name> Remove a plugin
3119
+ update [name] Update brainiac or a specific plugin
3120
+ plugins List installed plugins
3121
+
3122
+ Card Map Commands:
3123
+ card-map list Show all card map entries
3124
+ card-map clean Remove stale entries (missing worktrees)
3125
+
3126
+ Brain Commands:
3127
+ brain init [agent] Initialize brain (knowledge + persona)
3128
+ brain status [agent] Show brain status and files
3129
+ brain search [--persona] <q> Search knowledge or persona
3130
+ brain list List knowledge and all personas
3131
+ brain path Show knowledge directory
3132
+
3133
+ Cron Commands:
3134
+ cron add -s <schedule> -p <project> "<prompt>"
3135
+ Add a scheduled task
3136
+ cron list List all cron jobs
3137
+ cron remove <job-id> Remove a cron job
3138
+ cron enable <job-id> Enable a cron job
3139
+ cron disable <job-id> Disable a cron job
3140
+ cron update <job-id> -s <schedule>
3141
+ Update a cron job's schedule
3142
+
3143
+ Provider Commands:
3144
+ provider list List configured CLI providers
3145
+ provider show <name> Show provider configuration
3146
+ provider add <name> Create a new provider config
3147
+
3148
+ Role Commands:
3149
+ role list List configured roles and assignments
3150
+ role show <name> Show role definition
3151
+ role create <name> Create a new role file
3152
+ role assign <agent> <role> Assign a role to an agent (additive)
3153
+ role unassign <agent> <role> Remove a role from an agent
3154
+
3155
+ Agent Commands:
3156
+ agent list List all agents in the registry
3157
+ agent <name> show Show agent configuration (tokens redacted)
3158
+ agent <name> env <KEY> <VALUE> Set an env var for an agent
3159
+ agent <name> env List env vars for an agent
3160
+ agent <name> env --delete <KEY> Remove an env var from an agent
3161
+
3162
+ Examples:
3163
+ # Start the server in the foreground (like rails server)
3164
+ brainiac server
3165
+ brainiac s
3166
+ #{" "}
3167
+ # Register current directory
3168
+ cd ~/Code/my-project && brainiac register
3169
+ #{" "}
3170
+ # Install a plugin
3171
+ brainiac install discord
3172
+ #{" "}
3173
+ # List all projects
3174
+ brainiac list
3175
+
3176
+ Configuration:
3177
+ Projects are stored in: #{BRAINIAC_DIR}/projects.json
3178
+ Server config: #{BRAINIAC_DIR}/config.json
3179
+ Brain storage: #{BRAINIAC_DIR}/brain/
2500
3180
  HELP
2501
3181
 
2502
3182
  else
2503
- puts "Unknown command: #{subcommand}"
2504
- puts "Run 'brainiac help' for usage information."
2505
- exit 1
3183
+ # Check if the subcommand matches an installed plugin (e.g. "brainiac discord ...")
3184
+ plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
3185
+ plugins_config = File.exist?(plugins_file) ? JSON.parse(File.read(plugins_file)) : { "plugins" => [] }
3186
+ (plugins_config["plugins"] || []).map { |p| p.is_a?(Hash) ? p["name"] : p.to_s }
3187
+ plugin_entry = (plugins_config["plugins"] || []).find { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == subcommand }
3188
+
3189
+ if plugin_entry
3190
+ # Delegate to the plugin's CLI module
3191
+ gem_name = "brainiac-#{subcommand}"
3192
+ begin
3193
+ if plugin_entry.is_a?(Hash) && plugin_entry["path"]
3194
+ lib_path = File.join(plugin_entry["path"], "lib")
3195
+ $LOAD_PATH.unshift(lib_path) unless $LOAD_PATH.include?(lib_path)
3196
+ end
3197
+
3198
+ # Try lightweight CLI loading first (metadata + cli only, no server runtime)
3199
+ cli_loaded = try_require_plugin_cli(subcommand, plugin_entry)
3200
+
3201
+ # Fall back to full gem load if no standalone CLI file exists
3202
+ unless cli_loaded
3203
+ begin
3204
+ require gem_name
3205
+ rescue LoadError
3206
+ require gem_name.tr("-", "_")
3207
+ end
3208
+ end
3209
+
3210
+ # Resolve the plugin module and call .cli(ARGV)
3211
+ mod = resolve_plugin_module_for(subcommand)
3212
+
3213
+ if mod.respond_to?(:cli)
3214
+ mod.cli(ARGV)
3215
+ else
3216
+ puts "Plugin '#{subcommand}' is installed but does not provide CLI commands."
3217
+ puts "It runs as a server plugin — use 'brainiac server' to activate it."
3218
+ end
3219
+ rescue LoadError => e
3220
+ puts "Plugin '#{subcommand}' is registered but could not be loaded: #{e.message}"
3221
+ puts "Try reinstalling: brainiac uninstall #{subcommand} && brainiac install #{subcommand}"
3222
+ exit 1
3223
+ end
3224
+ else
3225
+ puts "Unknown command: #{subcommand}"
3226
+ puts "Run 'brainiac help' for usage information."
3227
+ exit 1
3228
+ end
2506
3229
  end