brainiac 0.0.23 → 0.0.26
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 +4 -4
- data/Gemfile.lock +2 -2
- data/bin/brainiac +655 -40
- data/bin/brainiac-completion.bash +40 -5
- data/lib/brainiac/config.rb +1 -1
- data/lib/brainiac/cron.rb +4 -1
- data/lib/brainiac/handlers/shared/git.rb +59 -0
- data/lib/brainiac/helpers.rb +7 -3
- data/lib/brainiac/hooks.rb +2 -0
- data/lib/brainiac/version.rb +1 -1
- data/monitor/shared.rb +11 -1
- data/monitor/xbar/deploy_env.rb +11 -1
- data/receiver.rb +6 -1
- data/templates/cli-providers/kiro.json.example +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 963f3a3319ca2ccff44fc003072a0566d3b76d06a42ea645d81693bb48ea2ab3
|
|
4
|
+
data.tar.gz: 7abb9b38680549aaf3da6068b459f0faf474f5862f7f235f1dabddfcb38d5e55
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dae8e213cb21a26f97de1406a5fe0bc1ce7dc5806e3b67079e2c0183b94888439f288243d86dc52fd9831d1ee61294a2f28fe9fe4f88dc03819c1d720a95db9b
|
|
7
|
+
data.tar.gz: 2e7cf53a24fd776e2eb7960c379dc49309d932412f5fe39a8a56dd90a6162dcf3a9924396a7386b67a0288c0034381343effb529e53b8bcf4b4f43b7fc454708
|
data/Gemfile.lock
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
PATH
|
|
2
2
|
remote: .
|
|
3
3
|
specs:
|
|
4
|
-
brainiac (0.0.
|
|
4
|
+
brainiac (0.0.26)
|
|
5
5
|
puma (~> 7.2)
|
|
6
6
|
rackup (~> 2.3)
|
|
7
7
|
sinatra (~> 4.1)
|
|
@@ -84,7 +84,7 @@ DEPENDENCIES
|
|
|
84
84
|
CHECKSUMS
|
|
85
85
|
ast (2.4.3) sha256=954615157c1d6a382bc27d690d973195e79db7f55e9765ac7c481c60bdb4d383
|
|
86
86
|
base64 (0.3.0) sha256=27337aeabad6ffae05c265c450490628ef3ebd4b67be58257393227588f5a97b
|
|
87
|
-
brainiac (0.0.
|
|
87
|
+
brainiac (0.0.26)
|
|
88
88
|
json (2.19.9) sha256=9b9025b7cdddafa38d316eca0b2358488e42d417045c1b90d216a9fefe46b79a
|
|
89
89
|
language_server-protocol (3.17.0.5) sha256=fd1e39a51a28bf3eec959379985a72e296e9f9acfce46f6a79d31ca8760803cc
|
|
90
90
|
lint_roller (1.1.0) sha256=2c0c845b632a7d172cb849cc90c1bce937a28c5c8ccccb50dfd46a485003cc87
|
data/bin/brainiac
CHANGED
|
@@ -208,14 +208,7 @@ end
|
|
|
208
208
|
def stop_server
|
|
209
209
|
pid = find_server_pid
|
|
210
210
|
|
|
211
|
-
|
|
212
|
-
puts "No running Brainiac server found."
|
|
213
|
-
return false
|
|
214
|
-
end
|
|
215
|
-
|
|
216
|
-
puts "Stopping Brainiac server (PID: #{pid})..."
|
|
217
|
-
|
|
218
|
-
# Also stop the monitor daemon
|
|
211
|
+
# Stop the monitor daemon regardless of server PID
|
|
219
212
|
daemon_pid_file = "/tmp/brainiac-daemon.pid"
|
|
220
213
|
if File.exist?(daemon_pid_file)
|
|
221
214
|
daemon_pid = File.read(daemon_pid_file).strip.to_i
|
|
@@ -228,40 +221,61 @@ def stop_server
|
|
|
228
221
|
File.delete(daemon_pid_file)
|
|
229
222
|
end
|
|
230
223
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
sleep 2
|
|
237
|
-
next if process_running?(pid)
|
|
224
|
+
# Kill the known server PID
|
|
225
|
+
if pid
|
|
226
|
+
puts "Stopping Brainiac server (PID: #{pid})..."
|
|
227
|
+
kill_pid(pid)
|
|
228
|
+
end
|
|
238
229
|
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
230
|
+
# After killing the known PID, find anything still holding port 4567.
|
|
231
|
+
# This catches orphaned processes from self-restarts, stale PIDs, or child processes.
|
|
232
|
+
lingering = find_port_holders(4567) - [pid].compact
|
|
233
|
+
if lingering.any?
|
|
234
|
+
puts "Cleaning up #{lingering.size} orphaned process(es) on port 4567..."
|
|
235
|
+
lingering.each { |lpid| kill_pid(lpid, quiet: true) }
|
|
236
|
+
end
|
|
243
237
|
|
|
244
|
-
|
|
245
|
-
puts "Server didn't stop gracefully, forcing..."
|
|
246
|
-
Process.kill("KILL", pid)
|
|
247
|
-
sleep 1
|
|
238
|
+
FileUtils.rm_f(PID_FILE)
|
|
248
239
|
|
|
249
|
-
|
|
250
|
-
puts "✓ Server stopped
|
|
240
|
+
if pid || lingering.any?
|
|
241
|
+
puts "✓ Server stopped."
|
|
251
242
|
true
|
|
252
|
-
|
|
253
|
-
puts "
|
|
254
|
-
FileUtils.rm_f(PID_FILE)
|
|
255
|
-
true
|
|
256
|
-
rescue Errno::EPERM
|
|
257
|
-
puts "Error: Permission denied. Try running with sudo or check process ownership."
|
|
258
|
-
false
|
|
259
|
-
rescue StandardError => e
|
|
260
|
-
puts "Error stopping server: #{e.message}"
|
|
243
|
+
else
|
|
244
|
+
puts "No running Brainiac server found."
|
|
261
245
|
false
|
|
262
246
|
end
|
|
263
247
|
end
|
|
264
248
|
|
|
249
|
+
# Kill a single process: TERM first, KILL if it doesn't exit within 5s.
|
|
250
|
+
def kill_pid(pid, quiet: false)
|
|
251
|
+
Process.kill("TERM", pid)
|
|
252
|
+
|
|
253
|
+
5.times do
|
|
254
|
+
sleep 1
|
|
255
|
+
return true unless process_running?(pid)
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
puts " PID #{pid} didn't stop gracefully, forcing..." unless quiet
|
|
259
|
+
Process.kill("KILL", pid)
|
|
260
|
+
sleep 1
|
|
261
|
+
true
|
|
262
|
+
rescue Errno::ESRCH
|
|
263
|
+
true # Already dead
|
|
264
|
+
rescue Errno::EPERM
|
|
265
|
+
puts "Error: Permission denied to stop PID #{pid}." unless quiet
|
|
266
|
+
false
|
|
267
|
+
end
|
|
268
|
+
|
|
269
|
+
# Find all PIDs listening on a given port via lsof.
|
|
270
|
+
def find_port_holders(port)
|
|
271
|
+
output, status = Open3.capture2("lsof", "-ti", ":#{port}")
|
|
272
|
+
return [] unless status.success?
|
|
273
|
+
|
|
274
|
+
output.strip.split("\n").map(&:to_i).select(&:positive?)
|
|
275
|
+
rescue StandardError
|
|
276
|
+
[]
|
|
277
|
+
end
|
|
278
|
+
|
|
265
279
|
def update_handler_config(name, enabled)
|
|
266
280
|
brainiac_config_path = File.join(BRAINIAC_DIR, "brainiac.json")
|
|
267
281
|
config = if File.exist?(brainiac_config_path)
|
|
@@ -527,6 +541,84 @@ def generate_plugin_skill_md(plugin_name, pascal_name)
|
|
|
527
541
|
SKILL
|
|
528
542
|
end
|
|
529
543
|
|
|
544
|
+
SCREENSHOT_CREDS_FILE = File.join(BRAINIAC_DIR, "tmp", "screenshot-creds.json")
|
|
545
|
+
|
|
546
|
+
# Resolve all op:// references in deployments.json and write to a secure cache file.
|
|
547
|
+
# This allows screenshot commands to work without re-authenticating with 1Password.
|
|
548
|
+
def resolve_screenshot_credentials(quiet: false)
|
|
549
|
+
deployments_file = File.join(BRAINIAC_DIR, "deployments.json")
|
|
550
|
+
unless File.exist?(deployments_file)
|
|
551
|
+
puts " ⚠ No deployments.json found, skipping credential resolution." unless quiet
|
|
552
|
+
return false
|
|
553
|
+
end
|
|
554
|
+
|
|
555
|
+
deployments = JSON.parse(File.read(deployments_file))
|
|
556
|
+
environments = deployments["environments"] || {}
|
|
557
|
+
|
|
558
|
+
# Collect all unique op:// references
|
|
559
|
+
op_refs = {}
|
|
560
|
+
environments.each_value do |env_config|
|
|
561
|
+
screenshot = env_config["screenshot"]
|
|
562
|
+
next unless screenshot
|
|
563
|
+
|
|
564
|
+
(screenshot["customers"] || {}).each_value { |ref| op_refs[ref] = nil }
|
|
565
|
+
(screenshot["employees"] || {}).each_value { |ref| op_refs[ref] = nil }
|
|
566
|
+
end
|
|
567
|
+
|
|
568
|
+
if op_refs.empty?
|
|
569
|
+
puts " ⚠ No screenshot credentials to resolve." unless quiet
|
|
570
|
+
return false
|
|
571
|
+
end
|
|
572
|
+
|
|
573
|
+
puts " Resolving #{op_refs.size} credential(s) from 1Password..." unless quiet
|
|
574
|
+
|
|
575
|
+
# Resolve each unique op:// reference
|
|
576
|
+
failed = []
|
|
577
|
+
op_refs.each_key do |ref|
|
|
578
|
+
password, status = Open3.capture2("op", "read", ref)
|
|
579
|
+
if status.success?
|
|
580
|
+
op_refs[ref] = password.chomp
|
|
581
|
+
else
|
|
582
|
+
failed << ref
|
|
583
|
+
end
|
|
584
|
+
end
|
|
585
|
+
|
|
586
|
+
if failed.any?
|
|
587
|
+
puts " ❌ Failed to resolve #{failed.size} credential(s):" unless quiet
|
|
588
|
+
failed.each { |ref| puts " #{ref}" } unless quiet
|
|
589
|
+
return false
|
|
590
|
+
end
|
|
591
|
+
|
|
592
|
+
# Write resolved credentials to cache file with restrictive permissions
|
|
593
|
+
cache_dir = File.join(BRAINIAC_DIR, "tmp")
|
|
594
|
+
FileUtils.mkdir_p(cache_dir)
|
|
595
|
+
File.write(SCREENSHOT_CREDS_FILE, JSON.pretty_generate(op_refs))
|
|
596
|
+
File.chmod(0o600, SCREENSHOT_CREDS_FILE)
|
|
597
|
+
|
|
598
|
+
puts " ✓ Screenshot credentials cached (#{op_refs.size} resolved)" unless quiet
|
|
599
|
+
true
|
|
600
|
+
rescue JSON::ParserError => e
|
|
601
|
+
puts " ❌ Failed to parse deployments.json: #{e.message}" unless quiet
|
|
602
|
+
false
|
|
603
|
+
end
|
|
604
|
+
|
|
605
|
+
# Read a resolved credential from the cache file, falling back to `op read`.
|
|
606
|
+
def read_screenshot_credential(op_ref)
|
|
607
|
+
# Try cache first
|
|
608
|
+
if File.exist?(SCREENSHOT_CREDS_FILE)
|
|
609
|
+
creds = JSON.parse(File.read(SCREENSHOT_CREDS_FILE))
|
|
610
|
+
return creds[op_ref] if creds[op_ref]
|
|
611
|
+
end
|
|
612
|
+
|
|
613
|
+
# Fallback: resolve directly via op read
|
|
614
|
+
password, status = Open3.capture2("op", "read", op_ref)
|
|
615
|
+
return password.chomp if status.success?
|
|
616
|
+
|
|
617
|
+
nil
|
|
618
|
+
rescue JSON::ParserError
|
|
619
|
+
nil
|
|
620
|
+
end
|
|
621
|
+
|
|
530
622
|
def start_server(daemon: false)
|
|
531
623
|
# Resolve the real path of the brainiac script (follows symlinks)
|
|
532
624
|
receiver_path = File.join(BRAINIAC_ROOT, "receiver.rb")
|
|
@@ -544,6 +636,9 @@ def start_server(daemon: false)
|
|
|
544
636
|
exit 1
|
|
545
637
|
end
|
|
546
638
|
|
|
639
|
+
# Pre-resolve screenshot credentials from 1Password (so agents can screenshot without re-auth)
|
|
640
|
+
resolve_screenshot_credentials
|
|
641
|
+
|
|
547
642
|
if daemon
|
|
548
643
|
# Daemon mode: start in background (like the old behavior)
|
|
549
644
|
log_dir = File.join(receiver_dir, "tmp")
|
|
@@ -1107,8 +1202,7 @@ when "unregister", "remove", "rm"
|
|
|
1107
1202
|
when "list", "ls"
|
|
1108
1203
|
list_projects
|
|
1109
1204
|
|
|
1110
|
-
when "projects"
|
|
1111
|
-
# Support "brainiac projects list" as an alias for "brainiac list"
|
|
1205
|
+
when "projects", "project"
|
|
1112
1206
|
projects_cmd = ARGV.shift
|
|
1113
1207
|
case projects_cmd
|
|
1114
1208
|
when "list", "ls", nil
|
|
@@ -1140,9 +1234,140 @@ when "projects"
|
|
|
1140
1234
|
save_projects(projects)
|
|
1141
1235
|
|
|
1142
1236
|
puts "✓ Set '#{project_key}' as the default project"
|
|
1237
|
+
when "update", "set"
|
|
1238
|
+
project_key = ARGV.shift
|
|
1239
|
+
field = ARGV.shift
|
|
1240
|
+
value = ARGV.join(" ") # Allow multi-word values (e.g. tags with spaces)
|
|
1241
|
+
|
|
1242
|
+
unless project_key && field
|
|
1243
|
+
puts "Usage: brainiac projects update <key> <field> <value>"
|
|
1244
|
+
puts ""
|
|
1245
|
+
puts "Fields:"
|
|
1246
|
+
puts " repo_path <path> Path to the project repository"
|
|
1247
|
+
puts " github_repo <owner/repo> GitHub repository (e.g. stowzilla/brainiac)"
|
|
1248
|
+
puts " fizzy_tags <tag1,tag2> Comma-separated Fizzy tags"
|
|
1249
|
+
puts " fizzy_board <board> Default Fizzy board key"
|
|
1250
|
+
puts " cli_provider <provider> CLI provider name (e.g. kiro, grok)"
|
|
1251
|
+
puts " agent_name <name> Default agent for this project"
|
|
1252
|
+
puts " agent_model <model> Default model (e.g. auto, opus, sonnet)"
|
|
1253
|
+
puts " default_branch <branch> Default git branch (e.g. main, master)"
|
|
1254
|
+
puts ""
|
|
1255
|
+
puts "Examples:"
|
|
1256
|
+
puts " brainiac projects update brainiac fizzy_board stowzilla"
|
|
1257
|
+
puts " brainiac projects update marketplace cli_provider grok"
|
|
1258
|
+
puts " brainiac projects update brainiac fizzy_tags brainiac,core"
|
|
1259
|
+
puts " brainiac projects update marketplace agent_model opus"
|
|
1260
|
+
exit 1
|
|
1261
|
+
end
|
|
1262
|
+
|
|
1263
|
+
projects = load_projects
|
|
1264
|
+
unless projects.key?(project_key)
|
|
1265
|
+
puts "Error: Project '#{project_key}' not found."
|
|
1266
|
+
puts "Available projects: #{projects.keys.join(", ")}"
|
|
1267
|
+
exit 1
|
|
1268
|
+
end
|
|
1269
|
+
|
|
1270
|
+
# Handle special field parsing
|
|
1271
|
+
case field
|
|
1272
|
+
when "fizzy_tags", "tags"
|
|
1273
|
+
field = "fizzy_tags"
|
|
1274
|
+
unless value && !value.empty?
|
|
1275
|
+
puts "Error: Value required. Provide comma-separated tags."
|
|
1276
|
+
exit 1
|
|
1277
|
+
end
|
|
1278
|
+
parsed_value = value.split(",").map(&:strip)
|
|
1279
|
+
when "repo_path"
|
|
1280
|
+
unless value && !value.empty?
|
|
1281
|
+
puts "Error: Value required."
|
|
1282
|
+
exit 1
|
|
1283
|
+
end
|
|
1284
|
+
parsed_value = File.expand_path(value)
|
|
1285
|
+
puts "Warning: Path does not exist: #{parsed_value}" unless Dir.exist?(parsed_value)
|
|
1286
|
+
else
|
|
1287
|
+
# Handles known fields (github_repo, fizzy_board, cli_provider, agent_name, agent_model,
|
|
1288
|
+
# default_branch) and arbitrary fields for forward-compatibility
|
|
1289
|
+
unless value && !value.empty?
|
|
1290
|
+
puts "Error: Value required."
|
|
1291
|
+
exit 1
|
|
1292
|
+
end
|
|
1293
|
+
parsed_value = value
|
|
1294
|
+
end
|
|
1295
|
+
|
|
1296
|
+
projects[project_key][field] = parsed_value
|
|
1297
|
+
save_projects(projects)
|
|
1298
|
+
|
|
1299
|
+
display_value = parsed_value.is_a?(Array) ? parsed_value.join(", ") : parsed_value
|
|
1300
|
+
puts "✓ Updated #{project_key}: #{field} = #{display_value}"
|
|
1301
|
+
|
|
1302
|
+
when "unset", "delete-field"
|
|
1303
|
+
project_key = ARGV.shift
|
|
1304
|
+
field = ARGV.shift
|
|
1305
|
+
|
|
1306
|
+
unless project_key && field
|
|
1307
|
+
puts "Usage: brainiac projects unset <key> <field>"
|
|
1308
|
+
puts ""
|
|
1309
|
+
puts "Removes a field from a project's configuration."
|
|
1310
|
+
puts ""
|
|
1311
|
+
puts "Examples:"
|
|
1312
|
+
puts " brainiac projects unset marketplace fizzy_board"
|
|
1313
|
+
puts " brainiac projects unset brainiac default_branch"
|
|
1314
|
+
exit 1
|
|
1315
|
+
end
|
|
1316
|
+
|
|
1317
|
+
projects = load_projects
|
|
1318
|
+
unless projects.key?(project_key)
|
|
1319
|
+
puts "Error: Project '#{project_key}' not found."
|
|
1320
|
+
exit 1
|
|
1321
|
+
end
|
|
1322
|
+
|
|
1323
|
+
unless projects[project_key].key?(field)
|
|
1324
|
+
puts "Error: Field '#{field}' not set on project '#{project_key}'."
|
|
1325
|
+
exit 1
|
|
1326
|
+
end
|
|
1327
|
+
|
|
1328
|
+
projects[project_key].delete(field)
|
|
1329
|
+
save_projects(projects)
|
|
1330
|
+
puts "✓ Removed #{field} from #{project_key}"
|
|
1331
|
+
|
|
1332
|
+
when "help", "--help", "-h"
|
|
1333
|
+
puts <<~HELP
|
|
1334
|
+
Usage: brainiac projects <command> [options]
|
|
1335
|
+
|
|
1336
|
+
Commands:
|
|
1337
|
+
list List all registered projects (default)
|
|
1338
|
+
show <key> Show detailed configuration for a project
|
|
1339
|
+
default <key> Set the default project (fallback when no tags match)
|
|
1340
|
+
update <key> <field> <value> Update a project config field
|
|
1341
|
+
unset <key> <field> Remove a field from project config
|
|
1342
|
+
|
|
1343
|
+
Aliases:
|
|
1344
|
+
brainiac project Works the same as 'brainiac projects'
|
|
1345
|
+
brainiac list Shortcut for 'brainiac projects list'
|
|
1346
|
+
brainiac show Shortcut for 'brainiac projects show'
|
|
1347
|
+
|
|
1348
|
+
Update fields:
|
|
1349
|
+
repo_path Path to the project repository
|
|
1350
|
+
github_repo GitHub repository (owner/repo)
|
|
1351
|
+
fizzy_tags Comma-separated Fizzy tags
|
|
1352
|
+
fizzy_board Default Fizzy board key
|
|
1353
|
+
cli_provider CLI provider name (kiro, grok, etc.)
|
|
1354
|
+
agent_name Default agent for this project
|
|
1355
|
+
agent_model Default model (auto, opus, sonnet, haiku, etc.)
|
|
1356
|
+
default_branch Default git branch
|
|
1357
|
+
|
|
1358
|
+
Examples:
|
|
1359
|
+
brainiac projects list
|
|
1360
|
+
brainiac project show marketplace
|
|
1361
|
+
brainiac projects default marketplace
|
|
1362
|
+
brainiac projects update brainiac fizzy_board stowzilla
|
|
1363
|
+
brainiac projects update marketplace agent_model opus
|
|
1364
|
+
brainiac projects update brainiac fizzy_tags brainiac,core
|
|
1365
|
+
brainiac projects unset marketplace fizzy_board
|
|
1366
|
+
HELP
|
|
1143
1367
|
else
|
|
1144
1368
|
puts "Unknown projects command: #{projects_cmd}"
|
|
1145
|
-
puts "Available: list, show, default"
|
|
1369
|
+
puts "Available: list, show, default, update, unset, help"
|
|
1370
|
+
puts "Run 'brainiac projects help' for more details."
|
|
1146
1371
|
exit 1
|
|
1147
1372
|
end
|
|
1148
1373
|
|
|
@@ -3161,16 +3386,404 @@ when "plugin"
|
|
|
3161
3386
|
puts " brainiac install #{plugin_name} --path #{output_dir}"
|
|
3162
3387
|
puts " brainiac restart"
|
|
3163
3388
|
|
|
3389
|
+
when "list", "ls"
|
|
3390
|
+
# Delegate to the same logic as `brainiac plugins`
|
|
3391
|
+
plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
|
|
3392
|
+
plugins_config = if File.exist?(plugins_file)
|
|
3393
|
+
JSON.parse(File.read(plugins_file))
|
|
3394
|
+
else
|
|
3395
|
+
{ "plugins" => [] }
|
|
3396
|
+
end
|
|
3397
|
+
|
|
3398
|
+
plugins = plugins_config["plugins"] || []
|
|
3399
|
+
if plugins.empty?
|
|
3400
|
+
puts "No plugins installed."
|
|
3401
|
+
puts ""
|
|
3402
|
+
puts "Install plugins with: brainiac install <name>"
|
|
3403
|
+
puts " Example: brainiac install whatsapp"
|
|
3404
|
+
puts " Example: brainiac install fizzy --path ~/Code/brainiac-fizzy"
|
|
3405
|
+
else
|
|
3406
|
+
puts "Installed plugins:"
|
|
3407
|
+
plugins.each do |p|
|
|
3408
|
+
entry = p.is_a?(Hash) ? p : { "name" => p.to_s }
|
|
3409
|
+
name = entry["name"]
|
|
3410
|
+
gem_name = entry["gem"] || "brainiac-#{name}"
|
|
3411
|
+
local_path = entry["path"]
|
|
3412
|
+
installed_at = entry["installed_at"] ? " (#{entry["installed_at"][0..9]})" : ""
|
|
3413
|
+
|
|
3414
|
+
if local_path
|
|
3415
|
+
if Dir.exist?(local_path)
|
|
3416
|
+
puts " ✓ #{name} → #{local_path}#{installed_at}"
|
|
3417
|
+
else
|
|
3418
|
+
puts " ✗ #{name} → #{local_path} (path missing)#{installed_at}"
|
|
3419
|
+
end
|
|
3420
|
+
else
|
|
3421
|
+
loadable = begin
|
|
3422
|
+
spec = Gem::Specification.find_by_name(gem_name)
|
|
3423
|
+
"✓ #{name} (#{gem_name} #{spec.version})"
|
|
3424
|
+
rescue Gem::MissingSpecError
|
|
3425
|
+
"✗ #{name} (#{gem_name} — gem not found)"
|
|
3426
|
+
end
|
|
3427
|
+
puts " #{loadable}#{installed_at}"
|
|
3428
|
+
end
|
|
3429
|
+
end
|
|
3430
|
+
end
|
|
3431
|
+
|
|
3432
|
+
when "switch"
|
|
3433
|
+
plugin_name = ARGV.shift
|
|
3434
|
+
branch_name = ARGV.shift
|
|
3435
|
+
|
|
3436
|
+
unless plugin_name && branch_name
|
|
3437
|
+
puts "Usage: brainiac plugin switch <name> <branch>"
|
|
3438
|
+
puts ""
|
|
3439
|
+
puts "Switches a local plugin to a different branch (via git worktrees)."
|
|
3440
|
+
puts "Useful for testing plugin changes from a PR before releasing."
|
|
3441
|
+
puts ""
|
|
3442
|
+
puts "Examples:"
|
|
3443
|
+
puts " brainiac plugin switch fizzy fix-fallback-for-followups-20260813"
|
|
3444
|
+
puts " brainiac plugin switch fizzy main # switch back to main"
|
|
3445
|
+
puts ""
|
|
3446
|
+
puts "After switching, restart the server: brainiac restart"
|
|
3447
|
+
exit 1
|
|
3448
|
+
end
|
|
3449
|
+
|
|
3450
|
+
plugin_name = plugin_name.sub(/^brainiac-/, "")
|
|
3451
|
+
|
|
3452
|
+
plugins_file = File.join(BRAINIAC_DIR, "plugins.json")
|
|
3453
|
+
unless File.exist?(plugins_file)
|
|
3454
|
+
puts "Error: No plugins installed (plugins.json not found)."
|
|
3455
|
+
exit 1
|
|
3456
|
+
end
|
|
3457
|
+
|
|
3458
|
+
plugins_config = JSON.parse(File.read(plugins_file))
|
|
3459
|
+
plugin_idx = (plugins_config["plugins"] || []).index { |p| (p.is_a?(Hash) ? p["name"] : p.to_s) == plugin_name }
|
|
3460
|
+
|
|
3461
|
+
unless plugin_idx
|
|
3462
|
+
puts "Error: Plugin '#{plugin_name}' is not installed."
|
|
3463
|
+
exit 1
|
|
3464
|
+
end
|
|
3465
|
+
|
|
3466
|
+
entry = plugins_config["plugins"][plugin_idx]
|
|
3467
|
+
unless entry.is_a?(Hash) && entry["path"]
|
|
3468
|
+
puts "Error: Plugin '#{plugin_name}' is not a local-path plugin."
|
|
3469
|
+
puts " Only plugins installed with --path support branch switching."
|
|
3470
|
+
exit 1
|
|
3471
|
+
end
|
|
3472
|
+
|
|
3473
|
+
current_path = entry["path"]
|
|
3474
|
+
gem_name = "brainiac-#{plugin_name}"
|
|
3475
|
+
|
|
3476
|
+
# Determine the base repo path (strip worktree suffix like --branch-name)
|
|
3477
|
+
base_path = current_path.sub(/--[^\/]+$/, "")
|
|
3478
|
+
unless Dir.exist?(base_path)
|
|
3479
|
+
puts "Error: Base repo not found at #{base_path}"
|
|
3480
|
+
exit 1
|
|
3481
|
+
end
|
|
3482
|
+
|
|
3483
|
+
# Get the default branch of the repo
|
|
3484
|
+
default_branch, _, db_status = Open3.capture3("git", "symbolic-ref", "refs/remotes/origin/HEAD", "--short", chdir: base_path)
|
|
3485
|
+
default_branch = default_branch.strip.sub("origin/", "") if db_status.success?
|
|
3486
|
+
default_branch = "main" unless db_status.success? && !default_branch.empty?
|
|
3487
|
+
|
|
3488
|
+
# If switching to the default branch, just point back to the base path
|
|
3489
|
+
if branch_name == default_branch
|
|
3490
|
+
if current_path == base_path
|
|
3491
|
+
puts "Plugin '#{plugin_name}' is already on #{default_branch}."
|
|
3492
|
+
exit 0
|
|
3493
|
+
end
|
|
3494
|
+
|
|
3495
|
+
plugins_config["plugins"][plugin_idx]["path"] = base_path
|
|
3496
|
+
File.write(plugins_file, JSON.pretty_generate(plugins_config))
|
|
3497
|
+
puts "✓ Switched plugin '#{plugin_name}' back to #{default_branch}"
|
|
3498
|
+
puts " Path: #{base_path}"
|
|
3499
|
+
puts " Restart the server to apply: brainiac restart"
|
|
3500
|
+
exit 0
|
|
3501
|
+
end
|
|
3502
|
+
|
|
3503
|
+
# Fetch latest to make sure the branch is available
|
|
3504
|
+
puts "Fetching latest from origin..."
|
|
3505
|
+
_out, _err, fetch_status = Open3.capture3("git", "fetch", "origin", chdir: base_path)
|
|
3506
|
+
unless fetch_status.success?
|
|
3507
|
+
puts "Warning: git fetch failed — continuing with local state."
|
|
3508
|
+
end
|
|
3509
|
+
|
|
3510
|
+
# Check if a worktree already exists for this branch
|
|
3511
|
+
worktree_list, _, wt_status = Open3.capture3("git", "worktree", "list", "--porcelain", chdir: base_path)
|
|
3512
|
+
target_worktree = nil
|
|
3513
|
+
|
|
3514
|
+
if wt_status.success?
|
|
3515
|
+
# Parse porcelain output to find worktrees on the target branch
|
|
3516
|
+
worktrees = worktree_list.split("\n\n").map do |block|
|
|
3517
|
+
wt = {}
|
|
3518
|
+
block.each_line do |line|
|
|
3519
|
+
case line
|
|
3520
|
+
when /^worktree (.+)/
|
|
3521
|
+
wt[:path] = Regexp.last_match(1).strip
|
|
3522
|
+
when /^branch refs\/heads\/(.+)/
|
|
3523
|
+
wt[:branch] = Regexp.last_match(1).strip
|
|
3524
|
+
end
|
|
3525
|
+
end
|
|
3526
|
+
wt
|
|
3527
|
+
end.select { |wt| wt[:path] && wt[:branch] }
|
|
3528
|
+
|
|
3529
|
+
target_worktree = worktrees.find { |wt| wt[:branch] == branch_name }
|
|
3530
|
+
end
|
|
3531
|
+
|
|
3532
|
+
if target_worktree
|
|
3533
|
+
# Worktree already exists — just update the path
|
|
3534
|
+
new_path = target_worktree[:path]
|
|
3535
|
+
if current_path == new_path
|
|
3536
|
+
puts "Plugin '#{plugin_name}' is already on branch '#{branch_name}'."
|
|
3537
|
+
exit 0
|
|
3538
|
+
end
|
|
3539
|
+
|
|
3540
|
+
plugins_config["plugins"][plugin_idx]["path"] = new_path
|
|
3541
|
+
File.write(plugins_file, JSON.pretty_generate(plugins_config))
|
|
3542
|
+
puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
|
|
3543
|
+
puts " Path: #{new_path}"
|
|
3544
|
+
puts " Restart the server to apply: brainiac restart"
|
|
3545
|
+
else
|
|
3546
|
+
# Check if branch exists remotely or locally
|
|
3547
|
+
branch_exists = false
|
|
3548
|
+
check_cmd, _, check_status = Open3.capture3("git", "rev-parse", "--verify", "origin/#{branch_name}", chdir: base_path)
|
|
3549
|
+
branch_exists = check_status.success?
|
|
3550
|
+
|
|
3551
|
+
unless branch_exists
|
|
3552
|
+
# Try local branch
|
|
3553
|
+
_, _, local_status = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
|
|
3554
|
+
branch_exists = local_status.success?
|
|
3555
|
+
end
|
|
3556
|
+
|
|
3557
|
+
unless branch_exists
|
|
3558
|
+
puts "Error: Branch '#{branch_name}' not found (checked local and origin)."
|
|
3559
|
+
puts ""
|
|
3560
|
+
puts "Available remote branches:"
|
|
3561
|
+
branches_out, = Open3.capture3("git", "branch", "-r", "--list", "origin/*", chdir: base_path)
|
|
3562
|
+
branches_out.each_line do |line|
|
|
3563
|
+
branch = line.strip.sub("origin/", "")
|
|
3564
|
+
next if branch.include?("HEAD")
|
|
3565
|
+
|
|
3566
|
+
puts " #{branch}"
|
|
3567
|
+
end
|
|
3568
|
+
exit 1
|
|
3569
|
+
end
|
|
3570
|
+
|
|
3571
|
+
# Create a new worktree
|
|
3572
|
+
worktree_dir = "#{base_path}--#{branch_name}"
|
|
3573
|
+
puts "Creating worktree at #{worktree_dir}..."
|
|
3574
|
+
|
|
3575
|
+
# If the branch exists on origin but not locally, track it
|
|
3576
|
+
_, _, local_check = Open3.capture3("git", "rev-parse", "--verify", branch_name, chdir: base_path)
|
|
3577
|
+
if !local_check.success? && check_status.success?
|
|
3578
|
+
# Remote branch exists but not local — create tracking branch in worktree
|
|
3579
|
+
_, stderr, wt_status = Open3.capture3("git", "worktree", "add", "--track", "-b", branch_name, worktree_dir, "origin/#{branch_name}", chdir: base_path)
|
|
3580
|
+
else
|
|
3581
|
+
# Local branch exists — use it directly
|
|
3582
|
+
_, stderr, wt_status = Open3.capture3("git", "worktree", "add", worktree_dir, branch_name, chdir: base_path)
|
|
3583
|
+
end
|
|
3584
|
+
|
|
3585
|
+
unless wt_status.success?
|
|
3586
|
+
puts "Error: Failed to create worktree:"
|
|
3587
|
+
puts " #{stderr.strip}"
|
|
3588
|
+
exit 1
|
|
3589
|
+
end
|
|
3590
|
+
|
|
3591
|
+
plugins_config["plugins"][plugin_idx]["path"] = worktree_dir
|
|
3592
|
+
File.write(plugins_file, JSON.pretty_generate(plugins_config))
|
|
3593
|
+
puts "✓ Switched plugin '#{plugin_name}' to branch '#{branch_name}'"
|
|
3594
|
+
puts " Worktree: #{worktree_dir}"
|
|
3595
|
+
puts " Restart the server to apply: brainiac restart"
|
|
3596
|
+
end
|
|
3597
|
+
|
|
3164
3598
|
else
|
|
3165
3599
|
puts "Usage: brainiac plugin <command>"
|
|
3166
3600
|
puts ""
|
|
3167
3601
|
puts "Commands:"
|
|
3602
|
+
puts " list List installed plugins"
|
|
3168
3603
|
puts " new <name> Generate a new plugin gem skeleton"
|
|
3604
|
+
puts " switch <name> <branch> Switch a local plugin to a different branch"
|
|
3169
3605
|
puts ""
|
|
3170
3606
|
puts "Managing installed plugins:"
|
|
3171
3607
|
puts " brainiac install <name> Install a plugin"
|
|
3172
3608
|
puts " brainiac uninstall <name> Remove a plugin"
|
|
3173
|
-
puts " brainiac plugins List installed plugins"
|
|
3609
|
+
puts " brainiac plugins List installed plugins (alias)"
|
|
3610
|
+
end
|
|
3611
|
+
|
|
3612
|
+
when "screenshot"
|
|
3613
|
+
# Resolve screenshot credentials from cached file and delegate to project's screenshot script.
|
|
3614
|
+
# Usage: brainiac screenshot [app-name] [page-path] [--env ENV] [extra args...]
|
|
3615
|
+
#
|
|
3616
|
+
# Credentials are resolved from 1Password at server start and cached in
|
|
3617
|
+
# ~/.brainiac/tmp/screenshot-creds.json. Falls back to `op read` if cache is missing.
|
|
3618
|
+
# Run `brainiac screenshot resolve` to manually refresh the cache.
|
|
3619
|
+
|
|
3620
|
+
deployments_file = File.join(BRAINIAC_DIR, "deployments.json")
|
|
3621
|
+
abort "Error: #{deployments_file} not found. Run 'brainiac setup' or create it manually." unless File.exist?(deployments_file)
|
|
3622
|
+
|
|
3623
|
+
deployments = JSON.parse(File.read(deployments_file))
|
|
3624
|
+
environments = deployments["environments"] || {}
|
|
3625
|
+
|
|
3626
|
+
sub = ARGV.shift
|
|
3627
|
+
case sub
|
|
3628
|
+
when "envs", "list"
|
|
3629
|
+
environments.each do |key, env|
|
|
3630
|
+
screenshot = env["screenshot"]
|
|
3631
|
+
if screenshot
|
|
3632
|
+
customers = (screenshot["customers"] || {}).keys
|
|
3633
|
+
employees = (screenshot["employees"] || {}).keys
|
|
3634
|
+
puts " #{key} (#{env["label"]})"
|
|
3635
|
+
puts " Customers: #{customers.join(", ")}" if customers.any?
|
|
3636
|
+
puts " Employees: #{employees.join(", ")}" if employees.any?
|
|
3637
|
+
else
|
|
3638
|
+
puts " #{key} (#{env["label"]}) — no screenshot config"
|
|
3639
|
+
end
|
|
3640
|
+
end
|
|
3641
|
+
|
|
3642
|
+
when "resolve"
|
|
3643
|
+
puts "Resolving screenshot credentials from 1Password..."
|
|
3644
|
+
if resolve_screenshot_credentials(quiet: false)
|
|
3645
|
+
puts "\nCredentials cached at #{SCREENSHOT_CREDS_FILE}"
|
|
3646
|
+
puts "Screenshots will use the cache — no 1Password re-auth needed."
|
|
3647
|
+
else
|
|
3648
|
+
abort "\nFailed to resolve credentials. Ensure `op` is signed in."
|
|
3649
|
+
end
|
|
3650
|
+
|
|
3651
|
+
when "help", "--help", "-h", nil
|
|
3652
|
+
puts <<~HELP
|
|
3653
|
+
brainiac screenshot — Take screenshots with credentials from deployments.json
|
|
3654
|
+
|
|
3655
|
+
Usage:
|
|
3656
|
+
brainiac screenshot <app-name> <page-path> [options]
|
|
3657
|
+
|
|
3658
|
+
Credentials are resolved from 1Password at server start and cached in
|
|
3659
|
+
#{SCREENSHOT_CREDS_FILE} (mode 0600). Use `brainiac screenshot resolve`
|
|
3660
|
+
to manually refresh the cache.
|
|
3661
|
+
|
|
3662
|
+
Options:
|
|
3663
|
+
--env ENV Environment to use (default: auto-detect from project owner)
|
|
3664
|
+
--customer EMAIL Use specific customer email (default: first in list)
|
|
3665
|
+
--employee EMAIL Use specific employee email (default: first in list)
|
|
3666
|
+
--project KEY Project key (default: current directory or default project)
|
|
3667
|
+
All other args are passed through to screenshot-page.sh
|
|
3668
|
+
|
|
3669
|
+
Commands:
|
|
3670
|
+
brainiac screenshot resolve Resolve and cache credentials from 1Password
|
|
3671
|
+
brainiac screenshot envs List environments with screenshot config
|
|
3672
|
+
brainiac screenshot help Show this help
|
|
3673
|
+
|
|
3674
|
+
Examples:
|
|
3675
|
+
brainiac screenshot ops-app /api-explorer
|
|
3676
|
+
brainiac screenshot ops-app /inventory --env dev04
|
|
3677
|
+
brainiac screenshot app /dashboard --env dev02
|
|
3678
|
+
HELP
|
|
3679
|
+
|
|
3680
|
+
else
|
|
3681
|
+
# Parse flags from remaining args
|
|
3682
|
+
app_name = sub
|
|
3683
|
+
page_path = ARGV.shift
|
|
3684
|
+
abort "Error: page path required. Usage: brainiac screenshot <app-name> <page-path> [options]" unless page_path
|
|
3685
|
+
|
|
3686
|
+
env_name = nil
|
|
3687
|
+
customer_email = nil
|
|
3688
|
+
employee_email = nil
|
|
3689
|
+
project_key = nil
|
|
3690
|
+
passthrough_args = []
|
|
3691
|
+
|
|
3692
|
+
while (arg = ARGV.shift)
|
|
3693
|
+
case arg
|
|
3694
|
+
when "--env"
|
|
3695
|
+
env_name = ARGV.shift
|
|
3696
|
+
when "--customer"
|
|
3697
|
+
customer_email = ARGV.shift
|
|
3698
|
+
when "--employee"
|
|
3699
|
+
employee_email = ARGV.shift
|
|
3700
|
+
when "--project"
|
|
3701
|
+
project_key = ARGV.shift
|
|
3702
|
+
else
|
|
3703
|
+
passthrough_args << arg
|
|
3704
|
+
# If the flag takes a value, pass it through too
|
|
3705
|
+
passthrough_args << ARGV.shift if arg.start_with?("--") && !arg.include?("=") && ARGV.first && !ARGV.first.start_with?("--")
|
|
3706
|
+
end
|
|
3707
|
+
end
|
|
3708
|
+
|
|
3709
|
+
# Determine environment (default: dev02 for Linux)
|
|
3710
|
+
env_name ||= case RUBY_PLATFORM
|
|
3711
|
+
when /darwin/ then "dev01"
|
|
3712
|
+
else "dev02"
|
|
3713
|
+
end
|
|
3714
|
+
|
|
3715
|
+
env_config = environments[env_name]
|
|
3716
|
+
abort "Error: Environment '#{env_name}' not found in deployments.json. Available: #{environments.keys.join(", ")}" unless env_config
|
|
3717
|
+
|
|
3718
|
+
screenshot_config = env_config["screenshot"]
|
|
3719
|
+
abort "Error: No 'screenshot' config for environment '#{env_name}' in deployments.json." unless screenshot_config
|
|
3720
|
+
|
|
3721
|
+
customers = screenshot_config["customers"] || {}
|
|
3722
|
+
employees = screenshot_config["employees"] || {}
|
|
3723
|
+
|
|
3724
|
+
# Resolve which email to use
|
|
3725
|
+
if app_name == "ops-app"
|
|
3726
|
+
email = employee_email || employees.keys.first
|
|
3727
|
+
op_ref = employees[email]
|
|
3728
|
+
abort "Error: Employee email '#{email}' not found in #{env_name} screenshot config. Available: #{employees.keys.join(", ")}" unless op_ref
|
|
3729
|
+
else
|
|
3730
|
+
email = customer_email || customers.keys.first
|
|
3731
|
+
op_ref = customers[email]
|
|
3732
|
+
abort "Error: Customer email '#{email}' not found in #{env_name} screenshot config. Available: #{customers.keys.join(", ")}" unless op_ref
|
|
3733
|
+
end
|
|
3734
|
+
|
|
3735
|
+
# Resolve password (from cache or 1Password directly)
|
|
3736
|
+
password = read_screenshot_credential(op_ref)
|
|
3737
|
+
unless password
|
|
3738
|
+
abort "Error: Failed to resolve password for #{op_ref}. Run 'brainiac screenshot resolve' or 'brainiac restart' to refresh the cache."
|
|
3739
|
+
end
|
|
3740
|
+
|
|
3741
|
+
# Determine project path
|
|
3742
|
+
projects = load_projects
|
|
3743
|
+
if project_key
|
|
3744
|
+
project = projects[project_key]
|
|
3745
|
+
abort "Error: Project '#{project_key}' not found." unless project
|
|
3746
|
+
else
|
|
3747
|
+
# Try to find project from current directory or env config
|
|
3748
|
+
project = projects.find { |_k, p| p["repo_path"] == Dir.pwd }&.last
|
|
3749
|
+
project ||= projects[env_config["project"]]
|
|
3750
|
+
end
|
|
3751
|
+
|
|
3752
|
+
abort "Error: Could not determine project. Use --project KEY." unless project
|
|
3753
|
+
repo_path = project["repo_path"]
|
|
3754
|
+
script_path = File.join(repo_path, "scripts", "screenshot-page.sh")
|
|
3755
|
+
abort "Error: Screenshot script not found at #{script_path}" unless File.exist?(script_path)
|
|
3756
|
+
|
|
3757
|
+
# Build environment
|
|
3758
|
+
screenshot_env = {
|
|
3759
|
+
"SCREENSHOT_CUSTOMER_EMAIL" => (app_name == "ops-app" ? (customers.keys.first || email) : email),
|
|
3760
|
+
"SCREENSHOT_CUSTOMER_PASSWORD" => password,
|
|
3761
|
+
"SCREENSHOT_OPS_EMAIL" => (app_name == "ops-app" ? email : (employees.keys.first || email)),
|
|
3762
|
+
"SCREENSHOT_OPS_PASSWORD" => password
|
|
3763
|
+
}
|
|
3764
|
+
|
|
3765
|
+
# Also set environment-specific email vars
|
|
3766
|
+
env_upper = env_name.upcase.tr("-", "_")
|
|
3767
|
+
screenshot_env["SCREENSHOT_#{env_upper}_CUSTOMER_EMAIL"] = customers.keys.first if customers.any?
|
|
3768
|
+
|
|
3769
|
+
# Run the screenshot script
|
|
3770
|
+
cmd = [script_path, app_name, page_path, "", ""] + passthrough_args
|
|
3771
|
+
puts "Taking screenshot: #{app_name} #{page_path} (env: #{env_name}, user: #{email})"
|
|
3772
|
+
puts "Running: #{cmd.join(" ")}"
|
|
3773
|
+
|
|
3774
|
+
pid = spawn(screenshot_env, *cmd, chdir: repo_path)
|
|
3775
|
+
Process.wait(pid)
|
|
3776
|
+
|
|
3777
|
+
if $CHILD_STATUS.success?
|
|
3778
|
+
# Find the most recent screenshot
|
|
3779
|
+
attachments_dir = File.join(repo_path, ".fizzy-attachments")
|
|
3780
|
+
if Dir.exist?(attachments_dir)
|
|
3781
|
+
latest = Dir.glob(File.join(attachments_dir, "*.png")).max_by { |f| File.mtime(f) }
|
|
3782
|
+
puts "\n✅ Screenshot saved: #{latest}" if latest
|
|
3783
|
+
end
|
|
3784
|
+
else
|
|
3785
|
+
abort "\n❌ Screenshot failed (exit #{$CHILD_STATUS.exitstatus})"
|
|
3786
|
+
end
|
|
3174
3787
|
end
|
|
3175
3788
|
|
|
3176
3789
|
when "version", "--version", "-v"
|
|
@@ -3200,8 +3813,8 @@ when "help", "--help", "-h", nil
|
|
|
3200
3813
|
brainiac register [options] Register current directory as a project
|
|
3201
3814
|
brainiac unregister <key> Unregister a project
|
|
3202
3815
|
brainiac list List all registered projects
|
|
3203
|
-
brainiac projects list
|
|
3204
|
-
brainiac
|
|
3816
|
+
brainiac projects <command> Manage projects (list, show, default, help)
|
|
3817
|
+
brainiac project <command> Alias for 'brainiac projects'
|
|
3205
3818
|
brainiac show <key> Show project configuration
|
|
3206
3819
|
brainiac plugin new <name> Generate a new plugin gem skeleton
|
|
3207
3820
|
brainiac update Update brainiac and all installed plugins
|
|
@@ -3214,6 +3827,7 @@ when "help", "--help", "-h", nil
|
|
|
3214
3827
|
brainiac provider <command> Manage CLI providers
|
|
3215
3828
|
brainiac role <command> Manage agent roles
|
|
3216
3829
|
brainiac agent <command> Manage agent registry (env, list, show)
|
|
3830
|
+
brainiac screenshot <app> <path> Take screenshot with 1Password credentials
|
|
3217
3831
|
brainiac config Configure Brainiac CLI
|
|
3218
3832
|
brainiac path Show Brainiac config directory
|
|
3219
3833
|
brainiac version Show version
|
|
@@ -3224,6 +3838,7 @@ when "help", "--help", "-h", nil
|
|
|
3224
3838
|
|
|
3225
3839
|
Plugin Commands:
|
|
3226
3840
|
plugin new <name> Generate a new plugin gem skeleton
|
|
3841
|
+
plugin switch <name> <branch> Switch a local plugin to a branch (for testing)
|
|
3227
3842
|
install <name> Install a plugin (gem install brainiac-<name>)
|
|
3228
3843
|
uninstall <name> Remove a plugin
|
|
3229
3844
|
update [name] Update brainiac + all plugins, or a specific plugin
|
|
@@ -9,7 +9,7 @@ _brainiac() {
|
|
|
9
9
|
local brainiac_dir="${BRAINIAC_DIR:-$HOME/.brainiac}"
|
|
10
10
|
|
|
11
11
|
# Top-level commands (built-in + installed plugins)
|
|
12
|
-
local commands="server start stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects card-map handler plugin install uninstall plugins"
|
|
12
|
+
local commands="server start stop restart logs status register unregister list show brain cron provider role agent config path version help setup projects project card-map handler plugin install uninstall plugins"
|
|
13
13
|
|
|
14
14
|
# Add installed plugin names as top-level commands
|
|
15
15
|
if [[ -f "$brainiac_dir/plugins.json" ]]; then
|
|
@@ -63,7 +63,18 @@ _brainiac() {
|
|
|
63
63
|
plugin)
|
|
64
64
|
case $cword in
|
|
65
65
|
2)
|
|
66
|
-
COMPREPLY=($(compgen -W "new" -- "$cur"))
|
|
66
|
+
COMPREPLY=($(compgen -W "new switch list" -- "$cur"))
|
|
67
|
+
;;
|
|
68
|
+
3)
|
|
69
|
+
# For "plugin switch", suggest installed plugin names
|
|
70
|
+
if [[ "${COMP_WORDS[2]}" == "switch" && -f "$brainiac_dir/plugins.json" ]]; then
|
|
71
|
+
local installed
|
|
72
|
+
installed=$(ruby -rjson -e '
|
|
73
|
+
config = JSON.parse(File.read(ARGV[0]))
|
|
74
|
+
(config["plugins"] || []).select { |p| p.is_a?(Hash) && p["path"] }.each { |p| puts p["name"] }
|
|
75
|
+
' "$brainiac_dir/plugins.json" 2>/dev/null)
|
|
76
|
+
COMPREPLY=($(compgen -W "$installed" -- "$cur"))
|
|
77
|
+
fi
|
|
67
78
|
;;
|
|
68
79
|
esac
|
|
69
80
|
;;
|
|
@@ -180,16 +191,40 @@ _brainiac() {
|
|
|
180
191
|
esac
|
|
181
192
|
;;
|
|
182
193
|
|
|
183
|
-
projects)
|
|
194
|
+
projects|project)
|
|
184
195
|
case $cword in
|
|
185
196
|
2)
|
|
186
|
-
COMPREPLY=($(compgen -W "list default" -- "$cur"))
|
|
197
|
+
COMPREPLY=($(compgen -W "list show default update unset help" -- "$cur"))
|
|
187
198
|
;;
|
|
188
199
|
3)
|
|
189
|
-
|
|
200
|
+
local subcmd="${words[2]}"
|
|
201
|
+
if [[ "$subcmd" == "default" || "$subcmd" == "show" || "$subcmd" == "update" || "$subcmd" == "unset" ]]; then
|
|
190
202
|
COMPREPLY=($(compgen -W "$(_brainiac_projects)" -- "$cur"))
|
|
191
203
|
fi
|
|
192
204
|
;;
|
|
205
|
+
4)
|
|
206
|
+
local subcmd="${words[2]}"
|
|
207
|
+
if [[ "$subcmd" == "update" || "$subcmd" == "unset" ]]; then
|
|
208
|
+
COMPREPLY=($(compgen -W "repo_path github_repo fizzy_tags fizzy_board cli_provider agent_name agent_model default_branch" -- "$cur"))
|
|
209
|
+
fi
|
|
210
|
+
;;
|
|
211
|
+
5)
|
|
212
|
+
local subcmd="${words[2]}"
|
|
213
|
+
local field="${words[4]}"
|
|
214
|
+
if [[ "$subcmd" == "update" ]]; then
|
|
215
|
+
case "$field" in
|
|
216
|
+
cli_provider)
|
|
217
|
+
COMPREPLY=($(compgen -W "$(_brainiac_providers)" -- "$cur"))
|
|
218
|
+
;;
|
|
219
|
+
agent_model)
|
|
220
|
+
COMPREPLY=($(compgen -W "auto opus sonnet haiku deepseek minimax qwen" -- "$cur"))
|
|
221
|
+
;;
|
|
222
|
+
agent_name)
|
|
223
|
+
COMPREPLY=($(compgen -W "$(_brainiac_agents)" -- "$cur"))
|
|
224
|
+
;;
|
|
225
|
+
esac
|
|
226
|
+
fi
|
|
227
|
+
;;
|
|
193
228
|
esac
|
|
194
229
|
;;
|
|
195
230
|
|
data/lib/brainiac/config.rb
CHANGED
|
@@ -157,7 +157,7 @@ DEFAULT_PROJECT = {
|
|
|
157
157
|
"agent_effort_flag" => ENV["AGENT_EFFORT_FLAG"] || "--effort",
|
|
158
158
|
"agent_effort" => ENV.fetch("AGENT_EFFORT", nil),
|
|
159
159
|
"allowed_models" => {
|
|
160
|
-
"opus" => "claude-opus-4.
|
|
160
|
+
"opus" => "claude-opus-4.5",
|
|
161
161
|
"sonnet" => "claude-sonnet-4.6",
|
|
162
162
|
"haiku" => "claude-haiku-4.5",
|
|
163
163
|
"deepseek" => "deepseek-3.2",
|
data/lib/brainiac/cron.rb
CHANGED
|
@@ -466,7 +466,10 @@ def handle_cron_completion(job, project, agent_name, agent_config_name, log_file
|
|
|
466
466
|
update_cron_job_state(job)
|
|
467
467
|
|
|
468
468
|
if File.exist?(response_file)
|
|
469
|
-
|
|
469
|
+
response_content = File.read(response_file).strip
|
|
470
|
+
LOG.info "[Cron] Job #{job[:id]} completed. Response: #{response_content[0..100]}..."
|
|
471
|
+
|
|
472
|
+
notify_cron_output(job, response_content, agent_name: agent_name) if job[:notify_target] && !response_content.empty?
|
|
470
473
|
else
|
|
471
474
|
LOG.warn "[Cron] Job #{job[:id]} produced no response"
|
|
472
475
|
end
|
|
@@ -4,6 +4,31 @@
|
|
|
4
4
|
#
|
|
5
5
|
# All git-related utilities live here. Handlers call these instead of
|
|
6
6
|
# reimplementing git worktree/branch logic.
|
|
7
|
+
#
|
|
8
|
+
# NOTE: Methods that need to be accessible from Sinatra route handlers
|
|
9
|
+
# are wrapped in GitHelpers module and registered with Sinatra.
|
|
10
|
+
|
|
11
|
+
# Module containing git helpers that need to be accessible from Sinatra routes.
|
|
12
|
+
# These are registered as Sinatra helpers in receiver.rb so plugins can call them.
|
|
13
|
+
module GitHelpers
|
|
14
|
+
def resolve_base_branch(repo_path:, card_number: nil, project_key: nil)
|
|
15
|
+
results = Brainiac.emit(:resolve_base_branch,
|
|
16
|
+
repo_path: repo_path, card_number: card_number, project_key: project_key)
|
|
17
|
+
custom = results.compact.first
|
|
18
|
+
if custom
|
|
19
|
+
LOG.info "Using custom base branch '#{custom}' (from plugin hook)"
|
|
20
|
+
custom
|
|
21
|
+
else
|
|
22
|
+
"origin/#{get_default_branch(repo_path)}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def resolve_pr_target(repo_path:, card_number: nil, project_key: nil)
|
|
27
|
+
results = Brainiac.emit(:resolve_pr_target,
|
|
28
|
+
repo_path: repo_path, card_number: card_number, project_key: project_key)
|
|
29
|
+
results.compact.first
|
|
30
|
+
end
|
|
31
|
+
end
|
|
7
32
|
|
|
8
33
|
# Debounced repo git fetch — avoids fetching the same repo multiple times within a short window.
|
|
9
34
|
REPO_LAST_FETCH = {}
|
|
@@ -106,6 +131,40 @@ def run_project_hook(repo_path, hook_name, extra_env: {})
|
|
|
106
131
|
end
|
|
107
132
|
end
|
|
108
133
|
|
|
134
|
+
# Resolve the base branch for a new worktree.
|
|
135
|
+
# Emits :resolve_base_branch hook — plugins can override the default (origin/main).
|
|
136
|
+
# Returns a ref string (e.g., "origin/main", "epic/my-feature").
|
|
137
|
+
#
|
|
138
|
+
# @param repo_path [String] Path to the git repo
|
|
139
|
+
# @param card_number [Integer, String, nil] Fizzy card number (if applicable)
|
|
140
|
+
# @param project_key [String, nil] Brainiac project key
|
|
141
|
+
# @return [String] Base ref for the worktree
|
|
142
|
+
def resolve_base_branch(repo_path:, card_number: nil, project_key: nil)
|
|
143
|
+
results = Brainiac.emit(:resolve_base_branch,
|
|
144
|
+
repo_path: repo_path, card_number: card_number, project_key: project_key)
|
|
145
|
+
# First non-nil result wins (plugins return a branch name or nil to skip)
|
|
146
|
+
custom = results.compact.first
|
|
147
|
+
if custom
|
|
148
|
+
LOG.info "Using custom base branch '#{custom}' (from plugin hook)"
|
|
149
|
+
custom
|
|
150
|
+
else
|
|
151
|
+
"origin/#{get_default_branch(repo_path)}"
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
# Resolve the PR target branch for a card.
|
|
156
|
+
# Emits :resolve_pr_target hook — plugins can override the default branch.
|
|
157
|
+
#
|
|
158
|
+
# @param repo_path [String] Path to the git repo
|
|
159
|
+
# @param card_number [Integer, String, nil] Fizzy card number
|
|
160
|
+
# @param project_key [String, nil] Brainiac project key
|
|
161
|
+
# @return [String, nil] Target branch name (without origin/ prefix), or nil for default
|
|
162
|
+
def resolve_pr_target(repo_path:, card_number: nil, project_key: nil)
|
|
163
|
+
results = Brainiac.emit(:resolve_pr_target,
|
|
164
|
+
repo_path: repo_path, card_number: card_number, project_key: project_key)
|
|
165
|
+
results.compact.first
|
|
166
|
+
end
|
|
167
|
+
|
|
109
168
|
# Create or reuse a git worktree for a given branch.
|
|
110
169
|
# Returns the worktree path on success.
|
|
111
170
|
def create_or_reuse_worktree(repo_path:, branch:, base_ref: nil, worktree_path: nil)
|
data/lib/brainiac/helpers.rb
CHANGED
|
@@ -492,7 +492,11 @@ end
|
|
|
492
492
|
def intent_skip?(message, agent_name:, source: nil, channel: nil, context: nil)
|
|
493
493
|
return false unless message && agent_name && intent_config["enabled"]
|
|
494
494
|
|
|
495
|
+
# Some channels always require a response (e.g. PR comments are inherently directed at the agent).
|
|
496
|
+
bypass_channels = intent_config["bypass_channels"] || %w[github]
|
|
495
497
|
intent_channel = channel || source&.to_s || "conversation"
|
|
498
|
+
return false if bypass_channels.any? { |bc| intent_channel.to_s.downcase.include?(bc) }
|
|
499
|
+
|
|
496
500
|
unless check_intent(message, agent_name: agent_name, channel: intent_channel, context: context)
|
|
497
501
|
LOG.info "[Intent] Skipping dispatch for #{agent_name} — message classified as not requiring response"
|
|
498
502
|
return true
|
|
@@ -503,7 +507,7 @@ end
|
|
|
503
507
|
|
|
504
508
|
def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil, effort: nil, agent_name: nil, card_number: nil, comment_id: nil,
|
|
505
509
|
source: nil, source_context: {}, skip_column_move: false, cli_provider: nil, resume: false,
|
|
506
|
-
message: nil, channel: nil, context: nil)
|
|
510
|
+
message: nil, channel: nil, context: nil, env: {})
|
|
507
511
|
# Intent gate: if a raw message is provided, check whether the agent should respond.
|
|
508
512
|
return nil if intent_skip?(message, agent_name: agent_name, source: source, channel: channel, context: context)
|
|
509
513
|
|
|
@@ -527,7 +531,7 @@ def run_agent(prompt, project_config:, chdir: nil, log_name: "agent", model: nil
|
|
|
527
531
|
cmd = build_agent_cmd(resolved, agent_config_name: agent_config_name, model: model, effort: effort, prompt_file: prompt_file, resume: should_resume)
|
|
528
532
|
prompt_mode = resolved["prompt_mode"] || "stdin"
|
|
529
533
|
|
|
530
|
-
spawn_env = agent_env_for(agent_name)
|
|
534
|
+
spawn_env = agent_env_for(agent_name).merge(env)
|
|
531
535
|
|
|
532
536
|
LOG.info "Running #{resolved["agent_cli"]} in #{chdir}, logging to #{log_file}"
|
|
533
537
|
LOG.info "Prompt written to #{prompt_file}"
|
|
@@ -585,7 +589,7 @@ def build_agent_cmd(resolved, agent_config_name: nil, model: nil, effort: nil, p
|
|
|
585
589
|
# "auto" means "let the CLI choose" — skip passing it unless the provider explicitly maps it.
|
|
586
590
|
if model && resolved["agent_model_flag"] && !resolved["agent_model_flag"].empty?
|
|
587
591
|
allowed = resolved["allowed_models"] || {}
|
|
588
|
-
# Pass the model if it's a mapped value (e.g. "claude-opus-4.
|
|
592
|
+
# Pass the model if it's a mapped value (e.g. "claude-opus-4.5") or the key itself is mapped
|
|
589
593
|
is_known = allowed.value?(model) || allowed.key?(model)
|
|
590
594
|
cmd.push(resolved["agent_model_flag"], model) if is_known
|
|
591
595
|
end
|
data/lib/brainiac/hooks.rb
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
# :build_brain_context — When building brain context (plugins add source-specific queries)
|
|
18
18
|
# :pre_dispatch — Before dispatching an agent (plugins can inject config)
|
|
19
19
|
# :post_comment — After an agent posts a comment/response
|
|
20
|
+
# :resolve_base_branch — Resolve custom base branch for worktree creation
|
|
21
|
+
# :resolve_pr_target — Resolve custom PR target branch (instead of default branch)
|
|
20
22
|
#
|
|
21
23
|
# Usage (in plugin .register):
|
|
22
24
|
# Brainiac.on(:agent_completed) do |ctx|
|
data/lib/brainiac/version.rb
CHANGED
data/monitor/shared.rb
CHANGED
|
@@ -205,7 +205,17 @@ def deploy_bash_script(env_key, worktree:, aws_profile: nil)
|
|
|
205
205
|
fi
|
|
206
206
|
rm -f "$logfile"
|
|
207
207
|
echo
|
|
208
|
-
if [ $status -eq 0 ]; then
|
|
208
|
+
if [ $status -eq 0 ]; then
|
|
209
|
+
echo "✅ Deploy complete"
|
|
210
|
+
curl -s -X POST http://localhost:4567/api/deployments/#{env_key.shellescape} \
|
|
211
|
+
-H "Content-Type: application/json" \
|
|
212
|
+
-d '{"worktree": #{worktree.to_json}, "deployed_by": "waybar"}' > /dev/null 2>&1
|
|
213
|
+
else
|
|
214
|
+
echo "❌ Deploy failed (exit $status)"
|
|
215
|
+
curl -s -X POST http://localhost:4567/api/deployments/#{env_key.shellescape}/failed \
|
|
216
|
+
-H "Content-Type: application/json" \
|
|
217
|
+
-d '{}' > /dev/null 2>&1
|
|
218
|
+
fi
|
|
209
219
|
echo "Press Enter to close..."
|
|
210
220
|
read
|
|
211
221
|
BASH
|
data/monitor/xbar/deploy_env.rb
CHANGED
|
@@ -111,7 +111,17 @@ deploy_script = <<~BASH
|
|
|
111
111
|
fi
|
|
112
112
|
rm -f "$logfile"
|
|
113
113
|
echo
|
|
114
|
-
if [ $status -eq 0 ]; then
|
|
114
|
+
if [ $status -eq 0 ]; then
|
|
115
|
+
echo "✅ Deploy complete"
|
|
116
|
+
curl -s -X POST http://localhost:4567/api/deployments/#{Shellwords.escape(env_key)} \
|
|
117
|
+
-H "Content-Type: application/json" \
|
|
118
|
+
-d '{"worktree": #{worktree.to_json}, "deployed_by": "waybar"}' > /dev/null 2>&1
|
|
119
|
+
else
|
|
120
|
+
echo "❌ Deploy failed (exit $status)"
|
|
121
|
+
curl -s -X POST http://localhost:4567/api/deployments/#{Shellwords.escape(env_key)}/failed \
|
|
122
|
+
-H "Content-Type: application/json" \
|
|
123
|
+
-d '{}' > /dev/null 2>&1
|
|
124
|
+
fi
|
|
115
125
|
echo
|
|
116
126
|
echo "Press any key to close..."
|
|
117
127
|
read -n 1
|
data/receiver.rb
CHANGED
|
@@ -114,8 +114,13 @@ else
|
|
|
114
114
|
LOG.info "[Intent] Disabled (enable in brainiac.json → intent.enabled: true)"
|
|
115
115
|
end
|
|
116
116
|
|
|
117
|
-
# ---
|
|
117
|
+
# --- Sinatra helpers ---
|
|
118
118
|
|
|
119
|
+
# Register GitHelpers so plugins can call resolve_pr_target, resolve_base_branch
|
|
120
|
+
# from within Sinatra route handlers
|
|
121
|
+
helpers GitHelpers
|
|
122
|
+
|
|
123
|
+
# Dashboard authentication helpers
|
|
119
124
|
helpers do
|
|
120
125
|
def authenticate_dashboard!
|
|
121
126
|
return unless DASHBOARD_TOKEN # No token configured = no auth (local-only mode)
|