Class: Octo::CLI

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

Class Method Summary collapse

Instance Method Summary collapse

Class Method Details

.exit_on_failure?Boolean

Returns:

  • (Boolean)


12
13
14
# File 'lib/octo/cli.rb', line 12

def self.exit_on_failure?
  true
end

Instance Method Details

#agentObject



61
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
# File 'lib/octo/cli.rb', line 61

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

  # ── 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 Octo server running on this machine
  # (in another terminal or via `octo server`), auto-wire OCTO_SERVER_HOST
  # / OCTO_SERVER_PORT so those skills can reach it transparently.
  discover_sibling_server!

  agent_config = Octo::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(Octo::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
    Octo::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 = Octo::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 = Octo::Agent.new(client_factory.call, agent_config, working_dir: working_dir, ui: nil, profile: agent_profile,
                              session_id: Octo::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)
    Octo::BrowserManager.instance.stop rescue nil
  end
end

#serverObject



856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
# File 'lib/octo/cli.rb', line 856

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

  if ENV["OCTO_WORKER"] == "1"
    # ── Worker mode ───────────────────────────────────────────────────────
    # Spawned by Master. Inherit the listen socket from the file descriptor
    # passed via OCTO_INHERIT_FD, and report back to master via OCTO_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 = Octo::Server::EPIPESafeIO.new($stdout)
    $stderr = Octo::Server::EPIPESafeIO.new($stderr)

    fd              = ENV["OCTO_INHERIT_FD"].to_i
    master_pid      = ENV["OCTO_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)

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

    agent_config = Octo::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
      Octo::Client.new(
        agent_config.api_key,
        base_url: agent_config.base_url,
        model: agent_config.model_name,
        anthropic_format: agent_config.anthropic_format?
      )
    end

    Octo::Server::HttpServer.new(
      host:           options[:host],
      port:           options[:port],
      agent_config:   agent_config,
      client_factory: client_factory,
      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"

    extra_flags = []
    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]

    Octo::Logger.console = true

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