brainiac 0.3.0 → 0.4.1

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 6acdbbb2c88f69d1ca544dca2cb71afb7d4abd3963e4ffb93c1ea90666d4f966
4
- data.tar.gz: 738962e302868862b6be89f4cb65f300b0140dc15bfaf1ff258f286492c9c45e
3
+ metadata.gz: 1edc9fb64e331daacfa390596ce230ee37f6739e91efc72438434110e88d24de
4
+ data.tar.gz: 0fdf11e2eace81f2ce56f6f562e675d7c67598232a23b020fcc982f7ac20db58
5
5
  SHA512:
6
- metadata.gz: c31b44489df80beab3cb6ed1e5bc7308e1e1926d78793bc73bb9c339ab06a98375ee1b8be1b70e031cdd3c1433c2e9e088976afe96343a08449600868eb206a1
7
- data.tar.gz: dabb28067671721b8890aea7a9b5a67d4a3a4bfc434836fcdd41e3cc13c5f7ee05efc6b93acc43dbf09c426b01749d5f68bcb39bfae451a8605ad9e8a4778703
6
+ metadata.gz: ca3e17f678f0207947da8fda7af7711788cacdf377e2496b04c82a1aed303faa3cc57e6c3b32be3227a98bc12b86bb28b25d523ab817514b396a249955cc226a
7
+ data.tar.gz: 2cab777438aa1b245b037a46f6fdf9dcfe16120eca888528dd045f7e70fed699c850bfe76b858601f9f7708a61c213f3be96cb4173a3f2fae81d83f7f4fcc36b
data/CHANGELOG.md CHANGED
@@ -1,5 +1,9 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.2 — 2026-11-02
4
+
5
+ - Add filesystem-search guardrail to PROMPT_CORE — forbids `find /`, `find ~`, `grep -r /`, and `locate`, which hang indefinitely on macOS (autofs, iCloud, sealed sim volumes). Teaches agents to use `gem which` / `Gem.loaded_specs` and scope `find` to known gem/repo roots with a depth limit.
6
+
3
7
  ## 0.9.1 — 2026-04-21
4
8
 
5
9
  - Add subagent delegation section to PROMPT_CORE — teaches agents when/how to use `use_subagent` tool, with `brainiac list` for on-demand project discovery
data/bin/brainiac CHANGED
@@ -15,7 +15,7 @@ BRAINIAC_ROOT = File.expand_path("..", __dir__)
15
15
  require_relative "../lib/brainiac/version"
16
16
  BRAINIAC_VERSION = Brainiac::VERSION
17
17
 
18
- BRAINIAC_DIR = File.join(Dir.home, ".brainiac")
18
+ BRAINIAC_DIR = ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
19
19
  PROJECTS_FILE = File.join(BRAINIAC_DIR, "projects.json")
20
20
  CONFIG_FILE = File.join(BRAINIAC_DIR, "config.json")
21
21
  PID_FILE = File.join(BRAINIAC_DIR, "server.pid")
@@ -193,27 +193,135 @@ rescue StandardError
193
193
  nil
194
194
  end
195
195
 
196
- # Run CLI-time plugin hooks for agent lifecycle events.
197
- # Loads each installed plugin's CLI module and calls the hook method if defined.
198
- # This runs synchronously and can prompt for interactive input.
199
- def notify_plugins_agent_created(agent_key, entry)
196
+ # Installed plugins whose CLI module implements on_agent_created, as
197
+ # [name, module] pairs in plugins.json order.
198
+ def agent_setup_plugins
200
199
  plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
201
- return unless File.exist?(plugins_file)
200
+ return [] unless File.exist?(plugins_file)
202
201
 
203
- plugins_config = JSON.parse(File.read(plugins_file))
204
- (plugins_config["plugins"] || []).each do |pentry|
202
+ (JSON.parse(File.read(plugins_file))["plugins"] || []).filter_map do |pentry|
205
203
  pname = pentry.is_a?(Hash) ? pentry["name"] : pentry.to_s
206
204
  try_require_metadata(pname, pentry)
207
205
  try_require_plugin_cli(pname, pentry)
208
206
  mod = resolve_plugin_module_for(pname)
209
- next unless mod.respond_to?(:on_agent_created)
207
+ [pname, mod] if mod.respond_to?(:on_agent_created)
208
+ rescue StandardError, LoadError => e
209
+ puts " ⚠ Plugin '#{pname}' couldn't load: #{e.message}"
210
+ nil
211
+ end
212
+ rescue JSON::ParserError
213
+ []
214
+ end
215
+
216
+ # Run each plugin's on_agent_created hook (Discord bot, Fizzy user, ...).
217
+ # Runs synchronously and can prompt for interactive input. Plugins in `skip`
218
+ # are left alone; when `only` is given, just those plugins run. With
219
+ # `confirm`, each plugin is asked about first and a "no" is added to `skip`
220
+ # (the caller persists it so `agent setup` won't ask again).
221
+ def notify_plugins_agent_created(agent_key, entry, skip: [], only: nil, confirm: false)
222
+ agent_setup_plugins.each do |pname, mod|
223
+ if only ? !only.include?(pname) : skip.include?(pname)
224
+ puts " [#{pname}] skipped"
225
+ next
226
+ end
227
+
228
+ if confirm
229
+ print "\nSet up #{pname} for #{entry["display_name"] || agent_key}? [Y/n]: "
230
+ if $stdin.gets.to_s.strip.downcase.start_with?("n")
231
+ skip << pname
232
+ next
233
+ end
234
+ end
210
235
 
211
236
  mod.on_agent_created(agent_key, entry)
212
237
  rescue StandardError => e
213
238
  puts " ⚠ Plugin '#{pname}' hook failed: #{e.message}"
214
239
  end
215
- rescue JSON::ParserError
216
- nil
240
+ end
241
+
242
+ # "--skip basecamp,fizzy" style values → ["basecamp", "fizzy"]
243
+ def plugin_list_arg(value)
244
+ value.to_s.split(",").map { |p| p.strip.downcase.delete_prefix("brainiac-") }.reject(&:empty?)
245
+ end
246
+
247
+ AVATAR_EXTENSIONS = %w[.png .jpg .jpeg .gif .webp].freeze
248
+
249
+ # Resolve a description flag value: "-" reads stdin, "@path" reads a file,
250
+ # anything else is the literal text.
251
+ def read_description_arg(value)
252
+ return $stdin.read.strip if value == "-"
253
+ return File.read(File.expand_path(value[1..])).strip if value.start_with?("@")
254
+
255
+ value
256
+ end
257
+
258
+ # Open $VISUAL/$EDITOR on a scratch file so a multi-paragraph persona can be
259
+ # written comfortably. HTML comment lines are instructions and get stripped.
260
+ # Returns nil when the result is blank.
261
+ def edit_description_in_editor(display_name, initial = nil)
262
+ require "tempfile"
263
+ require "shellwords"
264
+ editor = ENV["VISUAL"] || ENV["EDITOR"] || "vi"
265
+ Tempfile.create(["#{display_name.downcase}-persona", ".md"]) do |f|
266
+ f.write(initial || <<~MD)
267
+ <!-- Describe #{display_name}: personality, tone, voice, what they focus on. -->
268
+ <!-- Paragraphs and markdown are fine. Lines like this one are removed. Save and quit when done. -->
269
+
270
+ MD
271
+ f.flush
272
+ system(*Shellwords.split(editor), f.path)
273
+ text = File.read(f.path).gsub(/^\s*<!--.*?-->\s*\n?/, "").strip
274
+ text.empty? ? nil : text
275
+ end
276
+ end
277
+
278
+ # Default agent email from brainiac.json "agent_email_template",
279
+ # e.g. "agent+{key}@example.com". Returns nil when no template is configured.
280
+ def default_agent_email(agent_key, display_name)
281
+ config_path = File.join(BRAINIAC_DIR, "brainiac.json")
282
+ template = File.exist?(config_path) ? JSON.parse(File.read(config_path))["agent_email_template"] : nil
283
+ return nil if template.nil? || template.strip.empty?
284
+
285
+ template.gsub("{key}", agent_key).gsub("{name}", display_name)
286
+ end
287
+
288
+ # Copy an avatar image into the agent's persona dir (synced with the brain)
289
+ # and return the stored path.
290
+ def store_agent_avatar(agent_key, source)
291
+ path = File.expand_path(source)
292
+ ext = File.extname(path).downcase
293
+ raise ArgumentError, "Avatar not found: #{source}" unless File.file?(path)
294
+ raise ArgumentError, "Avatar must be one of #{AVATAR_EXTENSIONS.join(", ")}" unless AVATAR_EXTENSIONS.include?(ext)
295
+
296
+ persona_dir = File.join(BRAINIAC_DIR, "brain", "persona", agent_key)
297
+ FileUtils.mkdir_p(persona_dir)
298
+ dest = File.join(persona_dir, "avatar#{ext}")
299
+ FileUtils.cp(path, dest) unless File.identical?(path, dest)
300
+ dest
301
+ end
302
+
303
+ # The persona text for an agent, read back from its style.md (frontmatter and
304
+ # the generated "# Name — Persona" heading removed).
305
+ def agent_persona_description(agent_key)
306
+ file = File.join(BRAINIAC_DIR, "brain", "persona", agent_key, "style.md")
307
+ return nil unless File.exist?(file)
308
+
309
+ body = File.read(file).sub(/\A---\n.*?\n---\n/m, "").sub(/\A\s*# .*— Persona\n/, "").strip
310
+ body.empty? ? nil : body
311
+ end
312
+
313
+ # Payload handed to plugins' on_agent_created: the registry entry plus the
314
+ # setup inputs plugins need to provision accounts (avatar, description, email).
315
+ # "open_browser" tells plugins whether they may launch a browser for their
316
+ # manual steps (false with --no-browser, BRAINIAC_NO_BROWSER, or no terminal).
317
+ def agent_hook_payload(agent_key, entry, open_browser: true)
318
+ entry.merge(
319
+ "agent_key" => agent_key,
320
+ "avatar_path" => entry["avatar"],
321
+ "description" => agent_persona_description(agent_key),
322
+ "email" => entry["email"],
323
+ "open_browser" => open_browser && $stdin.tty? && !ENV["BRAINIAC_NO_BROWSER"]
324
+ ).compact
217
325
  end
218
326
 
219
327
  # Run CLI-time plugin hooks for agent removal.
@@ -560,6 +668,78 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
560
668
  | `:create_work_item` | Create card/issue/ticket |
561
669
  | `:detect_cli_provider` | Detect CLI provider |
562
670
  | `:detect_effort` | Detect effort level |
671
+ | `:agent_added` | New agent in agents.json (after reload) |
672
+ | `:agent_updated` | Existing agent's entry changed (after reload) |
673
+ | `:agent_removed` | Agent removed from agents.json (after reload) |
674
+
675
+ ## Agent Onboarding (`on_agent_created`)
676
+
677
+ `brainiac agent create` writes the brainiac files (agents.json, persona, brain),
678
+ then asks each plugin to provision the new agent on its service: a Discord bot,
679
+ a Fizzy user, a Basecamp person. `brainiac agent setup <name>` runs the same hook
680
+ again. If your plugin gives agents an identity on some service, implement it.
681
+
682
+ Define it in `cli.rb` (or a file `cli.rb` requires), since it runs in the CLI context:
683
+
684
+ ```ruby
685
+ # cli.rb
686
+ require_relative "agent_setup"
687
+
688
+ module Brainiac
689
+ module Plugins
690
+ module MyService
691
+ # Called by `brainiac agent create` / `brainiac agent setup`.
692
+ def self.on_agent_created(agent_key, entry)
693
+ AgentSetup.run(agent_key, entry)
694
+ end
695
+
696
+ def self.on_agent_removed(agent_key, display_name) # optional: clean up config
697
+ end
698
+ end
699
+ end
700
+ end
701
+ ```
702
+
703
+ `entry` is the agents.json entry plus:
704
+
705
+ | Key | Value |
706
+ |-----|-------|
707
+ | `agent_key` | Registry key, e.g. `quill` |
708
+ | `display_name` | e.g. `Quill` |
709
+ | `avatar_path` | Profile image (png/jpg/gif/webp) or absent |
710
+ | `description` | Persona text (can be several paragraphs) or absent |
711
+ | `email` | Email for the agent's accounts (from `--email` / `agent_email_template`) or absent |
712
+ | `open_browser` | `true` when you may launch a browser for a manual step |
713
+
714
+ Rules:
715
+
716
+ 1. **Idempotent.** `agent setup` re-runs every hook. Check your config first and
717
+ print `✓ <Name> already ... (skipping)` when the work is done.
718
+ 2. **Automate everything the API allows.** Only stop for steps the service truly
719
+ can't do by API (e.g. Discord has no create-application endpoint). Then print
720
+ numbered steps and the URL, and read the result with `$stdin.noecho(&:gets)`
721
+ for secrets.
722
+ 3. **Browser.** If `entry["open_browser"]` is true, open the URL as well as printing
723
+ it (`xdg-open` on Linux, `open` on macOS, spawned and detached). Don't open links
724
+ that must be used while signed in as someone else (e.g. a join link for the bot's
725
+ own account), because they'd land in the user's own session. Say "open in a
726
+ private window" instead.
727
+ 4. **Optional inputs.** Skip quietly if `avatar_path`, `description` or `email` is missing.
728
+ Return without error when `$stdin` isn't a TTY and you need input.
729
+ 5. **Never raise for expected failures.** Print `✗ ... re-run: brainiac agent setup <key>`.
730
+ Uncaught exceptions are reported as "hook failed" and the next plugin runs.
731
+ 6. **Prefix output** with ` [MyService]` so it's clear whose step is running.
732
+
733
+ Users choose which plugins run with `--skip` / `--only` (or by answering "n" when
734
+ asked during create). Core filters on the plugin name before calling you, so
735
+ plugins don't need to handle this.
736
+
737
+ **Going live without a restart.** `agent create` / `agent setup` tell the running
738
+ server to reload `agents.json`. The server may already have seen the agent before
739
+ your hook wrote its token, so a server-side plugin that starts something per agent
740
+ (a gateway connection, a poller) must handle `:agent_updated` as well as
741
+ `:agent_added`: start it when the credential appears or changes, stop it when it's
742
+ removed.
563
743
 
564
744
  ## Channel Prompts
565
745
 
@@ -2254,18 +2434,28 @@ when "agent"
2254
2434
  puts "Usage: brainiac agent create <name> [options]"
2255
2435
  puts ""
2256
2436
  puts "Options:"
2257
- puts " --local Mark agent as local (dispatches on this machine)"
2258
- puts " --role <role> Assign a role (repeatable)"
2259
- puts " --cli <provider> Set CLI provider (default: kiro)"
2260
- puts " --persona <text> Create a persona style file with this description"
2437
+ puts " --avatar <path> Profile picture (png/jpg/gif/webp), used by plugins (Discord, Fizzy)"
2438
+ puts " --description <text> Persona description (\"-\" reads stdin, \"@file\" reads a file)"
2439
+ puts " --description-file <path> Read the persona description from a file"
2440
+ puts " --email <address> Email for the agent's accounts (default: agent_email_template)"
2441
+ puts " --local Mark agent as local (dispatches on this machine)"
2442
+ puts " --role <role> Assign a role (repeatable)"
2443
+ puts " --cli <provider> Set CLI provider (default: kiro)"
2444
+ puts " --skip <plugins> Don't set up these plugins for this agent, e.g. --skip basecamp"
2445
+ puts " --only <plugins> Set up just these plugins, e.g. --only discord,fizzy"
2446
+ puts " --no-browser Print setup links instead of opening them"
2261
2447
  puts ""
2262
- puts "If no options are given and stdin is a terminal, interactive mode is used."
2448
+ puts "With no flags and a terminal, interactive mode is used. If no description is"
2449
+ puts "given on a terminal, $EDITOR opens so you can write a full persona."
2450
+ puts "After the brainiac files are written, each installed plugin sets up its part"
2451
+ puts "(Discord bot, Fizzy user, Basecamp person), asking first unless --skip/--only"
2452
+ puts "is given. Re-run with: brainiac agent setup <name>"
2263
2453
  puts ""
2264
2454
  puts "Examples:"
2265
- puts " brainiac agent create MyAgent # interactive"
2455
+ puts " brainiac agent create Quill --local --avatar quill.png # opens $EDITOR for the persona"
2456
+ puts " brainiac agent create Quill --local --avatar quill.png --description @quill.md"
2457
+ puts " brainiac agent create MyAgent # interactive"
2266
2458
  puts " brainiac agent create SecurityBot --local --role code-reviewer"
2267
- puts " brainiac agent create Jane --role general-engineer --cli grok"
2268
- puts " brainiac agent create Merlin --persona \"Wise, enigmatic, helpful\""
2269
2459
  exit 1
2270
2460
  end
2271
2461
 
@@ -2273,7 +2463,8 @@ when "agent"
2273
2463
  registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
2274
2464
 
2275
2465
  if registry[agent_key]
2276
- puts "Agent '#{name}' already exists. Use 'brainiac agent #{agent_key} show' to view."
2466
+ puts "Agent '#{name}' already exists. Use 'brainiac agent #{agent_key} show' to view,"
2467
+ puts "or 'brainiac agent setup #{agent_key}' to finish plugin setup."
2277
2468
  exit 1
2278
2469
  end
2279
2470
 
@@ -2281,6 +2472,11 @@ when "agent"
2281
2472
  roles = []
2282
2473
  cli_provider = nil
2283
2474
  persona_text = nil
2475
+ avatar = nil
2476
+ email = nil
2477
+ skip_plugins = []
2478
+ only_plugins = nil
2479
+ open_browser = true
2284
2480
 
2285
2481
  if ARGV.any?
2286
2482
  # Flag mode
@@ -2295,9 +2491,27 @@ when "agent"
2295
2491
  when "--cli"
2296
2492
  ARGV.shift
2297
2493
  cli_provider = ARGV.shift
2298
- when "--persona"
2494
+ when "--description", "--persona"
2495
+ ARGV.shift
2496
+ persona_text = read_description_arg(ARGV.shift.to_s)
2497
+ when "--description-file"
2498
+ ARGV.shift
2499
+ persona_text = File.read(File.expand_path(ARGV.shift.to_s)).strip
2500
+ when "--avatar"
2299
2501
  ARGV.shift
2300
- persona_text = ARGV.shift
2502
+ avatar = ARGV.shift
2503
+ when "--email"
2504
+ ARGV.shift
2505
+ email = ARGV.shift
2506
+ when "--skip"
2507
+ ARGV.shift
2508
+ skip_plugins.concat(plugin_list_arg(ARGV.shift))
2509
+ when "--only"
2510
+ ARGV.shift
2511
+ only_plugins = plugin_list_arg(ARGV.shift)
2512
+ when "--no-browser"
2513
+ ARGV.shift
2514
+ open_browser = false
2301
2515
  else
2302
2516
  puts "Unknown option: #{ARGV[0]}"
2303
2517
  exit 1
@@ -2327,15 +2541,39 @@ when "agent"
2327
2541
  input = $stdin.gets.chomp
2328
2542
  roles = input.empty? ? [] : input.split(",").map(&:strip)
2329
2543
 
2330
- print "Persona description (tone/style, or blank to skip) []: "
2544
+ print "Avatar image path (or blank to skip) []: "
2545
+ input = $stdin.gets.chomp
2546
+ avatar = input.empty? ? nil : input
2547
+
2548
+ default_email = default_agent_email(agent_key, name)
2549
+ print "Email for the agent's accounts#{" [#{default_email}]" if default_email}: "
2331
2550
  input = $stdin.gets.chomp
2332
- persona_text = input.empty? ? nil : input
2551
+ email = input.empty? ? default_email : input
2333
2552
  end
2334
2553
 
2554
+ # A persona is usually a paragraph or more — give it a real editor.
2555
+ if persona_text.nil? && $stdin.tty?
2556
+ print "Write #{name}'s persona description in #{ENV["VISUAL"] || ENV["EDITOR"] || "vi"}? [Y/n]: "
2557
+ persona_text = edit_description_in_editor(name) unless $stdin.gets.to_s.strip.downcase.start_with?("n")
2558
+ end
2559
+
2560
+ email ||= default_agent_email(agent_key, name)
2561
+
2335
2562
  entry = { "display_name" => name }
2336
2563
  entry["local"] = true if local
2337
2564
  entry["cli_provider"] = cli_provider if cli_provider
2338
2565
  entry["role"] = roles.size == 1 ? roles.first : roles if roles.any?
2566
+ entry["email"] = email if email
2567
+ entry["skip_plugins"] = skip_plugins.uniq if skip_plugins.any?
2568
+
2569
+ if avatar
2570
+ begin
2571
+ entry["avatar"] = store_agent_avatar(agent_key, avatar)
2572
+ rescue ArgumentError => e
2573
+ puts "❌ #{e.message}"
2574
+ exit 1
2575
+ end
2576
+ end
2339
2577
 
2340
2578
  registry[agent_key] = entry
2341
2579
  File.write(agent_registry_file, JSON.pretty_generate(registry))
@@ -2343,6 +2581,8 @@ when "agent"
2343
2581
  puts " Local: #{local}" if local
2344
2582
  puts " Role: #{roles.join(", ")}" if roles.any?
2345
2583
  puts " CLI: #{cli_provider}" if cli_provider
2584
+ puts " Email: #{email}" if email
2585
+ puts " Avatar: #{entry["avatar"]}" if entry["avatar"]
2346
2586
 
2347
2587
  # Create persona directory and style file if requested
2348
2588
  if persona_text
@@ -2356,6 +2596,7 @@ when "agent"
2356
2596
  description: Persona voice for #{name}.
2357
2597
  ---
2358
2598
  # #{name} — Persona
2599
+
2359
2600
  #{persona_text}
2360
2601
  MD
2361
2602
  puts " Persona: #{persona_file}"
@@ -2368,12 +2609,86 @@ when "agent"
2368
2609
  brain_init(name)
2369
2610
  end
2370
2611
 
2371
- # Let plugins prompt for additional config (e.g. Discord user ID)
2372
- notify_plugins_agent_created(agent_key, entry)
2612
+ # Each plugin provisions its side (Discord bot, Fizzy user, Basecamp person, ...).
2613
+ # Without --skip/--only, a terminal user is asked per plugin; "no" answers
2614
+ # are saved so `agent setup` leaves those plugins alone too.
2615
+ confirm = $stdin.tty? && skip_plugins.empty? && only_plugins.nil?
2616
+ notify_plugins_agent_created(agent_key, agent_hook_payload(agent_key, entry, open_browser: open_browser),
2617
+ skip: skip_plugins, only: only_plugins, confirm: confirm)
2618
+ if skip_plugins.any? && entry["skip_plugins"] != skip_plugins.uniq
2619
+ # Re-read: the plugin hooks above wrote their tokens (DISCORD_BOT_TOKEN,
2620
+ # FIZZY_TOKEN, ...) into agents.json, and the copy we loaded at the start
2621
+ # would clobber them.
2622
+ registry = JSON.parse(File.read(agent_registry_file))
2623
+ registry[agent_key] ||= entry
2624
+ registry[agent_key]["skip_plugins"] = skip_plugins.uniq
2625
+ File.write(agent_registry_file, JSON.pretty_generate(registry))
2626
+ puts " Skipping #{skip_plugins.uniq.join(", ")} for #{name} (undo: brainiac agent setup #{agent_key} --only <plugin>)"
2627
+ end
2373
2628
 
2374
2629
  # Notify running server to reload and trigger :agent_added hook
2375
2630
  notify_server_reload
2376
2631
 
2632
+ when "setup"
2633
+ name = ARGV.shift
2634
+ unless name
2635
+ puts "Usage: brainiac agent setup <name> [--avatar <path>] [--email <address>] [--skip <plugins>] [--only <plugins>] [--no-browser]"
2636
+ puts ""
2637
+ puts "Re-runs every plugin's agent setup (Discord bot, Fizzy user, Basecamp person)."
2638
+ puts "Steps that are already done are skipped, so it's safe to run after an"
2639
+ puts "interrupted 'agent create' or to set up a newly installed plugin."
2640
+ puts ""
2641
+ puts " --skip a,b Never set up these plugins for this agent (saved)"
2642
+ puts " --only a,b Run just these plugins (also un-skips them)"
2643
+ puts " --no-browser Print links instead of opening them"
2644
+ exit 1
2645
+ end
2646
+
2647
+ agent_key = name.downcase.gsub(/[^a-z0-9-]/, "-")
2648
+ registry = File.exist?(agent_registry_file) ? JSON.parse(File.read(agent_registry_file)) : {}
2649
+ entry = registry[agent_key]
2650
+ unless entry
2651
+ puts "Agent '#{name}' not found in registry."
2652
+ exit 1
2653
+ end
2654
+
2655
+ only_plugins = nil
2656
+ open_browser = true
2657
+ while ARGV.any?
2658
+ case ARGV.shift
2659
+ when "--skip"
2660
+ entry["skip_plugins"] = ((entry["skip_plugins"] || []) + plugin_list_arg(ARGV.shift)).uniq
2661
+ when "--only"
2662
+ only_plugins = plugin_list_arg(ARGV.shift)
2663
+ when "--no-browser"
2664
+ open_browser = false
2665
+ when "--avatar"
2666
+ begin
2667
+ entry["avatar"] = store_agent_avatar(agent_key, ARGV.shift.to_s)
2668
+ rescue ArgumentError => e
2669
+ puts "❌ #{e.message}"
2670
+ exit 1
2671
+ end
2672
+ when "--email"
2673
+ entry["email"] = ARGV.shift
2674
+ else
2675
+ puts "Unknown option. Run 'brainiac agent setup' for usage."
2676
+ exit 1
2677
+ end
2678
+ end
2679
+ entry["email"] ||= default_agent_email(agent_key, entry["display_name"] || name)
2680
+ entry.delete("email") unless entry["email"]
2681
+ # Explicitly running a plugin with --only un-skips it for good.
2682
+ entry["skip_plugins"] -= only_plugins if only_plugins && entry["skip_plugins"]
2683
+ entry.delete("skip_plugins") if entry["skip_plugins"] == []
2684
+ registry[agent_key] = entry
2685
+ File.write(agent_registry_file, JSON.pretty_generate(registry))
2686
+
2687
+ puts "Setting up #{entry["display_name"] || name} (#{agent_key})"
2688
+ notify_plugins_agent_created(agent_key, agent_hook_payload(agent_key, entry, open_browser: open_browser),
2689
+ skip: entry["skip_plugins"] || [], only: only_plugins)
2690
+ notify_server_reload
2691
+
2377
2692
  when "remove", "delete", "rm"
2378
2693
  name = ARGV.shift
2379
2694
  unless name
@@ -2407,12 +2722,14 @@ when "agent"
2407
2722
  brainiac agent list
2408
2723
  brainiac agent default [name]
2409
2724
  brainiac agent create <name> [options]
2725
+ brainiac agent setup <name> [--skip|--only <plugins>]
2410
2726
  brainiac agent remove <name>
2411
2727
 
2412
2728
  Commands:
2413
2729
  list List all agents in the registry
2414
2730
  default [name] Show or set the default agent
2415
- create <name> [options] Add a new agent to the registry
2731
+ create <name> [options] Add a new agent and run every plugin's setup
2732
+ setup <name> Re-run plugin setup (skips steps already done)
2416
2733
  remove <name> Remove an agent from the registry
2417
2734
  <name> show Show agent configuration (tokens redacted)
2418
2735
  <name> set <field> <value> Update a config field (local, cli, role, persona, display_name)
@@ -2421,14 +2738,21 @@ when "agent"
2421
2738
  <name> env --delete <KEY> Remove an env var from an agent
2422
2739
 
2423
2740
  Create options (or interactive if no flags given):
2741
+ --avatar <path> Profile picture, used by plugins (Discord, Fizzy)
2742
+ --description <text> Persona description ("-" = stdin, "@file" = file; $EDITOR if omitted)
2743
+ --email <address> Email for the agent's accounts (default: agent_email_template)
2424
2744
  --local Mark agent as local (dispatches on this machine)
2425
2745
  --role <role> Assign a role (repeatable)
2426
2746
  --cli <provider> Set CLI provider (default: kiro)
2427
- --persona <text> Create a persona style file
2747
+ --skip <plugins> Don't set up these plugins for this agent (saved; also for setup)
2748
+ --only <plugins> Set up just these plugins (also for setup)
2749
+ --no-browser Print setup links instead of opening them
2428
2750
 
2429
2751
  Examples:
2430
2752
  brainiac agent list
2431
2753
  brainiac agent default Sherlock
2754
+ brainiac agent create Quill --local --avatar quill.png
2755
+ brainiac agent setup quill
2432
2756
  brainiac agent create SecurityBot --local --role code-reviewer
2433
2757
  brainiac agent create Jane --cli grok
2434
2758
  brainiac agent create MyAgent # interactive mode
@@ -3249,6 +3573,12 @@ when "plugin"
3249
3573
  Cli.run(args)
3250
3574
  end
3251
3575
 
3576
+ # Optional: provision a new agent on #{pascal_name} during
3577
+ # `brainiac agent create` / `brainiac agent setup`. Must be idempotent.
3578
+ # See "Agent Onboarding" in skills/#{plugin_name}/SKILL.md for the payload and rules.
3579
+ # def self.on_agent_created(agent_key, entry)
3580
+ # end
3581
+
3252
3582
  # Subcommand names for bash completion.
3253
3583
  def self.completions
3254
3584
  %w[config setup status]
@@ -109,14 +109,14 @@ _brainiac() {
109
109
  agent)
110
110
  case $cword in
111
111
  2)
112
- COMPREPLY=($(compgen -W "list create remove $(_brainiac_agents)" -- "$cur"))
112
+ COMPREPLY=($(compgen -W "list create setup remove $(_brainiac_agents)" -- "$cur"))
113
113
  ;;
114
114
  3)
115
115
  local agent_name="${words[2]}"
116
116
  case "$agent_name" in
117
- list|remove|delete|rm) ;;
117
+ list|remove|delete|rm|setup) ;;
118
118
  create|add)
119
- COMPREPLY=($(compgen -W "--local --role --cli --persona" -- "$cur"))
119
+ COMPREPLY=($(compgen -W "--avatar --description --description-file --email --local --role --cli --skip --only --no-browser" -- "$cur"))
120
120
  ;;
121
121
  *)
122
122
  COMPREPLY=($(compgen -W "show env" -- "$cur"))
@@ -50,6 +50,7 @@ AGENT_REGISTRY = load_agent_registry
50
50
  def reload_agent_registry!(force: false)
51
51
  return unless file_changed?(AGENT_REGISTRY_FILE, force: force)
52
52
 
53
+ old_registry = AGENT_REGISTRY.dup
53
54
  old_keys = AGENT_REGISTRY.keys.to_set
54
55
  AGENT_REGISTRY.replace(load_agent_registry)
55
56
  new_keys = AGENT_REGISTRY.keys.to_set
@@ -70,6 +71,17 @@ def reload_agent_registry!(force: false)
70
71
  LOG.info "Agent removed: #{key}"
71
72
  Brainiac.emit(:agent_removed, agent_key: key)
72
73
  end
74
+
75
+ # Agents that already existed but whose entry changed — e.g. `agent create`
76
+ # wrote the entry first and a plugin added its token a few seconds later.
77
+ (old_keys & new_keys).each do |key|
78
+ entry = AGENT_REGISTRY[key]
79
+ next if entry == old_registry[key]
80
+
81
+ display_name = entry.is_a?(Hash) ? (entry["display_name"] || key.capitalize) : key.capitalize
82
+ Brainiac.emit(:agent_updated, agent_key: key, display_name: display_name, entry: entry,
83
+ previous_entry: old_registry[key])
84
+ end
73
85
  end
74
86
 
75
87
  # Default environment for all agent processes. Prevents git from hanging
@@ -11,6 +11,7 @@
11
11
  # :agent_completed — After an agent session finishes (success or failure)
12
12
  # :agent_added — When a new agent appears in the registry (after reload)
13
13
  # :agent_removed — When an agent is removed from the registry (after reload)
14
+ # :agent_updated — When an existing agent's entry changes (after reload; has :previous_entry)
14
15
  # :pr_merged — After a GitHub PR is merged
15
16
  # :pr_opened — After a GitHub PR is opened
16
17
  # :pr_reviewed — After a PR review is submitted
@@ -58,6 +58,18 @@ PROMPT_CORE = <<~PROMPT
58
58
  Standard unix commands (cd, ls, grep, cat, git, curl, etc.) don't need a brain search.
59
59
  But for project-specific tools, do NOT guess at flags or syntax — wrong commands waste time and tokens. Look it up first.
60
60
 
61
+ **NEVER run a whole-filesystem search.** Do not run `find /`, `find ~`, `grep -r /`, `locate`, or any
62
+ command that walks the entire disk from root or home. On macOS these effectively never return — they
63
+ block on autofs automounts, iCloud Drive cloud-only files, sealed CoreSimulator volumes, and Spotlight
64
+ stores — and they pin a CPU for hours. To locate an installed gem or plugin, scope the search:
65
+ ```
66
+ gem which <gem-name> # exact file for a loaded gem
67
+ ruby -e 'puts Gem.loaded_specs.keys.grep(/<pattern>/)' # list matching loaded gems
68
+ # Only if those fail, scope find to known roots with a timeout and head:
69
+ find "$(gem env gemdir)" ~/.asdf/installs ./ -maxdepth 6 -path '*<pattern>*' -name '*.rb' 2>/dev/null | head
70
+ ```
71
+ Always point `find`/`grep -r` at a specific repo, gem root, or directory — never at `/` or `~`.
72
+
61
73
  **When to save knowledge:** Be selective — only save significant architecture decisions,
62
74
  non-obvious gotchas, major workflow changes, or things the user explicitly asks you to remember.
63
75
  Routine card work and things already documented in the codebase don't need brain entries.
@@ -2,5 +2,5 @@
2
2
 
3
3
  module Brainiac
4
4
  # @return [String] the current gem version
5
- VERSION = "0.3.0"
5
+ VERSION = "0.4.1"
6
6
  end
@@ -109,6 +109,78 @@ Available hooks:
109
109
  | `:create_work_item` | Create a card/issue/ticket | title, body, project |
110
110
  | `:detect_cli_provider` | Detect CLI provider | metadata hash |
111
111
  | `:detect_effort` | Detect effort level | metadata hash |
112
+ | `:agent_added` | New key in agents.json (after reload) | agent_key, display_name, entry |
113
+ | `:agent_updated` | Existing agent's entry changed (after reload) | agent_key, display_name, entry, previous_entry |
114
+ | `:agent_removed` | Key gone from agents.json (after reload) | agent_key |
115
+
116
+ ## Agent Onboarding (`on_agent_created`)
117
+
118
+ `brainiac agent create` writes the brainiac files (agents.json, persona, brain),
119
+ then asks each plugin to provision the new agent on its service: a Discord bot,
120
+ a Fizzy user, a Basecamp person. `brainiac agent setup <name>` runs the same hook
121
+ again. If your plugin gives agents an identity on some service, implement it.
122
+
123
+ Define it in `cli.rb` (or a file `cli.rb` requires), since it runs in the CLI context:
124
+
125
+ ```ruby
126
+ # cli.rb
127
+ require_relative "agent_setup"
128
+
129
+ module Brainiac
130
+ module Plugins
131
+ module MyService
132
+ # Called by `brainiac agent create` / `brainiac agent setup`.
133
+ def self.on_agent_created(agent_key, entry)
134
+ AgentSetup.run(agent_key, entry)
135
+ end
136
+
137
+ def self.on_agent_removed(agent_key, display_name) # optional: clean up config
138
+ end
139
+ end
140
+ end
141
+ end
142
+ ```
143
+
144
+ `entry` is the agents.json entry plus:
145
+
146
+ | Key | Value |
147
+ |-----|-------|
148
+ | `agent_key` | Registry key, e.g. `quill` |
149
+ | `display_name` | e.g. `Quill` |
150
+ | `avatar_path` | Profile image (png/jpg/gif/webp) or absent |
151
+ | `description` | Persona text (can be several paragraphs) or absent |
152
+ | `email` | Email for the agent's accounts (from `--email` / `agent_email_template`) or absent |
153
+ | `open_browser` | `true` when you may launch a browser for a manual step |
154
+
155
+ Rules:
156
+
157
+ 1. **Idempotent.** `agent setup` re-runs every hook. Check your config first and
158
+ print `✓ <Name> already ... (skipping)` when the work is done.
159
+ 2. **Automate everything the API allows.** Only stop for steps the service truly
160
+ can't do by API (e.g. Discord has no create-application endpoint). Then print
161
+ numbered steps and the URL, and read the result with `$stdin.noecho(&:gets)`
162
+ for secrets.
163
+ 3. **Browser.** If `entry["open_browser"]` is true, open the URL as well as printing
164
+ it (`xdg-open` on Linux, `open` on macOS, spawned and detached). Don't open links
165
+ that must be used while signed in as someone else (e.g. a join link for the bot's
166
+ own account), because they'd land in the user's own session. Say "open in a
167
+ private window" instead.
168
+ 4. **Optional inputs.** Skip quietly if `avatar_path`, `description` or `email` is missing.
169
+ Return without error when `$stdin` isn't a TTY and you need input.
170
+ 5. **Never raise for expected failures.** Print `✗ ... re-run: brainiac agent setup <key>`.
171
+ Uncaught exceptions are reported as "hook failed" and the next plugin runs.
172
+ 6. **Prefix output** with ` [MyService]` so it's clear whose step is running.
173
+
174
+ Users choose which plugins run with `--skip` / `--only` (or by answering "n" when
175
+ asked during create). Core filters on the plugin name before calling you, so
176
+ plugins don't need to handle this.
177
+
178
+ **Going live without a restart.** `agent create` / `agent setup` tell the running
179
+ server to reload `agents.json`. The server may already have seen the agent before
180
+ your hook wrote its token, so a server-side plugin that starts something per agent
181
+ (a gateway connection, a poller) must handle `:agent_updated` as well as
182
+ `:agent_added`: start it when the credential appears or changes, stop it when it's
183
+ removed.
112
184
 
113
185
  ## Channel Prompts
114
186
 
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: brainiac
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.0
4
+ version: 0.4.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Andy Davis