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.
Files changed (199) hide show
  1. checksums.yaml +7 -0
  2. data/CLA.md +91 -0
  3. data/CLAUDE.md +2226 -0
  4. data/CONTRIBUTING.md +104 -0
  5. data/LICENSE +108 -0
  6. data/LICENSE-COMMERCIAL.md +39 -0
  7. data/PLAN.md +161 -0
  8. data/README.md +1155 -0
  9. data/exe/superkick +6 -0
  10. data/lib/superkick/agent/runtime.rb +82 -0
  11. data/lib/superkick/agent/runtimes/local.rb +74 -0
  12. data/lib/superkick/agent/runtimes.rb +4 -0
  13. data/lib/superkick/agent.rb +209 -0
  14. data/lib/superkick/agent_store.rb +85 -0
  15. data/lib/superkick/attach/client.rb +245 -0
  16. data/lib/superkick/attach/protocol.rb +71 -0
  17. data/lib/superkick/attach/server.rb +371 -0
  18. data/lib/superkick/budget_checker.rb +120 -0
  19. data/lib/superkick/buffer/client.rb +91 -0
  20. data/lib/superkick/buffer/server.rb +127 -0
  21. data/lib/superkick/cli/agent.rb +524 -0
  22. data/lib/superkick/cli/completion.rb +591 -0
  23. data/lib/superkick/cli/goal.rb +71 -0
  24. data/lib/superkick/cli/mcp.rb +34 -0
  25. data/lib/superkick/cli/monitor.rb +47 -0
  26. data/lib/superkick/cli/notifier.rb +39 -0
  27. data/lib/superkick/cli/repository.rb +46 -0
  28. data/lib/superkick/cli/server.rb +106 -0
  29. data/lib/superkick/cli/setup.rb +166 -0
  30. data/lib/superkick/cli/spawner.rb +85 -0
  31. data/lib/superkick/cli/team.rb +407 -0
  32. data/lib/superkick/cli.rb +175 -0
  33. data/lib/superkick/client_registry.rb +30 -0
  34. data/lib/superkick/configuration.rb +178 -0
  35. data/lib/superkick/connection.rb +56 -0
  36. data/lib/superkick/control/client.rb +78 -0
  37. data/lib/superkick/control/reply.rb +43 -0
  38. data/lib/superkick/control/server.rb +1271 -0
  39. data/lib/superkick/cost_accumulator.rb +53 -0
  40. data/lib/superkick/cost_extractor.rb +65 -0
  41. data/lib/superkick/cost_poller.rb +70 -0
  42. data/lib/superkick/driver/profile_source.rb +134 -0
  43. data/lib/superkick/driver.rb +179 -0
  44. data/lib/superkick/drivers/claude_code.rb +110 -0
  45. data/lib/superkick/drivers/codex.rb +57 -0
  46. data/lib/superkick/drivers/copilot.rb +75 -0
  47. data/lib/superkick/drivers/gemini.rb +86 -0
  48. data/lib/superkick/drivers/goose.rb +74 -0
  49. data/lib/superkick/drivers.rb +16 -0
  50. data/lib/superkick/drop.rb +80 -0
  51. data/lib/superkick/drops.rb +76 -0
  52. data/lib/superkick/environment_executor.rb +90 -0
  53. data/lib/superkick/goal.rb +95 -0
  54. data/lib/superkick/goals/agent_exit.rb +41 -0
  55. data/lib/superkick/goals/agent_signal.rb +42 -0
  56. data/lib/superkick/goals/command.rb +103 -0
  57. data/lib/superkick/history_buffer.rb +38 -0
  58. data/lib/superkick/hosted/attach/bridge.rb +52 -0
  59. data/lib/superkick/hosted/attach/client.rb +208 -0
  60. data/lib/superkick/hosted/attach/relay.rb +313 -0
  61. data/lib/superkick/hosted/attach/relay_store.rb +48 -0
  62. data/lib/superkick/hosted/bridge.rb +263 -0
  63. data/lib/superkick/hosted/buffer/bridge.rb +42 -0
  64. data/lib/superkick/hosted/buffer/client.rb +63 -0
  65. data/lib/superkick/hosted/buffer/relay.rb +126 -0
  66. data/lib/superkick/hosted/buffer/relay_store.rb +42 -0
  67. data/lib/superkick/hosted/control/client.rb +84 -0
  68. data/lib/superkick/hosted/mcp_proxy.rb +144 -0
  69. data/lib/superkick/inject_handler.rb +24 -0
  70. data/lib/superkick/injection_guard.rb +26 -0
  71. data/lib/superkick/injection_queue.rb +177 -0
  72. data/lib/superkick/injector.rb +65 -0
  73. data/lib/superkick/input_buffer.rb +171 -0
  74. data/lib/superkick/integrations/bugsnag/README.md +98 -0
  75. data/lib/superkick/integrations/bugsnag/spawner.rb +307 -0
  76. data/lib/superkick/integrations/bugsnag/templates/error_opened.liquid +17 -0
  77. data/lib/superkick/integrations/bugsnag.rb +7 -0
  78. data/lib/superkick/integrations/circleci/README.md +75 -0
  79. data/lib/superkick/integrations/circleci/monitor.rb +185 -0
  80. data/lib/superkick/integrations/circleci/probe.rb +36 -0
  81. data/lib/superkick/integrations/circleci/templates/ci_failure.liquid +8 -0
  82. data/lib/superkick/integrations/circleci/templates/ci_success.liquid +1 -0
  83. data/lib/superkick/integrations/circleci.rb +8 -0
  84. data/lib/superkick/integrations/datadog/README.md +253 -0
  85. data/lib/superkick/integrations/datadog/alert_goal.rb +94 -0
  86. data/lib/superkick/integrations/datadog/alert_monitor.rb +163 -0
  87. data/lib/superkick/integrations/datadog/alert_spawner.rb +201 -0
  88. data/lib/superkick/integrations/datadog/notification_templates/default.liquid +10 -0
  89. data/lib/superkick/integrations/datadog/notifier.rb +294 -0
  90. data/lib/superkick/integrations/datadog/spawner.rb +201 -0
  91. data/lib/superkick/integrations/datadog/templates/alert_changed.liquid +8 -0
  92. data/lib/superkick/integrations/datadog/templates/alert_escalated.liquid +8 -0
  93. data/lib/superkick/integrations/datadog/templates/alert_recovered.liquid +14 -0
  94. data/lib/superkick/integrations/datadog/templates/alert_triggered.liquid +29 -0
  95. data/lib/superkick/integrations/datadog/templates/error_opened.liquid +15 -0
  96. data/lib/superkick/integrations/datadog.rb +14 -0
  97. data/lib/superkick/integrations/docker/README.md +256 -0
  98. data/lib/superkick/integrations/docker/client.rb +295 -0
  99. data/lib/superkick/integrations/docker/runtime.rb +218 -0
  100. data/lib/superkick/integrations/docker.rb +4 -0
  101. data/lib/superkick/integrations/git/repository_source.rb +66 -0
  102. data/lib/superkick/integrations/git/version_control.rb +119 -0
  103. data/lib/superkick/integrations/git.rb +8 -0
  104. data/lib/superkick/integrations/github/README.md +300 -0
  105. data/lib/superkick/integrations/github/check_failed_spawner.rb +199 -0
  106. data/lib/superkick/integrations/github/drops.rb +114 -0
  107. data/lib/superkick/integrations/github/goal.rb +135 -0
  108. data/lib/superkick/integrations/github/issue_goal.rb +104 -0
  109. data/lib/superkick/integrations/github/issue_spawner.rb +160 -0
  110. data/lib/superkick/integrations/github/monitor.rb +251 -0
  111. data/lib/superkick/integrations/github/probe.rb +30 -0
  112. data/lib/superkick/integrations/github/repository_source.rb +228 -0
  113. data/lib/superkick/integrations/github/templates/check_failed.liquid +10 -0
  114. data/lib/superkick/integrations/github/templates/ci_failure.liquid +5 -0
  115. data/lib/superkick/integrations/github/templates/ci_success.liquid +1 -0
  116. data/lib/superkick/integrations/github/templates/issue_opened.liquid +20 -0
  117. data/lib/superkick/integrations/github/templates/pr_comment.liquid +2 -0
  118. data/lib/superkick/integrations/github/templates/pr_review.liquid +4 -0
  119. data/lib/superkick/integrations/github.rb +16 -0
  120. data/lib/superkick/integrations/honeybadger/README.md +97 -0
  121. data/lib/superkick/integrations/honeybadger/notification_templates/default.liquid +8 -0
  122. data/lib/superkick/integrations/honeybadger/notifier.rb +250 -0
  123. data/lib/superkick/integrations/honeybadger/spawner.rb +214 -0
  124. data/lib/superkick/integrations/honeybadger/templates/error_opened.liquid +17 -0
  125. data/lib/superkick/integrations/honeybadger.rb +9 -0
  126. data/lib/superkick/integrations/shell/README.md +83 -0
  127. data/lib/superkick/integrations/shell/monitor.rb +87 -0
  128. data/lib/superkick/integrations/shell/templates/shell_alert.liquid +6 -0
  129. data/lib/superkick/integrations/shell/templates/shell_success.liquid +6 -0
  130. data/lib/superkick/integrations/shell.rb +7 -0
  131. data/lib/superkick/integrations/shortcut/README.md +193 -0
  132. data/lib/superkick/integrations/shortcut/drops.rb +91 -0
  133. data/lib/superkick/integrations/shortcut/monitor.rb +582 -0
  134. data/lib/superkick/integrations/shortcut/probe.rb +34 -0
  135. data/lib/superkick/integrations/shortcut/spawner.rb +264 -0
  136. data/lib/superkick/integrations/shortcut/templates/related_story_changed.liquid +6 -0
  137. data/lib/superkick/integrations/shortcut/templates/story_blocker.liquid +8 -0
  138. data/lib/superkick/integrations/shortcut/templates/story_comment.liquid +5 -0
  139. data/lib/superkick/integrations/shortcut/templates/story_description_changed.liquid +19 -0
  140. data/lib/superkick/integrations/shortcut/templates/story_owner_changed.liquid +10 -0
  141. data/lib/superkick/integrations/shortcut/templates/story_ready.liquid +41 -0
  142. data/lib/superkick/integrations/shortcut/templates/story_state_changed.liquid +9 -0
  143. data/lib/superkick/integrations/shortcut/templates/story_unblocked.liquid +5 -0
  144. data/lib/superkick/integrations/shortcut.rb +11 -0
  145. data/lib/superkick/integrations/slack/README.md +297 -0
  146. data/lib/superkick/integrations/slack/drops.rb +70 -0
  147. data/lib/superkick/integrations/slack/notifier.rb +426 -0
  148. data/lib/superkick/integrations/slack/spawner.rb +251 -0
  149. data/lib/superkick/integrations/slack/templates/default.liquid +17 -0
  150. data/lib/superkick/integrations/slack/templates/slack_reply.liquid +3 -0
  151. data/lib/superkick/integrations/slack/templates/spawn/slack_message.liquid +10 -0
  152. data/lib/superkick/integrations/slack/thread_monitor.rb +161 -0
  153. data/lib/superkick/integrations/slack.rb +12 -0
  154. data/lib/superkick/liquid.rb +129 -0
  155. data/lib/superkick/local/repository_source.rb +148 -0
  156. data/lib/superkick/mcp_server.rb +596 -0
  157. data/lib/superkick/monitor.rb +215 -0
  158. data/lib/superkick/notification_dispatcher.rb +280 -0
  159. data/lib/superkick/notifier.rb +173 -0
  160. data/lib/superkick/notifier_state_store.rb +55 -0
  161. data/lib/superkick/notifier_template.rb +121 -0
  162. data/lib/superkick/notifiers/command.rb +124 -0
  163. data/lib/superkick/notifiers/terminal_bell.rb +41 -0
  164. data/lib/superkick/output_logger.rb +54 -0
  165. data/lib/superkick/poller.rb +126 -0
  166. data/lib/superkick/process_runner.rb +87 -0
  167. data/lib/superkick/pty_proxy.rb +403 -0
  168. data/lib/superkick/registry.rb +75 -0
  169. data/lib/superkick/repository_source.rb +195 -0
  170. data/lib/superkick/server.rb +211 -0
  171. data/lib/superkick/session_recorder.rb +154 -0
  172. data/lib/superkick/setup.rb +160 -0
  173. data/lib/superkick/spawn/agent_spawner.rb +311 -0
  174. data/lib/superkick/spawn/approval_store.rb +113 -0
  175. data/lib/superkick/spawn/handler.rb +144 -0
  176. data/lib/superkick/spawn/injector.rb +119 -0
  177. data/lib/superkick/spawn/workflow_executor.rb +196 -0
  178. data/lib/superkick/spawn/workflow_validator.rb +77 -0
  179. data/lib/superkick/spawner.rb +67 -0
  180. data/lib/superkick/supervisor.rb +516 -0
  181. data/lib/superkick/team/artifact_store.rb +92 -0
  182. data/lib/superkick/team/log.rb +140 -0
  183. data/lib/superkick/team/log_entry_drop.rb +34 -0
  184. data/lib/superkick/team/log_monitor.rb +84 -0
  185. data/lib/superkick/team/log_notifier.rb +96 -0
  186. data/lib/superkick/team/log_store.rb +40 -0
  187. data/lib/superkick/template_filters.rb +24 -0
  188. data/lib/superkick/template_renderer.rb +223 -0
  189. data/lib/superkick/templates/team_log/planning_agent.liquid +38 -0
  190. data/lib/superkick/templates/team_log/team_digest.liquid +45 -0
  191. data/lib/superkick/templates/team_log/teammate_message.liquid +7 -0
  192. data/lib/superkick/templates/team_log/worker_kickoff.liquid +37 -0
  193. data/lib/superkick/templates/workflow/workflow_triggered.liquid +22 -0
  194. data/lib/superkick/version.rb +5 -0
  195. data/lib/superkick/version_control.rb +135 -0
  196. data/lib/superkick/yaml_config.rb +302 -0
  197. data/lib/superkick.rb +198 -0
  198. data/plan.md +267 -0
  199. 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)