Class: Clacky::CLI

Inherits:
Thor
  • Object
show all
Defined in:
lib/clacky/cli.rb

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.exit_on_failure?Boolean

Returns:

  • (Boolean)


13
14
15
# File 'lib/clacky/cli.rb', line 13

def self.exit_on_failure?
  true
end

Instance Method Details

#agentObject



62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
# File 'lib/clacky/cli.rb', line 62

def agent
  # Handle help option
  if options[:help]
    invoke :help, ["agent"]
    return
  end

  # ── Telemetry (anonymous, opt-out via CLACKY_TELEMETRY=0) ──────────
  # Fire-and-forget background thread; never blocks startup.
  Clacky::Telemetry.startup!

  # ── Sibling server discovery ───────────────────────────────────────
  # Bare-CLI mode does NOT boot an HTTP server, so skills that call
  # back into /api/* (channels, browser, scheduler) normally can't work.
  # If the user happens to have a Clacky server running on this machine
  # (in another terminal or via `clacky server`), auto-wire CLACKY_SERVER_HOST
  # / CLACKY_SERVER_PORT so those skills can reach it transparently.
  discover_sibling_server!

  agent_config = Clacky::AgentConfig.load

  # Override model if --model option is specified
  if options[:model]
    unless agent_config.switch_model_by_name(options[:model])
      # During early startup @ui may not be ready; use simple error output
      $stderr.puts "Error: model '#{options[:model]}' not found. Available: #{agent_config.model_names.join(', ')}"
      exit 1
    end
  end

  # Handle session listing
  if options[:list]
    list_sessions
    return
  end

  # Handle Ctrl+C gracefully - raise exception to be caught in the loop
  Signal.trap("INT") do
    Thread.main.raise(Clacky::AgentInterrupted, "Interrupted by user")
  end

  # Validate and get working directory
  working_dir = validate_working_directory(options[:path], agent_config)

  # Update agent config with CLI options
  agent_config.permission_mode = options[:mode].to_sym if options[:mode]
  agent_config.verbose = options[:verbose] if options[:verbose]

  # Client factory: produces a fresh Client reflecting the *current*
  # state of agent_config each time it's called. The CLI never holds a
  # long-lived `client` variable — instead, anyone who needs a client
  # (initial agent construction, /clear, etc.) calls the factory.
  #
  # This mirrors the server-side design (HTTPServer#client_factory) and
  # avoids the class of bugs where a shared client is ivar_set'd field by
  # field (easy to miss @model / @use_bedrock) and then reused for a
  # later Agent.new, serving stale credentials.
  client_factory = lambda do
    Clacky::Client.new(
      agent_config.api_key,
      base_url: agent_config.base_url,
      model: agent_config.model_name,
      anthropic_format: agent_config.anthropic_format?
    )
  end

  # Resolve agent profile name from --agent option
  agent_profile = options[:agent] || "coding"

  # Handle session loading/continuation
  session_manager = Clacky::SessionManager.new
  agent = nil
  is_session_load = false

  if options[:continue]
    agent = load_latest_session(client_factory.call, agent_config, session_manager, working_dir, profile: agent_profile)
    is_session_load = !agent.nil?
  elsif options[:attach]
    agent = load_session_by_number(client_factory.call, agent_config, session_manager, working_dir, options[:attach], profile: agent_profile)
    is_session_load = !agent.nil?
  end

  # Create new agent if no session loaded
  if agent.nil?
    agent = Clacky::Agent.new(client_factory.call, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile,
                              session_id: Clacky::SessionManager.generate_id, source: :manual)
    agent.rename("CLI Session")
  end

  # Change to working directory
  original_dir = Dir.pwd
  should_chdir = File.realpath(working_dir) != File.realpath(original_dir)
  Dir.chdir(working_dir) if should_chdir
  begin
    if options[:message]
      file_paths = Array(options[:file]) + Array(options[:image])
      run_non_interactive(agent, options[:message], file_paths, agent_config, session_manager)
    elsif options[:json]
      run_agent_with_json(agent, working_dir, agent_config, session_manager, client_factory, profile: agent_profile)
    else
      run_agent_with_ui2(agent, working_dir, agent_config, session_manager, client_factory, is_session_load: is_session_load)
    end
  ensure
    Dir.chdir(original_dir)
    Clacky::BrowserManager.instance.stop rescue nil
  end
end

#billingObject



1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
1089
1090
1091
1092
1093
1094
1095
1096
1097
1098
1099
1100
1101
1102
1103
1104
1105
1106
1107
1108
1109
1110
1111
1112
1113
1114
1115
1116
1117
1118
1119
1120
1121
1122
1123
1124
1125
1126
1127
1128
1129
1130
1131
1132
1133
1134
1135
1136
1137
1138
1139
# File 'lib/clacky/cli.rb', line 1073

def billing
  if options[:help]
    invoke :help, ["billing"]
    return
  end

  require_relative "billing/billing_store"

  store = Clacky::Billing::BillingStore.new
  period = options[:period].to_sym
  summary = store.summary(period: period)

  if options[:json]
    require "json"
    puts JSON.pretty_generate(summary)
    return
  end

  # Display formatted billing summary
  puts ""
  puts "📊 Billing Summary (#{period})"
  puts "" * 50
  puts ""

  # Total cost
  cost_str = summary[:total_cost] > 0 ? "$#{format('%.4f', summary[:total_cost])}" : "$0.0000"
  puts "  💰 Total Cost:       #{cost_str}"
  puts "  📝 Total Tokens:     #{format_number(summary[:total_tokens])}"
  puts "  📥 Prompt Tokens:    #{format_number(summary[:prompt_tokens])}"
  puts "  📤 Completion:       #{format_number(summary[:completion_tokens])}"
  puts "  🗄️  Cache Read:       #{format_number(summary[:cache_read_tokens])}"
  puts "  📝 Cache Write:      #{format_number(summary[:cache_write_tokens])}"
  puts "  🔢 API Requests:     #{summary[:record_count]}"
  puts ""

  # By model breakdown
  if summary[:by_model] && !summary[:by_model].empty?
    puts "📈 By Model:"
    puts "" * 50
    summary[:by_model].each do |model, data|
      cost = data.is_a?(Hash) ? data[:cost] : data
      requests = data.is_a?(Hash) ? data[:requests] : "?"
      puts "  #{model}"
      puts "    Cost: $#{format('%.4f', cost)}  |  Requests: #{requests}"
    end
    puts ""
  end

  # Daily breakdown (last N days)
  daily = store.daily_breakdown(days: [options[:days], 14].min)
  recent_days = daily.select { |d| d[:cost] > 0 }.last(7)

  if recent_days.any?
    puts "📅 Recent Daily Usage:"
    puts "" * 50
    recent_days.each do |day|
      bar_len = [(day[:cost] * 100).to_i, 30].min
      bar = "" * bar_len
      puts "  #{day[:date]}  $#{format('%.4f', day[:cost])}  #{bar}"
    end
    puts ""
  end

  puts "" * 50
  puts "  Data stored in: ~/.clacky/billing/"
  puts ""
end

#channel_new(name) ⇒ Object



1025
1026
1027
1028
1029
1030
1031
1032
1033
# File 'lib/clacky/cli.rb', line 1025

def channel_new(name)
  require_relative "server/channel"
  path = Clacky::Channel::Adapters::UserAdapterLoader.scaffold(name)
  puts "Created channel adapter: #{path}"
  puts "Edit the TODO sections, then run: clacky channel_verify"
rescue ArgumentError => e
  warn "Error: #{e.message}"
  exit 1
end

#channel_verifyObject



1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
# File 'lib/clacky/cli.rb', line 1036

def channel_verify
  require_relative "server/channel"
  result = Clacky::Channel::Adapters::UserAdapterLoader.last_result

  if result.loaded.empty? && result.skipped.empty?
    puts "No custom channel adapters found in ~/.clacky/channels/"
    return
  end

  result.loaded.each { |n| puts "[OK]   #{n}" }
  result.skipped.each { |(n, reason)| puts "[SKIP] #{n}#{reason}" }
  exit 1 if result.skipped.any?
end

#hook_newObject



989
990
991
992
993
994
995
996
997
# File 'lib/clacky/cli.rb', line 989

def hook_new
  require_relative "shell_hook_loader"
  path = Clacky::ShellHookLoader.scaffold
  puts "Created hooks config: #{path}"
  puts "Edit it, then run: clacky hook_verify"
rescue ArgumentError => e
  warn "Error: #{e.message}"
  exit 1
end

#hook_verifyObject



1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
# File 'lib/clacky/cli.rb', line 1000

def hook_verify
  require_relative "agent/hook_manager"
  require_relative "shell_hook_loader"
  hm = Clacky::HookManager.new
  result = Clacky::ShellHookLoader.load_into(hm)

  if result.registered.empty? && result.skipped.empty?
    puts "No hooks found in ~/.clacky/hooks.yml"
    return
  end

  result.registered.each { |(event, name)| puts "[OK]   #{event}#{name}" }
  result.skipped.each { |(name, reason)| puts "[SKIP] #{name}#{reason}" }
  exit 1 if result.skipped.any?
end

#patch_listObject



984
985
986
# File 'lib/clacky/cli.rb', line 984

def patch_list
  invoke :patch_verify, []
end

#patch_new(id, target) ⇒ Object



957
958
959
960
961
962
963
964
965
# File 'lib/clacky/cli.rb', line 957

def patch_new(id, target)
  require_relative "patch_loader"
  path = Clacky::PatchLoader.scaffold(id, target, description: options[:desc])
  puts "Created patch: #{path}"
  puts "Edit patch.rb, then run: clacky patch_verify"
rescue ArgumentError, StandardError => e
  warn "Error: #{e.message}"
  exit 1
end

#patch_verifyObject



968
969
970
971
972
973
974
975
976
977
978
979
980
981
# File 'lib/clacky/cli.rb', line 968

def patch_verify
  require "clacky"
  result = Clacky::PatchLoader.last_result

  if result.applied.empty? && result.disabled.empty? && result.skipped.empty?
    puts "No patches found in ~/.clacky/patches/"
    return
  end

  result.applied.each  { |id| puts "[OK]       #{id}" }
  result.disabled.each { |(id, reason)| puts "[DISABLED] #{id}#{reason}" }
  result.skipped.each  { |(id, reason)| puts "[SKIP]     #{id}#{reason}" }
  exit 1 if result.skipped.any?
end

#serverObject



1166
1167
1168
1169
1170
1171
1172
1173
1174
1175
1176
1177
1178
1179
1180
1181
1182
1183
1184
1185
1186
1187
1188
1189
1190
1191
1192
1193
1194
1195
1196
1197
1198
1199
1200
1201
1202
1203
1204
1205
1206
1207
1208
1209
1210
1211
1212
1213
1214
1215
1216
1217
1218
1219
1220
1221
1222
1223
1224
1225
1226
1227
1228
1229
1230
1231
1232
1233
1234
1235
1236
1237
1238
1239
1240
1241
1242
1243
1244
1245
1246
1247
1248
1249
1250
1251
1252
1253
1254
1255
1256
1257
1258
1259
1260
1261
1262
1263
1264
1265
1266
1267
1268
1269
1270
1271
1272
1273
1274
1275
1276
1277
1278
1279
1280
1281
1282
1283
1284
1285
1286
1287
1288
1289
1290
1291
1292
1293
1294
# File 'lib/clacky/cli.rb', line 1166

def server
  if options[:help]
    invoke :help, ["server"]
    return
  end

  # ── Security gate ──────────────────────────────────────────────────────
  # Binding to 0.0.0.0 exposes the server to the public network.
  # Refuse to start unless CLACKY_ACCESS_KEY env var is set.
  if options[:host] == "0.0.0.0" && !ENV.key?("CLACKY_ACCESS_KEY")
    puts <<~MSG
      ╔══════════════════════════════════════════════════════════════╗
      ║  ⚠️  Security Warning: Refusing to start                      ║
      ╠══════════════════════════════════════════════════════════════╣
      ║                                                              ║
      ║  Binding to 0.0.0.0 exposes Clacky to the public network.    ║
      ║  You must set CLACKY_ACCESS_KEY before starting the server.  ║
      ║                                                              ║
      ║  Generate a secure key:                                      ║
      ║    openssl rand -hex 32                                      ║
      ║                                                              ║
      ║  Then export it:                                             ║
      ║    export CLACKY_ACCESS_KEY=<your-generated-key>             ║
      ║                                                              ║
      ╚══════════════════════════════════════════════════════════════╝
    MSG
    exit(1)
  end
  # ─────────────────────────────────────────────────────────────────────

  if ENV["CLACKY_WORKER"] == "1"
    # ── Worker mode ───────────────────────────────────────────────────────
    # Spawned by Master. Inherit the listen socket from the file descriptor
    # passed via CLACKY_INHERIT_FD, and report back to master via CLACKY_MASTER_PID.
    require_relative "server/http_server"
    require_relative "server/epipe_safe_io"

    # Protect $stdout / $stderr from Errno::EPIPE.
    #
    # The worker inherits fd 1/2 from the Master process. If the Master's
    # stdout pipe ever breaks (e.g. it was launched by an installer or GUI
    # that has since exited), the next `puts` would raise Errno::EPIPE and
    # crash the worker — destroying all in-memory sessions, agent loops,
    # and SSE connections, and looping forever because the respawned
    # worker inherits the same broken fd.
    #
    # In healthy state these wrappers are transparent — output goes to
    # the user's terminal as usual. On first broken-pipe failure they
    # silently fall back to /dev/null and the worker stays alive.
    $stdout = Clacky::Server::EPIPESafeIO.new($stdout)
    $stderr = Clacky::Server::EPIPESafeIO.new($stderr)

    fd              = ENV["CLACKY_INHERIT_FD"].to_i
    master_pid      = ENV["CLACKY_MASTER_PID"].to_i
    # Must use TCPServer.for_fd (not Socket.for_fd) so that accept_nonblock
    # returns a single Socket, not [Socket, Addrinfo] — WEBrick expects the former.
    socket     = TCPServer.for_fd(fd)

    Clacky::Logger.console = true
    Clacky::Logger.info("[cli worker PID=#{Process.pid}] CLACKY_INHERIT_FD=#{fd} CLACKY_MASTER_PID=#{master_pid} socket=#{socket.class} fd=#{socket.fileno}")

    agent_config = Clacky::AgentConfig.load
    agent_config.permission_mode = :confirm_all

    # Apply CLI overrides to agent config (--no-compression etc.)
    # These override whatever is stored in config.yml.
    agent_config.enable_compression = false if options[:no_compression]
    agent_config.memory_update_enabled = false if options[:no_memory]
    agent_config.enable_prompt_caching = false if options[:no_caching]
    if options[:no_skill_evolution]
      agent_config.skill_evolution[:enabled] = false
    end

    client_factory = lambda do
      Clacky::Client.new(
        agent_config.api_key,
        base_url: agent_config.base_url,
        model: agent_config.model_name,
        anthropic_format: agent_config.anthropic_format?
      )
    end

    Clacky::Server::HttpServer.new(
      host:           options[:host],
      port:           options[:port],
      agent_config:   agent_config,
      client_factory: client_factory,
      brand_test:     options[:brand_test],
      socket:         socket,
      master_pid:     master_pid
    ).start
  else
    # ── Master mode ───────────────────────────────────────────────────────
    # First invocation by the user. Start the Master process which holds the
    # socket and supervises worker processes.
    require_relative "server/server_master"

    if options[:brand_test]
      say "⚡ Brand test mode — license activation uses mock data (no remote API calls).", :yellow
      say ""
      say "  Test license keys (paste any into Settings → Brand & License):", :cyan
      say ""
      say "    00000001-FFFFFFFF-DEADBEEF-CAFEBABE-00000001  →  Brand1"
      say "    00000002-FFFFFFFF-DEADBEEF-CAFEBABE-00000002  →  Brand2"
      say "    00000003-FFFFFFFF-DEADBEEF-CAFEBABE-00000003  →  Brand3"
      say ""
      say "  To reset: rm ~/.clacky/brand.yml", :cyan
      say ""
    end

    extra_flags = []
    extra_flags << "--brand-test" if options[:brand_test]
    extra_flags << "--no-compression" if options[:no_compression]
    extra_flags << "--no-memory" if options[:no_memory]
    extra_flags << "--no-caching" if options[:no_caching]
    extra_flags << "--no-skill-evolution" if options[:no_skill_evolution]

    Clacky::Logger.console = true

    # ── Telemetry (anonymous, opt-out via CLACKY_TELEMETRY=0) ──────────
    Clacky::Telemetry.startup!

    Clacky::Server::Master.new(
      host:        options[:host],
      port:        options[:port],
      extra_flags: extra_flags
    ).run
  end
end