superkick 0.1.0
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 +7 -0
- data/CLA.md +91 -0
- data/CLAUDE.md +2226 -0
- data/CONTRIBUTING.md +104 -0
- data/LICENSE +108 -0
- data/LICENSE-COMMERCIAL.md +39 -0
- data/PLAN.md +161 -0
- data/README.md +1155 -0
- data/exe/superkick +6 -0
- data/lib/superkick/agent/runtime.rb +82 -0
- data/lib/superkick/agent/runtimes/local.rb +74 -0
- data/lib/superkick/agent/runtimes.rb +4 -0
- data/lib/superkick/agent.rb +209 -0
- data/lib/superkick/agent_store.rb +85 -0
- data/lib/superkick/attach/client.rb +245 -0
- data/lib/superkick/attach/protocol.rb +71 -0
- data/lib/superkick/attach/server.rb +371 -0
- data/lib/superkick/budget_checker.rb +120 -0
- data/lib/superkick/buffer/client.rb +91 -0
- data/lib/superkick/buffer/server.rb +127 -0
- data/lib/superkick/cli/agent.rb +524 -0
- data/lib/superkick/cli/completion.rb +591 -0
- data/lib/superkick/cli/goal.rb +71 -0
- data/lib/superkick/cli/mcp.rb +34 -0
- data/lib/superkick/cli/monitor.rb +47 -0
- data/lib/superkick/cli/notifier.rb +39 -0
- data/lib/superkick/cli/repository.rb +46 -0
- data/lib/superkick/cli/server.rb +106 -0
- data/lib/superkick/cli/setup.rb +166 -0
- data/lib/superkick/cli/spawner.rb +85 -0
- data/lib/superkick/cli/team.rb +407 -0
- data/lib/superkick/cli.rb +175 -0
- data/lib/superkick/client_registry.rb +30 -0
- data/lib/superkick/configuration.rb +178 -0
- data/lib/superkick/connection.rb +56 -0
- data/lib/superkick/control/client.rb +78 -0
- data/lib/superkick/control/reply.rb +43 -0
- data/lib/superkick/control/server.rb +1271 -0
- data/lib/superkick/cost_accumulator.rb +53 -0
- data/lib/superkick/cost_extractor.rb +65 -0
- data/lib/superkick/cost_poller.rb +70 -0
- data/lib/superkick/driver/profile_source.rb +134 -0
- data/lib/superkick/driver.rb +179 -0
- data/lib/superkick/drivers/claude_code.rb +110 -0
- data/lib/superkick/drivers/codex.rb +57 -0
- data/lib/superkick/drivers/copilot.rb +75 -0
- data/lib/superkick/drivers/gemini.rb +86 -0
- data/lib/superkick/drivers/goose.rb +74 -0
- data/lib/superkick/drivers.rb +16 -0
- data/lib/superkick/drop.rb +80 -0
- data/lib/superkick/drops.rb +76 -0
- data/lib/superkick/environment_executor.rb +90 -0
- data/lib/superkick/goal.rb +95 -0
- data/lib/superkick/goals/agent_exit.rb +41 -0
- data/lib/superkick/goals/agent_signal.rb +42 -0
- data/lib/superkick/goals/command.rb +103 -0
- data/lib/superkick/history_buffer.rb +38 -0
- data/lib/superkick/hosted/attach/bridge.rb +52 -0
- data/lib/superkick/hosted/attach/client.rb +208 -0
- data/lib/superkick/hosted/attach/relay.rb +313 -0
- data/lib/superkick/hosted/attach/relay_store.rb +48 -0
- data/lib/superkick/hosted/bridge.rb +263 -0
- data/lib/superkick/hosted/buffer/bridge.rb +42 -0
- data/lib/superkick/hosted/buffer/client.rb +63 -0
- data/lib/superkick/hosted/buffer/relay.rb +126 -0
- data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
- data/lib/superkick/hosted/control/client.rb +84 -0
- data/lib/superkick/hosted/mcp_proxy.rb +144 -0
- data/lib/superkick/inject_handler.rb +24 -0
- data/lib/superkick/injection_guard.rb +26 -0
- data/lib/superkick/injection_queue.rb +177 -0
- data/lib/superkick/injector.rb +65 -0
- data/lib/superkick/input_buffer.rb +171 -0
- data/lib/superkick/integrations/bugsnag/README.md +98 -0
- data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
- data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/bugsnag.rb +7 -0
- data/lib/superkick/integrations/circleci/README.md +75 -0
- data/lib/superkick/integrations/circleci/monitor.rb +185 -0
- data/lib/superkick/integrations/circleci/probe.rb +36 -0
- data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
- data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/circleci.rb +8 -0
- data/lib/superkick/integrations/datadog/README.md +253 -0
- data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
- data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
- data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
- data/lib/superkick/integrations/datadog/notifier.rb +294 -0
- data/lib/superkick/integrations/datadog/spawner.rb +201 -0
- data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
- data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
- data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
- data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
- data/lib/superkick/integrations/datadog.rb +14 -0
- data/lib/superkick/integrations/docker/README.md +256 -0
- data/lib/superkick/integrations/docker/client.rb +295 -0
- data/lib/superkick/integrations/docker/runtime.rb +218 -0
- data/lib/superkick/integrations/docker.rb +4 -0
- data/lib/superkick/integrations/git/repository_source.rb +66 -0
- data/lib/superkick/integrations/git/version_control.rb +119 -0
- data/lib/superkick/integrations/git.rb +8 -0
- data/lib/superkick/integrations/github/README.md +300 -0
- data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
- data/lib/superkick/integrations/github/drops.rb +114 -0
- data/lib/superkick/integrations/github/goal.rb +135 -0
- data/lib/superkick/integrations/github/issue_goal.rb +104 -0
- data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
- data/lib/superkick/integrations/github/monitor.rb +251 -0
- data/lib/superkick/integrations/github/probe.rb +30 -0
- data/lib/superkick/integrations/github/repository_source.rb +228 -0
- data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
- data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
- data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
- data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
- data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
- data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
- data/lib/superkick/integrations/github.rb +16 -0
- data/lib/superkick/integrations/honeybadger/README.md +97 -0
- data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
- data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
- data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
- data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
- data/lib/superkick/integrations/honeybadger.rb +9 -0
- data/lib/superkick/integrations/shell/README.md +83 -0
- data/lib/superkick/integrations/shell/monitor.rb +87 -0
- data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
- data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
- data/lib/superkick/integrations/shell.rb +7 -0
- data/lib/superkick/integrations/shortcut/README.md +193 -0
- data/lib/superkick/integrations/shortcut/drops.rb +91 -0
- data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
- data/lib/superkick/integrations/shortcut/probe.rb +34 -0
- data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
- data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
- data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
- data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
- data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
- data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
- data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
- data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
- data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
- data/lib/superkick/integrations/shortcut.rb +11 -0
- data/lib/superkick/integrations/slack/README.md +297 -0
- data/lib/superkick/integrations/slack/drops.rb +70 -0
- data/lib/superkick/integrations/slack/notifier.rb +426 -0
- data/lib/superkick/integrations/slack/spawner.rb +251 -0
- data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
- data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
- data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
- data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
- data/lib/superkick/integrations/slack.rb +12 -0
- data/lib/superkick/liquid.rb +129 -0
- data/lib/superkick/local/repository_source.rb +148 -0
- data/lib/superkick/mcp_server.rb +596 -0
- data/lib/superkick/monitor.rb +215 -0
- data/lib/superkick/notification_dispatcher.rb +280 -0
- data/lib/superkick/notifier.rb +173 -0
- data/lib/superkick/notifier_state_store.rb +55 -0
- data/lib/superkick/notifier_template.rb +121 -0
- data/lib/superkick/notifiers/command.rb +124 -0
- data/lib/superkick/notifiers/terminal_bell.rb +41 -0
- data/lib/superkick/output_logger.rb +54 -0
- data/lib/superkick/poller.rb +126 -0
- data/lib/superkick/process_runner.rb +87 -0
- data/lib/superkick/pty_proxy.rb +403 -0
- data/lib/superkick/registry.rb +75 -0
- data/lib/superkick/repository_source.rb +195 -0
- data/lib/superkick/server.rb +211 -0
- data/lib/superkick/session_recorder.rb +154 -0
- data/lib/superkick/setup.rb +160 -0
- data/lib/superkick/spawn/agent_spawner.rb +311 -0
- data/lib/superkick/spawn/approval_store.rb +113 -0
- data/lib/superkick/spawn/handler.rb +144 -0
- data/lib/superkick/spawn/injector.rb +119 -0
- data/lib/superkick/spawn/workflow_executor.rb +196 -0
- data/lib/superkick/spawn/workflow_validator.rb +77 -0
- data/lib/superkick/spawner.rb +67 -0
- data/lib/superkick/supervisor.rb +516 -0
- data/lib/superkick/team/artifact_store.rb +92 -0
- data/lib/superkick/team/log.rb +140 -0
- data/lib/superkick/team/log_entry_drop.rb +34 -0
- data/lib/superkick/team/log_monitor.rb +84 -0
- data/lib/superkick/team/log_notifier.rb +96 -0
- data/lib/superkick/team/log_store.rb +40 -0
- data/lib/superkick/template_filters.rb +24 -0
- data/lib/superkick/template_renderer.rb +223 -0
- data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
- data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
- data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
- data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
- data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
- data/lib/superkick/version.rb +5 -0
- data/lib/superkick/version_control.rb +135 -0
- data/lib/superkick/yaml_config.rb +302 -0
- data/lib/superkick.rb +198 -0
- data/plan.md +267 -0
- metadata +404 -0
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Per-agent cost tracker. Stores on the Agent object and records
|
|
5
|
+
# cost samples from PTY scraping and MCP self-reporting.
|
|
6
|
+
#
|
|
7
|
+
# Thread-safe — all mutations go through a mutex.
|
|
8
|
+
class CostAccumulator
|
|
9
|
+
MAX_SAMPLES = 100
|
|
10
|
+
|
|
11
|
+
attr_reader :total_tokens_in, :total_tokens_out, :total_cost_usd,
|
|
12
|
+
:samples, :last_sample_at
|
|
13
|
+
|
|
14
|
+
def initialize
|
|
15
|
+
@total_tokens_in = 0
|
|
16
|
+
@total_tokens_out = 0
|
|
17
|
+
@total_cost_usd = 0.0
|
|
18
|
+
@samples = []
|
|
19
|
+
@last_sample_at = nil
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# Record a cost sample from any source.
|
|
24
|
+
# @param tokens_in [Integer] input tokens (incremental)
|
|
25
|
+
# @param tokens_out [Integer] output tokens (incremental)
|
|
26
|
+
# @param cost_usd [Float] cost in USD (incremental)
|
|
27
|
+
# @param source [Symbol] e.g. :pty_scrape, :mcp_report, :unknown
|
|
28
|
+
def record(tokens_in: 0, tokens_out: 0, cost_usd: 0.0, source: :unknown)
|
|
29
|
+
@mutex.synchronize do
|
|
30
|
+
@total_tokens_in += tokens_in
|
|
31
|
+
@total_tokens_out += tokens_out
|
|
32
|
+
@total_cost_usd += cost_usd
|
|
33
|
+
@samples << {
|
|
34
|
+
at: Time.now.iso8601,
|
|
35
|
+
tokens_in:, tokens_out:, cost_usd:, source:
|
|
36
|
+
}
|
|
37
|
+
@samples.shift if @samples.size > MAX_SAMPLES
|
|
38
|
+
@last_sample_at = Time.now
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def to_h
|
|
43
|
+
@mutex.synchronize do
|
|
44
|
+
{
|
|
45
|
+
total_tokens_in: @total_tokens_in,
|
|
46
|
+
total_tokens_out: @total_tokens_out,
|
|
47
|
+
total_cost_usd: @total_cost_usd.round(4),
|
|
48
|
+
sample_count: @samples.size
|
|
49
|
+
}
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Line-buffered cost extraction from PTY output.
|
|
5
|
+
#
|
|
6
|
+
# Buffers stripped output text and processes complete lines against
|
|
7
|
+
# driver-provided CostPattern instances. Uses cumulative-to-delta
|
|
8
|
+
# conversion so repeated status-bar refreshes (same cost) produce
|
|
9
|
+
# no duplicate samples.
|
|
10
|
+
class CostExtractor
|
|
11
|
+
MAX_BUFFER = 1024
|
|
12
|
+
|
|
13
|
+
def initialize(patterns:)
|
|
14
|
+
@patterns = patterns
|
|
15
|
+
@buffer = +""
|
|
16
|
+
@last_cumulative = {tokens_in: 0, tokens_out: 0, cost_usd: 0.0}
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Feed stripped output text. Returns array of cost deltas.
|
|
20
|
+
# Each delta is a hash like { tokens_in: N, cost_usd: F }.
|
|
21
|
+
def feed(text)
|
|
22
|
+
@buffer << text
|
|
23
|
+
deltas = []
|
|
24
|
+
|
|
25
|
+
while (idx = @buffer.index("\n"))
|
|
26
|
+
line = @buffer.slice!(0, idx + 1).strip
|
|
27
|
+
delta = process_line(line)
|
|
28
|
+
deltas << delta if delta
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# Prevent unbounded buffer growth
|
|
32
|
+
@buffer = @buffer[-MAX_BUFFER..] || @buffer if @buffer.size > MAX_BUFFER
|
|
33
|
+
|
|
34
|
+
deltas
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def process_line(line)
|
|
40
|
+
@patterns.each do |cp|
|
|
41
|
+
m = cp.pattern.match(line)
|
|
42
|
+
next unless m
|
|
43
|
+
|
|
44
|
+
raw = cp.extractor.call(m)
|
|
45
|
+
next unless raw
|
|
46
|
+
|
|
47
|
+
return compute_delta(raw)
|
|
48
|
+
end
|
|
49
|
+
nil
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
# Convert cumulative cost reports to incremental deltas.
|
|
53
|
+
def compute_delta(raw)
|
|
54
|
+
delta = {}
|
|
55
|
+
%i[tokens_in tokens_out cost_usd].each do |key|
|
|
56
|
+
next unless raw[key]
|
|
57
|
+
prev = @last_cumulative[key] || 0
|
|
58
|
+
d = raw[key] - prev
|
|
59
|
+
delta[key] = d if d > 0
|
|
60
|
+
@last_cumulative[key] = raw[key]
|
|
61
|
+
end
|
|
62
|
+
delta.empty? ? nil : delta
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Periodic cost command injection for spawned agents with stale cost data.
|
|
5
|
+
#
|
|
6
|
+
# When passive PTY scraping hasn't produced a cost sample recently,
|
|
7
|
+
# the poller enqueues the driver's cost command (e.g. `/cost`, `/stats`)
|
|
8
|
+
# into the agent's InjectionQueue to force fresh data.
|
|
9
|
+
#
|
|
10
|
+
# Only runs for spawned (headless) agents — interactive agents have
|
|
11
|
+
# a human who can type `/cost` themselves.
|
|
12
|
+
class CostPoller
|
|
13
|
+
DEFAULT_INTERVAL = 300 # check every 5 minutes
|
|
14
|
+
DEFAULT_STALE_AFTER = 600 # cost data is stale after 10 minutes
|
|
15
|
+
|
|
16
|
+
def initialize(agent_id:, store:, buffer_client: nil, config: Superkick.config, interval: nil, stale_after: nil)
|
|
17
|
+
@agent_id = agent_id
|
|
18
|
+
@store = store
|
|
19
|
+
@config = config
|
|
20
|
+
@buffer_client = buffer_client || Buffer.client_from(store:, config:)
|
|
21
|
+
@interval = interval || @config.cost_poll_interval || DEFAULT_INTERVAL
|
|
22
|
+
@stale_after = stale_after || @config.cost_stale_after || DEFAULT_STALE_AFTER
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def run
|
|
26
|
+
loop do
|
|
27
|
+
sleep @interval
|
|
28
|
+
poll
|
|
29
|
+
rescue => e
|
|
30
|
+
Superkick.logger.error("cost_poll:#{@agent_id}") { e.message }
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def poll
|
|
37
|
+
return unless Superkick.driver&.cost_command
|
|
38
|
+
|
|
39
|
+
agent = @store.get(@agent_id)
|
|
40
|
+
return unless agent
|
|
41
|
+
|
|
42
|
+
last_sample_at = agent.cost.last_sample_at
|
|
43
|
+
if last_sample_at.nil? || stale?(last_sample_at)
|
|
44
|
+
enqueue_cost_command
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def stale?(last_sample_at)
|
|
49
|
+
Time.now - last_sample_at > @stale_after
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def enqueue_cost_command
|
|
53
|
+
return unless @buffer_client.reachable?(@agent_id)
|
|
54
|
+
|
|
55
|
+
command = Superkick.driver.cost_command
|
|
56
|
+
@buffer_client.send_command(@agent_id, "enqueue_injection",
|
|
57
|
+
id: "cost-#{@agent_id}-#{Time.now.to_i}",
|
|
58
|
+
prompt: command,
|
|
59
|
+
monitor_type: "system",
|
|
60
|
+
monitor_name: "cost_poller",
|
|
61
|
+
priority: "low",
|
|
62
|
+
ttl: 60,
|
|
63
|
+
supersede_key: "cost_poller")
|
|
64
|
+
|
|
65
|
+
Superkick.logger.debug("cost_poll:#{@agent_id}") { "Enqueued #{command}" }
|
|
66
|
+
rescue Buffer::Client::AgentUnreachable, SystemCallError, IOError, JSON::ParserError => e
|
|
67
|
+
Superkick.logger.debug("cost_poll:#{@agent_id}") { "Enqueue failed: #{e.message}" }
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
class Driver
|
|
5
|
+
# ProfileSource — abstract base class for named driver profile sources.
|
|
6
|
+
#
|
|
7
|
+
# A profile is a named, reusable set of driver configuration options
|
|
8
|
+
# (type, config_dir, command, args, env) that can be referenced by name
|
|
9
|
+
# from spawner configs via `driver: { profile: :review }`.
|
|
10
|
+
#
|
|
11
|
+
# This follows the same registry + build pattern as RepositorySource:
|
|
12
|
+
# - StaticProfileSource for YAML-configured profiles (the default)
|
|
13
|
+
# - Future implementations can back onto a database for hosted mode
|
|
14
|
+
#
|
|
15
|
+
# Profile Hash structure:
|
|
16
|
+
# { type: :claude_code,
|
|
17
|
+
# config_dir: "~/.superkick/driver-configs/review/.claude",
|
|
18
|
+
# command: "/opt/bin/claude",
|
|
19
|
+
# args: ["--verbose"],
|
|
20
|
+
# env: { "ANTHROPIC_API_KEY" => "sk-review" } }
|
|
21
|
+
#
|
|
22
|
+
# Subclass contract:
|
|
23
|
+
# self.type → unique Symbol (e.g. :static)
|
|
24
|
+
# get(name) → profile Hash or nil
|
|
25
|
+
# list → Hash of { name_sym => profile Hash }
|
|
26
|
+
class ProfileSource
|
|
27
|
+
@registry = {}
|
|
28
|
+
|
|
29
|
+
class << self
|
|
30
|
+
include Superkick::Registry
|
|
31
|
+
|
|
32
|
+
def register(source_class)
|
|
33
|
+
key = source_class.type
|
|
34
|
+
raise ArgumentError, "ProfileSource :#{key} already registered" if @registry.key?(key)
|
|
35
|
+
@registry[key] = source_class
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def lookup(name)
|
|
39
|
+
@registry[name.to_sym] or raise ArgumentError, "Unknown profile source type: #{name.inspect}"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def registered
|
|
43
|
+
@registry.dup.freeze
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Build a source from a profiles config hash.
|
|
47
|
+
#
|
|
48
|
+
# Accepts a Hash where each key is a profile name and each value is
|
|
49
|
+
# a profile config hash (type, config_dir, command, args, env).
|
|
50
|
+
#
|
|
51
|
+
# Currently always builds a StaticProfileSource. Future typed sources
|
|
52
|
+
# (e.g. database-backed) can be distinguished by a :source_type key.
|
|
53
|
+
def build(config)
|
|
54
|
+
config = {} if config.nil?
|
|
55
|
+
StaticProfileSource.new(config)
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def self.type
|
|
60
|
+
raise NotImplementedError, "#{self}.type not implemented"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# Look up a profile by name.
|
|
64
|
+
# @param name [Symbol, String] profile name
|
|
65
|
+
# @return [Hash, nil] normalized profile config or nil
|
|
66
|
+
def get(name)
|
|
67
|
+
raise NotImplementedError, "#{self.class}#get not implemented"
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# List all profiles.
|
|
71
|
+
# @return [Hash] { name_sym => profile Hash }
|
|
72
|
+
def list
|
|
73
|
+
raise NotImplementedError, "#{self.class}#list not implemented"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def empty?
|
|
77
|
+
list.empty?
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def size
|
|
81
|
+
list.size
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# StaticProfileSource — profiles defined inline in the YAML config.
|
|
86
|
+
#
|
|
87
|
+
# Each key is a profile name, each value is a driver config hash.
|
|
88
|
+
#
|
|
89
|
+
# Example YAML:
|
|
90
|
+
# drivers:
|
|
91
|
+
# profiles:
|
|
92
|
+
# review:
|
|
93
|
+
# type: claude_code
|
|
94
|
+
# config_dir: ~/.superkick/driver-configs/review/.claude
|
|
95
|
+
# env:
|
|
96
|
+
# ANTHROPIC_API_KEY: sk-review
|
|
97
|
+
# worker:
|
|
98
|
+
# type: claude_code
|
|
99
|
+
# config_dir: ~/.superkick/driver-configs/worker/.claude
|
|
100
|
+
class StaticProfileSource < ProfileSource
|
|
101
|
+
def self.type = :static
|
|
102
|
+
|
|
103
|
+
def initialize(profiles_config = {})
|
|
104
|
+
raw = profiles_config.is_a?(Hash) ? profiles_config : {}
|
|
105
|
+
@profiles = raw.each_with_object({}) do |(name, config), h|
|
|
106
|
+
next unless config.is_a?(Hash)
|
|
107
|
+
h[name.to_sym] = normalize_profile(name, config)
|
|
108
|
+
end.freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def get(name)
|
|
112
|
+
@profiles[name.to_sym]
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def list
|
|
116
|
+
@profiles
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
private
|
|
120
|
+
|
|
121
|
+
def normalize_profile(name, config)
|
|
122
|
+
profile = {name: name.to_sym}
|
|
123
|
+
profile[:type] = config[:type].to_sym if config[:type]
|
|
124
|
+
profile[:config_dir] = File.expand_path(config[:config_dir]) if config[:config_dir]
|
|
125
|
+
profile[:command] = config[:command] if config[:command]
|
|
126
|
+
profile[:args] = Array(config[:args]) if config[:args]
|
|
127
|
+
profile[:env] = config[:env] if config[:env].is_a?(Hash)
|
|
128
|
+
profile
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
ProfileSource.register(StaticProfileSource)
|
|
133
|
+
end
|
|
134
|
+
end
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Superkick
|
|
4
|
+
# Cost extraction pattern — matched against stripped PTY output lines.
|
|
5
|
+
# @param pattern [Regexp] matched against the line
|
|
6
|
+
# @param extractor [Proc] receives MatchData, returns { tokens_in:, tokens_out:, cost_usd: } (all optional)
|
|
7
|
+
CostPattern = Data.define(:pattern, :extractor)
|
|
8
|
+
|
|
9
|
+
# Driver — base class for AI coding CLI drivers.
|
|
10
|
+
#
|
|
11
|
+
# The registry stores **classes**. Superkick.use(:driver_name, **opts)
|
|
12
|
+
# instantiates the class and stores the instance on Superkick.driver.
|
|
13
|
+
# Consumers read from the instance directly.
|
|
14
|
+
#
|
|
15
|
+
# ## Instance interface (the contract)
|
|
16
|
+
#
|
|
17
|
+
# driver_name → Symbol — unique identifier (e.g. :claude_code)
|
|
18
|
+
# cli_command → String — executable name or path (e.g. "claude")
|
|
19
|
+
# prompt_patterns → [Regexp] — matched against stripped pty output to detect idle prompt
|
|
20
|
+
# injection_guards → [PatternGuard] — suppress injection when any pattern matches
|
|
21
|
+
# install_mcp(exe_path:) → void — configure the CLI's MCP settings for Superkick
|
|
22
|
+
#
|
|
23
|
+
# ## Subclass contract
|
|
24
|
+
#
|
|
25
|
+
# Subclasses must:
|
|
26
|
+
# - Define `self.driver_name` (used as the registry key)
|
|
27
|
+
# - Implement `initialize(**opts)` accepting driver-specific config
|
|
28
|
+
# - Implement instance methods: driver_name, cli_command
|
|
29
|
+
#
|
|
30
|
+
# Subclasses may override:
|
|
31
|
+
# - prompt_patterns (default: [])
|
|
32
|
+
# - injection_guards (default: [])
|
|
33
|
+
# - install_mcp (default: prints manual instructions)
|
|
34
|
+
class Driver
|
|
35
|
+
# ── Registry (stores classes) ──────────────────────────────────────────
|
|
36
|
+
@registry = {}
|
|
37
|
+
|
|
38
|
+
class << self
|
|
39
|
+
include Superkick::Registry
|
|
40
|
+
|
|
41
|
+
def register(driver_class)
|
|
42
|
+
key = driver_class.driver_name
|
|
43
|
+
raise ArgumentError, "Driver :#{key} is already registered" if @registry.key?(key)
|
|
44
|
+
@registry[key] = driver_class
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def lookup(name)
|
|
48
|
+
@registry[name.to_sym] or raise ArgumentError, "Unknown driver: #{name.inspect}"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def registered
|
|
52
|
+
@registry.dup.freeze
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def names
|
|
56
|
+
@registry.keys
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# ── Driver config normalization and merging ────────────────────────────
|
|
61
|
+
|
|
62
|
+
# Normalize a polymorphic driver value to a Hash.
|
|
63
|
+
#
|
|
64
|
+
# Accepts:
|
|
65
|
+
# - String/Symbol → { type: :driver_name }
|
|
66
|
+
# - Hash → dup with symbolized :type
|
|
67
|
+
# - nil → nil
|
|
68
|
+
#
|
|
69
|
+
# When the Hash contains a :profile key and a profile_source is provided,
|
|
70
|
+
# the named profile is resolved and used as the base, with any remaining
|
|
71
|
+
# keys merged on top.
|
|
72
|
+
def self.normalize_driver(driver, profile_source: nil)
|
|
73
|
+
case driver
|
|
74
|
+
when String, Symbol
|
|
75
|
+
{type: driver.to_sym}
|
|
76
|
+
when Hash
|
|
77
|
+
resolved = driver.dup
|
|
78
|
+
resolved[:type] = resolved[:type].to_sym if resolved[:type]
|
|
79
|
+
|
|
80
|
+
# Resolve profile reference
|
|
81
|
+
if resolved[:profile] && profile_source
|
|
82
|
+
profile_name = resolved.delete(:profile).to_sym
|
|
83
|
+
profile = profile_source.get(profile_name)
|
|
84
|
+
if profile
|
|
85
|
+
overrides = resolved.empty? ? nil : resolved
|
|
86
|
+
resolved = profile.dup
|
|
87
|
+
resolved = merge_driver(resolved, overrides) if overrides
|
|
88
|
+
else
|
|
89
|
+
Superkick.logger.warn("driver") { "Unknown driver profile: #{profile_name.inspect}" }
|
|
90
|
+
end
|
|
91
|
+
elsif resolved[:profile]
|
|
92
|
+
# Profile reference without source — strip the key, it's unresolvable
|
|
93
|
+
resolved.delete(:profile)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
resolved
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Deep-merge two driver config values.
|
|
101
|
+
#
|
|
102
|
+
# When the override is a String/Symbol, it replaces the entire base
|
|
103
|
+
# config (no merge — it's a complete type override with no extra options).
|
|
104
|
+
#
|
|
105
|
+
# When both are Hashes:
|
|
106
|
+
# - Scalar keys (type, config_dir, command) — override wins
|
|
107
|
+
# - args — arrays are concatenated (base first, then override)
|
|
108
|
+
# - env — hashes are merged (override keys win)
|
|
109
|
+
def self.merge_driver(base, override)
|
|
110
|
+
return normalize_driver(base) unless override
|
|
111
|
+
return normalize_driver(override) unless base
|
|
112
|
+
|
|
113
|
+
# String/Symbol override = complete replacement (no merge)
|
|
114
|
+
return normalize_driver(override) if override.is_a?(String) || override.is_a?(Symbol)
|
|
115
|
+
|
|
116
|
+
base = normalize_driver(base)
|
|
117
|
+
override = normalize_driver(override)
|
|
118
|
+
|
|
119
|
+
merged = base.merge(override)
|
|
120
|
+
# Deep-merge env hashes
|
|
121
|
+
if base[:env] && override[:env]
|
|
122
|
+
merged[:env] = base[:env].merge(override[:env])
|
|
123
|
+
end
|
|
124
|
+
# Concatenate args arrays
|
|
125
|
+
if base[:args] && override[:args]
|
|
126
|
+
merged[:args] = base[:args] + override[:args]
|
|
127
|
+
end
|
|
128
|
+
merged
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# ── Instance interface ─────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
def driver_name = raise(NotImplementedError, "#{self.class}#driver_name not implemented")
|
|
134
|
+
def cli_command = raise(NotImplementedError, "#{self.class}#cli_command not implemented")
|
|
135
|
+
def prompt_patterns = []
|
|
136
|
+
def injection_guards = []
|
|
137
|
+
|
|
138
|
+
# Returns an array of CostPattern structs for extracting cost data
|
|
139
|
+
# from PTY output. Each pattern extracts cost data from a line.
|
|
140
|
+
# @return [Array<CostPattern>] — empty by default (no cost tracking)
|
|
141
|
+
def cost_patterns = []
|
|
142
|
+
|
|
143
|
+
# Returns the CLI command string that displays cost/usage info,
|
|
144
|
+
# or nil if the CLI has no such command.
|
|
145
|
+
# @return [String, nil]
|
|
146
|
+
def cost_command = nil
|
|
147
|
+
|
|
148
|
+
# Apply config_dir override to the spawned process environment and args.
|
|
149
|
+
# Each driver knows how its CLI accepts a settings directory override
|
|
150
|
+
# (env var, flag, etc.). The base implementation is a no-op.
|
|
151
|
+
#
|
|
152
|
+
# @param config_dir [String] expanded path to the config/settings directory
|
|
153
|
+
# @param env [Hash] mutable env hash — add env vars here
|
|
154
|
+
# @param args [Array] mutable args array — prepend flags here
|
|
155
|
+
def apply_config_dir(config_dir, env:, args:)
|
|
156
|
+
# No-op by default — drivers that support config_dir override this
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# MCP installation — default prints generic manual instructions.
|
|
160
|
+
# Subclasses override to write their specific config file.
|
|
161
|
+
def install_mcp(exe_path:)
|
|
162
|
+
print_manual_instructions(exe_path)
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
private
|
|
166
|
+
|
|
167
|
+
def print_manual_instructions(exe_path)
|
|
168
|
+
$stdout.puts <<~MSG
|
|
169
|
+
Could not automatically configure MCP. Add the following manually:
|
|
170
|
+
|
|
171
|
+
Command : #{exe_path} mcp
|
|
172
|
+
Args : (none)
|
|
173
|
+
Transport : stdio
|
|
174
|
+
|
|
175
|
+
Consult your CLI's documentation for MCP server configuration.
|
|
176
|
+
MSG
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module Superkick
|
|
7
|
+
module Drivers
|
|
8
|
+
class ClaudeCode < Driver
|
|
9
|
+
DEFAULT_CONFIG_DIR = File.join(Dir.home, ".claude").freeze
|
|
10
|
+
|
|
11
|
+
def self.driver_name = :claude_code
|
|
12
|
+
|
|
13
|
+
def initialize(cli_command: "claude", config_dir: DEFAULT_CONFIG_DIR, **)
|
|
14
|
+
@cli_command = cli_command
|
|
15
|
+
@config_dir = config_dir
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
attr_reader :cli_command
|
|
19
|
+
|
|
20
|
+
def driver_name = :claude_code
|
|
21
|
+
|
|
22
|
+
def prompt_patterns
|
|
23
|
+
[
|
|
24
|
+
/^\s*>\s*$/,
|
|
25
|
+
/Human:\s*$/,
|
|
26
|
+
/\$\s*$/
|
|
27
|
+
]
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def injection_guards
|
|
31
|
+
[
|
|
32
|
+
Superkick::PatternGuard.new(
|
|
33
|
+
name: :image_attached,
|
|
34
|
+
pattern: /\[Image\s*#?\d+\]/i,
|
|
35
|
+
reason: "Image is attached to current input",
|
|
36
|
+
clear_on_submit: true
|
|
37
|
+
)
|
|
38
|
+
]
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def cost_patterns
|
|
42
|
+
[
|
|
43
|
+
CostPattern.new(
|
|
44
|
+
pattern: /Total cost:\s*\$([0-9]+\.?[0-9]*)/,
|
|
45
|
+
extractor: ->(m) { {cost_usd: m[1].to_f} }
|
|
46
|
+
),
|
|
47
|
+
CostPattern.new(
|
|
48
|
+
pattern: /(\d+(?:\.\d+)?[km]?)\s*in,\s*(\d+(?:\.\d+)?[km]?)\s*out/i,
|
|
49
|
+
extractor: ->(m) {
|
|
50
|
+
{tokens_in: parse_token_count(m[1]),
|
|
51
|
+
tokens_out: parse_token_count(m[2])}
|
|
52
|
+
}
|
|
53
|
+
)
|
|
54
|
+
]
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def cost_command = "/cost"
|
|
58
|
+
|
|
59
|
+
def apply_config_dir(config_dir, env:, args:)
|
|
60
|
+
env["CLAUDE_CONFIG_DIR"] = config_dir
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def install_mcp(exe_path:)
|
|
64
|
+
config_path = File.join(@config_dir, "settings.json")
|
|
65
|
+
config = load_config(config_path)
|
|
66
|
+
return print_manual_instructions(exe_path) unless config
|
|
67
|
+
|
|
68
|
+
existing = config.dig("mcpServers", "superkick", "command")
|
|
69
|
+
|
|
70
|
+
if existing == exe_path
|
|
71
|
+
$stdout.puts "Superkick already configured in #{config_path} (path matches)."
|
|
72
|
+
return
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
config["mcpServers"] ||= {}
|
|
76
|
+
config["mcpServers"]["superkick"] = {"command" => exe_path, "args" => ["mcp"]}
|
|
77
|
+
write_config(config_path, config)
|
|
78
|
+
verb = existing ? "Updated" : "Installed"
|
|
79
|
+
$stdout.puts "#{verb} superkick MCP server in #{config_path}."
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def load_config(path)
|
|
85
|
+
return {} unless File.exist?(path)
|
|
86
|
+
|
|
87
|
+
JSON.parse(File.read(path))
|
|
88
|
+
rescue JSON::ParserError => e
|
|
89
|
+
$stdout.puts "Parse error in #{path}: #{e.message}"
|
|
90
|
+
nil
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def parse_token_count(str)
|
|
94
|
+
num = str.to_f
|
|
95
|
+
case str[-1]
|
|
96
|
+
when "k", "K" then (num * 1_000).to_i
|
|
97
|
+
when "m", "M" then (num * 1_000_000).to_i
|
|
98
|
+
else num.to_i
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def write_config(path, config)
|
|
103
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
104
|
+
File.write(path, JSON.pretty_generate(config))
|
|
105
|
+
end
|
|
106
|
+
end
|
|
107
|
+
end
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
Superkick::Driver.register(Superkick::Drivers::ClaudeCode)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Superkick
|
|
6
|
+
module Drivers
|
|
7
|
+
class Codex < Driver
|
|
8
|
+
DEFAULT_CONFIG_DIR = File.join(Dir.home, ".codex").freeze
|
|
9
|
+
|
|
10
|
+
def self.driver_name = :codex
|
|
11
|
+
|
|
12
|
+
def initialize(cli_command: "codex", config_dir: DEFAULT_CONFIG_DIR, **)
|
|
13
|
+
@cli_command = cli_command
|
|
14
|
+
@config_dir = config_dir
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :cli_command
|
|
18
|
+
|
|
19
|
+
def driver_name = :codex
|
|
20
|
+
|
|
21
|
+
def prompt_patterns
|
|
22
|
+
[/codex>\s*$/]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apply_config_dir(config_dir, env:, args:)
|
|
26
|
+
env["CODEX_HOME"] = config_dir
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def install_mcp(exe_path:)
|
|
30
|
+
config_path = File.join(@config_dir, "config.toml")
|
|
31
|
+
content = File.exist?(config_path) ? File.read(config_path) : ""
|
|
32
|
+
|
|
33
|
+
if content.include?("[mcp.servers.superkick]")
|
|
34
|
+
$stdout.puts "Superkick already present in #{config_path}."
|
|
35
|
+
return
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
if system("codex mcp add superkick #{exe_path} mcp",
|
|
39
|
+
out: File::NULL, err: File::NULL)
|
|
40
|
+
$stdout.puts "Superkick MCP server added via `codex mcp add`."
|
|
41
|
+
return
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
FileUtils.mkdir_p(File.dirname(config_path))
|
|
45
|
+
File.open(config_path, "a") do |f|
|
|
46
|
+
f.puts "\n[mcp.servers.superkick]"
|
|
47
|
+
f.puts "command = \"#{exe_path}\""
|
|
48
|
+
f.puts "args = [\"mcp\"]"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
$stdout.puts "Superkick MCP server installed in #{config_path}."
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
Superkick::Driver.register(Superkick::Drivers::Codex)
|