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
# 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])

  # 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)
  end
end

#serverObject



965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
1061
1062
1063
1064
1065
1066
1067
1068
1069
1070
1071
1072
1073
1074
1075
1076
1077
1078
1079
1080
1081
1082
1083
1084
1085
1086
1087
1088
# File 'lib/clacky/cli.rb', line 965

def server
  # ── 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.fetch("CLACKY_ACCESS_KEY", "").strip.empty?
    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