octomate-cli 0.0.2__py3-none-any.whl

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.
@@ -0,0 +1,8 @@
1
+ """Octomate's client half: the operator CLI, the native-session hook clients, and the
2
+ transcript tail.
3
+
4
+ Deliberately light: a client machine installs octomate-cli without the server.
5
+ Commands that need the server (`serve`, config-derived defaults) import it lazily
6
+ and say so plainly when it is absent. Shared stream and deployment contracts live
7
+ in octomate_protocol, which depends on neither application.
8
+ """
octomate_cli/config.py ADDED
@@ -0,0 +1,281 @@
1
+ """The client's own configuration — the two facts the CLI needs to reach Octomate:
2
+ where the server is, and the credential its hook routers require. A settings class,
3
+ the way the deployment's own config is one, so the variables, the files and their
4
+ precedence are declared in `CLISettings` rather than assembled by hand, and every
5
+ command reads the one `cli_settings()` object rather than building its own. Owned here
6
+ so a machine holding only octomate-cli never touches the server's config.
7
+
8
+ The stdlib hook scripts (`emit.py`, `launch.py`) cannot use it, and that is not an
9
+ oversight to be tidied away later. They exist to stay off the import cost of a
10
+ package on a hook the session blocks on, several times a turn: `tomllib` costs ~7ms
11
+ against `pydantic_settings`' ~230ms, on a script whose whole budget is ~25ms. So they
12
+ re-spell the variable names and the same file precedence in stdlib. Two tests in
13
+ `tests/agent/test_codex_emit.py` are what stop them drifting — one holds the names
14
+ against `CLISettings.env`, the other that both halves still pick the same file — so
15
+ a renamed field fails a test rather than a session going unauthenticated.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import secrets
22
+ import tomllib
23
+ from enum import Enum
24
+ from functools import cache
25
+ from pathlib import Path
26
+ from typing import Annotated
27
+
28
+ import typer
29
+ from pydantic import Field
30
+ from pydantic_settings import (
31
+ BaseSettings,
32
+ PydanticBaseSettingsSource,
33
+ SettingsConfigDict,
34
+ TomlConfigSettingsSource,
35
+ )
36
+ from rich.console import Console
37
+ from rich.panel import Panel
38
+
39
+ # Guidance goes to stderr, so a caller reading this command's stdout gets the file
40
+ # report alone.
41
+ console = Console(stderr=True)
42
+
43
+
44
+ class Scope(str, Enum):
45
+ user = "user"
46
+ project = "project"
47
+
48
+
49
+ # The two files, as `toml_file` wants them and as the source of both spellings below.
50
+ # Left unexpanded on purpose: pydantic resolves `~` and the relative path per settings
51
+ # object rather than at import, which is what lets one process read the cli.toml of
52
+ # whatever directory it was started in — a hook's cwd is its session's.
53
+ USER_CONFIG = "~/.config/octomate/cli.toml"
54
+ PROJECT_CONFIG = ".octomate/cli.toml"
55
+
56
+
57
+ def user_config_path() -> Path:
58
+ """`~/.config/octomate/cli.toml` — where CLI tools keep their config, git and
59
+ ruff alike."""
60
+ return Path(USER_CONFIG).expanduser()
61
+
62
+
63
+ def project_config_path() -> Path:
64
+ """`./.octomate/cli.toml` — beside the directory a session runs in, the way
65
+ `.claude/settings.json` scopes Claude. On a server machine `.octomate/` is also
66
+ the data directory; the two uses share the name, not their files."""
67
+ return Path.cwd() / PROJECT_CONFIG
68
+
69
+
70
+ class CLISettings(BaseSettings):
71
+ """What this machine's client half knows, resolved the same way everywhere.
72
+
73
+ Reached through `cli_settings()` rather than constructed: one object per process,
74
+ which is what a command or a hook is.
75
+ """
76
+
77
+ model_config = SettingsConfigDict(
78
+ # The reason there is a prefix: the server's settings read `OCTOMATE__` with
79
+ # `__` between the levels, so a client variable under that prefix would read
80
+ # as a deployment key — and one of them, the retired deployment secret,
81
+ # literally was.
82
+ env_prefix="OCTOMATE_CLI_",
83
+ # An exported-but-empty variable means unset, and the files under it still
84
+ # answer — what a shell that cleared one intends.
85
+ env_ignore_empty=True,
86
+ # Weakest first: within one TOML source the later file wins, so the project's
87
+ # own beats the machine's. That is how one directory aims at a different
88
+ # server (a debug instance) without touching the default.
89
+ toml_file=(USER_CONFIG, PROJECT_CONFIG),
90
+ )
91
+
92
+ url: str | None = Field(
93
+ default=None,
94
+ description=(
95
+ "Octomate's base URL (`http://host:port`), resolved when a hook fires "
96
+ "rather than pinned at install time — so switching servers is a config "
97
+ "switch, not a re-install. An explicit `--url` beats it."
98
+ ),
99
+ )
100
+ secret: str | None = Field(
101
+ default=None,
102
+ description=(
103
+ "This person's own bearer, which the hook routers and the served MCP "
104
+ "endpoints authenticate. Minted by `octomate configure` and registered "
105
+ "by an admin under `users.<name>.secret`."
106
+ ),
107
+ )
108
+
109
+ @classmethod
110
+ def env(cls, field: str) -> str:
111
+ """`CLISettings.env("secret")` — the variable that field reads.
112
+
113
+ The class is asked every time rather than a constant standing beside it, so
114
+ no variable name is ever spelled by hand: this composes from the prefix
115
+ pydantic is holding, and a renamed field changes what every message says.
116
+
117
+ A method and not `__class_getitem__`, which would read better at the ~20 call
118
+ sites: a subscript on a class is a *type* to a type checker, so pyright reads
119
+ `CLISettings["secret"]` as specialising the model and calls the result
120
+ `type[CLISettings]` whatever the override returns. Every site would be
121
+ mistyped, and silently so wherever the value is only interpolated.
122
+
123
+ `.get` with a default rather than a subscript because the key is optional in
124
+ `SettingsConfigDict`, and an absent prefix is the empty one.
125
+ """
126
+ return f"{cls.model_config.get('env_prefix', '')}{field}".upper()
127
+
128
+ @classmethod
129
+ def settings_customise_sources(
130
+ cls,
131
+ settings_cls: type[BaseSettings],
132
+ init_settings: PydanticBaseSettingsSource,
133
+ env_settings: PydanticBaseSettingsSource,
134
+ dotenv_settings: PydanticBaseSettingsSource,
135
+ file_secret_settings: PydanticBaseSettingsSource,
136
+ ) -> tuple[PydanticBaseSettingsSource, ...]:
137
+ """Which kinds of source there are, and in what order — per key, following
138
+ Claude's own settings pattern: an explicit value wins, then the environment,
139
+ then the files. Which files is `toml_file`'s to say, above.
140
+
141
+ The environment is ahead of the files for the cases with no home to write
142
+ into, a container or a CI step. Everywhere else the files are the durable
143
+ home and what `configure` writes: they survive launch paths the environment
144
+ does not — a GUI-launched editor never sourced a shell profile — and they are
145
+ per-user, where an exported credential is inherited by everything that shell
146
+ starts.
147
+
148
+ No dotenv and no secrets dir: `./.env` on a server machine is the
149
+ *deployment's* environment, and reading it here would let a server's file
150
+ decide a person's credential.
151
+ """
152
+ return (init_settings, env_settings, TomlConfigSettingsSource(settings_cls))
153
+
154
+
155
+ @cache
156
+ def cli_settings() -> CLISettings:
157
+ """This process's client config — the one object every command reads.
158
+
159
+ Typer has nothing like FastAPI's `Depends` to hand a command its config, and its
160
+ one shared-state channel, `ctx.obj`, is untyped and reaches only commands invoked
161
+ through the root app. So the dependency is this function, and every call site
162
+ asks it rather than building a settings object of its own.
163
+
164
+ Cached because a process is one CLI invocation or one hook, and neither moves its
165
+ own environment or working directory while it runs. Not for speed: a settings
166
+ object costs ~0.13ms to build and no invocation wants more than two. The suite is
167
+ the one process that plays many, and `tests/conftest.py` clears this between them.
168
+ """
169
+ return CLISettings()
170
+
171
+
172
+ def load_config(path: Path) -> dict[str, object]:
173
+ """One file's table, `{}` when absent — what `configure` reads back before
174
+ rewriting it, so keys it does not own survive. A file that does not parse is an
175
+ operator's mistake worth a precise error, not a silent fallback."""
176
+ try:
177
+ text = path.read_text()
178
+ except OSError:
179
+ return {}
180
+ try:
181
+ return tomllib.loads(text)
182
+ except tomllib.TOMLDecodeError as error:
183
+ raise typer.BadParameter(f"{path}: {error}") from None
184
+
185
+
186
+ def configure(
187
+ url: Annotated[
188
+ str | None,
189
+ typer.Option(help="Octomate's base URL (http://host:port)."),
190
+ ] = None,
191
+ secret: Annotated[
192
+ str | None,
193
+ typer.Option(
194
+ help="Your own credential. Omitted, the one already resolving is kept, "
195
+ "and one is generated when nothing resolves anywhere."
196
+ ),
197
+ ] = None,
198
+ scope: Annotated[
199
+ Scope,
200
+ typer.Option(
201
+ help="Which file to write: 'user' (~/.config/octomate/cli.toml) or "
202
+ "'project' (./.octomate/cli.toml, resolved first)."
203
+ ),
204
+ ] = Scope.user,
205
+ ) -> None:
206
+ """Write the client config every hook, tail and MCP entry on this machine reads.
207
+
208
+ The first of the three steps that set a person up: this mints the credential and
209
+ puts it somewhere durable, the panel it prints says how to get it registered, and
210
+ the runtimes' own `hooks install` / `mcp install` come last, once there is
211
+ something for them to resolve.
212
+
213
+ The environment still overrides both scopes, and a `--url` pinned at install time
214
+ beats everything — the files are the durable floor, so ingest works from any
215
+ launch path on a machine where octomate-cli is the only thing installed.
216
+ """
217
+ path = project_config_path() if scope is Scope.project else user_config_path()
218
+ current = load_config(path)
219
+ if url is not None:
220
+ current["url"] = url
221
+ generated = False
222
+ if secret is None:
223
+ secret = cli_settings().secret
224
+ if secret is None:
225
+ secret = secrets.token_urlsafe(32)
226
+ generated = True
227
+ current["secret"] = secret
228
+
229
+ # json.dumps output is a valid TOML basic string: the escapes JSON emits are the
230
+ # subset TOML shares, so no hand-rolled quoting and no extra dependency.
231
+ content = "".join(
232
+ f"{key} = {json.dumps(value)}\n"
233
+ for key, value in current.items()
234
+ if isinstance(value, str)
235
+ )
236
+ path.parent.mkdir(parents=True, exist_ok=True)
237
+ path.write_text(content)
238
+ path.chmod(0o600) # it holds a credential
239
+ # The one command that moves what `cli_settings()` answers, so it is the one that
240
+ # has to drop the cached answer: anything reading after this wants the file just
241
+ # written, not the object built before it existed.
242
+ cli_settings.cache_clear()
243
+
244
+ address = current.get("url") or f"(unset — hooks need ${CLISettings.env('url')})"
245
+ typer.echo(f"Wrote {path}")
246
+ typer.echo(f" url: {address}")
247
+ typer.echo(f" secret: {'generated' if generated else 'kept'}")
248
+ # Prose is left for rich to wrap; lines meant to be copied are kept short enough
249
+ # to survive intact, since a token split across two lines is worse than no help.
250
+ steps = [
251
+ "Point your runtimes at it. Each writes down what it resolves here, so "
252
+ "these come after the credential exists — and again if it moves:",
253
+ "",
254
+ " [cyan]octomate claude hooks install[/] [cyan]octomate claude mcp install[/]",
255
+ " [cyan]octomate codex hooks install[/] [cyan]octomate codex mcp install[/]",
256
+ ]
257
+ if not generated:
258
+ console.print(Panel("\n".join(steps), title="[bold]next[/]", padding=(1, 2)))
259
+ return
260
+ # A generated token is url-safe and a supplied one never reaches here, so no
261
+ # value below can carry markup.
262
+ console.print(
263
+ Panel(
264
+ "\n".join(
265
+ [
266
+ "[yellow]Octomate cannot see this credential yet[/], and its "
267
+ "routers refuse a bearer they do not know. Give it to the "
268
+ "server's admin to add as yours:",
269
+ "",
270
+ " [dim]users:[/]",
271
+ " [dim]<your username>:[/]",
272
+ f" [dim]secret:[/] [green]{secret}[/]",
273
+ "",
274
+ *steps,
275
+ ]
276
+ ),
277
+ title="[bold yellow]register this credential[/]",
278
+ border_style="yellow",
279
+ padding=(1, 2),
280
+ )
281
+ )
octomate_cli/emit.py ADDED
@@ -0,0 +1,153 @@
1
+ """Forward a native session's hook payload from stdin to Octomate's hook router.
2
+
3
+ Both agents' installers write `command` hooks that run this script — Codex because it
4
+ has no `http` hook handler at all, Claude so the settings file stays free of hosts and
5
+ credentials: with no `--url` pinned, the router's address is resolved when the hook
6
+ fires, from `OCTOMATE_CLI_URL` or the client config file, and switching servers is an
7
+ environment switch, not a re-install. The transport is HTTP either way: this only
8
+ carries stdin to the router at `<base>{--path}`.
9
+
10
+ Run by absolute path, never as `python -m octomate...`, and imports nothing from
11
+ octomate. Both matter: `octomate/__init__.py` builds `Octomate`, which pulls in
12
+ pydantic-ai and costs ~1.9s to import — per hook, on a handler the session blocks on,
13
+ several times a turn. By path with stdlib only it is ~25ms, because Python never
14
+ imports the package.
15
+
16
+ Anything added here must keep that property: stdlib imports only. The environment
17
+ variable names, the hook path, and the client-config resolution below are duplicated
18
+ from `octomate_cli/config.py` and `octomate_cli/tentacles/codex/hooks.py` (and octomate's
19
+ `tentacles/codex/hooks.py` holds `DRIVEN_ENV`) for the same reason — this
20
+ module cannot import them without paying for a package. Change them together; the
21
+ tests hold the copies to the canonical ones.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import os
28
+ import sys
29
+ import tomllib
30
+ import urllib.error
31
+ import urllib.request
32
+ from pathlib import Path
33
+
34
+ SECRET_ENV = "OCTOMATE_CLI_SECRET"
35
+ OCTOMATE_URL_ENV = "OCTOMATE_CLI_URL"
36
+ DRIVEN_ENV = "OCTOMATE_CODEX_DRIVEN"
37
+ CODEX_HOOK_PATH = "/hooks/codex"
38
+ HOOK_TIMEOUT = 10
39
+
40
+ USAGE = "usage: emit.py --path <hook-path> [--url <hook-url>]"
41
+
42
+
43
+ def config_files() -> tuple[Path, Path]:
44
+ """Project scope first, then user scope — hooks run with cwd at the directory the
45
+ session lives in, so `./.octomate/cli.toml` is that project's own override."""
46
+ return (
47
+ Path.cwd() / ".octomate" / "cli.toml",
48
+ Path.home() / ".config" / "octomate" / "cli.toml",
49
+ )
50
+
51
+
52
+ def file_config(path: Path) -> dict[str, object]:
53
+ """One file's table, `{}` when absent. A file that does not parse is reported and
54
+ treated as absent: this runs on the blocking path of someone's session, where a
55
+ traceback helps nobody."""
56
+ try:
57
+ text = path.read_text()
58
+ except OSError:
59
+ return {}
60
+ try:
61
+ return tomllib.loads(text)
62
+ except tomllib.TOMLDecodeError as error:
63
+ print(f"octomate: ignoring unparseable {path}: {error}", file=sys.stderr)
64
+ return {}
65
+
66
+
67
+ def resolved(key: str, env: str, tables: list[dict[str, object]]) -> str | None:
68
+ """The client's precedence: the environment, then the config files in scope
69
+ order."""
70
+ value = os.environ.get(env)
71
+ if value:
72
+ return value
73
+ for table in tables:
74
+ from_file = table.get(key)
75
+ if isinstance(from_file, str) and from_file:
76
+ return from_file
77
+ return None
78
+
79
+
80
+ def main(url: str, secret: str | None) -> int:
81
+ payload = json.load(sys.stdin)
82
+ if os.environ.get(DRIVEN_ENV) == "1":
83
+ # Octomate is driving this session itself and already records the run; the
84
+ # router drops the event rather than writing the conversation a second time.
85
+ payload["octomate_driven"] = True
86
+
87
+ if not secret:
88
+ print(
89
+ f"octomate: no credential — {SECRET_ENV} is unset and the "
90
+ "client config holds none, so this session is not being ingested. "
91
+ "Run `octomate configure`.",
92
+ file=sys.stderr,
93
+ )
94
+ return 1
95
+
96
+ request = urllib.request.Request(
97
+ url,
98
+ data=json.dumps(payload).encode(),
99
+ headers={
100
+ "Content-Type": "application/json",
101
+ "Authorization": f"Bearer {secret}",
102
+ },
103
+ )
104
+ try:
105
+ urllib.request.urlopen(request, timeout=HOOK_TIMEOUT).close()
106
+ except (urllib.error.URLError, OSError) as error:
107
+ # Report and get out of the way. A session is a person's own work, and Octomate
108
+ # only observes it; an ingest that cannot deliver must not take the turn down
109
+ # with it.
110
+ print(f"octomate: hook delivery to {url} failed: {error}", file=sys.stderr)
111
+ return 1
112
+ return 0
113
+
114
+
115
+ if __name__ == "__main__":
116
+ args = sys.argv[1:]
117
+ path: str | None = None
118
+ url: str | None = None
119
+ while args:
120
+ flag = args.pop(0)
121
+ if flag == "--path" and args:
122
+ path = args.pop(0)
123
+ elif flag == "--url" and args:
124
+ url = args.pop(0)
125
+ else:
126
+ print(USAGE, file=sys.stderr)
127
+ raise SystemExit(2)
128
+ # `--url` alone is the previous generation's installed form; honoring it keeps
129
+ # hooks written before `--path` existed delivering until their next re-install.
130
+ if path is None and url is None:
131
+ print(USAGE, file=sys.stderr)
132
+ raise SystemExit(2)
133
+ tables = [file_config(candidate) for candidate in config_files()]
134
+ if url is None:
135
+ base = resolved("url", OCTOMATE_URL_ENV, tables)
136
+ url = base.rstrip("/") + str(path) if base else None
137
+ if url is None:
138
+ print(
139
+ f"octomate: no target — {OCTOMATE_URL_ENV} is unset, the client config "
140
+ "names no url, and no --url was pinned. This session is not being "
141
+ "ingested; run `octomate configure`.",
142
+ file=sys.stderr,
143
+ )
144
+ status = 1
145
+ else:
146
+ status = main(url, resolved("secret", SECRET_ENV, tables))
147
+ if path is None or path == CODEX_HOOK_PATH:
148
+ # Codex reads stdout as the hook's decision; an empty object decides nothing,
149
+ # which is what an observer should do. A Claude hook's stdout is injected into
150
+ # the turn's context instead, so on Claude's path silence is the only correct
151
+ # answer.
152
+ print("{}")
153
+ raise SystemExit(status)
octomate_cli/launch.py ADDED
@@ -0,0 +1,146 @@
1
+ """Spawn the transcript tail for a native session — the launcher hook.
2
+
3
+ The forwarding hooks carry the ledger to Octomate over HTTP, but they can start
4
+ nothing on this machine; the transcript stream needs a local process, and this
5
+ command hook is what starts it. It fires on every prompt, spawns `octomate <agent>
6
+ tail` detached, and returns at once so the turn never waits — the tail itself refuses
7
+ to run twice per session, so the repeated fire is the liveness check, not a leak.
8
+ On a Codex `SubagentStop`, the event's `agent_transcript_path` rides along as
9
+ `--agent-path`: the running tail cannot see which sibling rollout is a child of its
10
+ session, so the hook that knows hands it the path (the tail spools it and exits when
11
+ one already holds the session).
12
+
13
+ The stream address is pinned only when the install pinned `--url`; otherwise it is
14
+ derived when the hook fires, from `OCTOMATE_CLI_URL` or the client config file — the
15
+ same resolution the forwarding hooks follow. With none of them, nothing spawns and
16
+ nothing is said: the emit hook on the same event already complained on stderr.
17
+
18
+ Run by absolute path, never as `python -m octomate...`, and imports nothing from
19
+ octomate: `octomate/__init__.py` builds `Octomate`, which costs ~1.9s to import, and
20
+ this command is on the blocking path of every prompt. The spawned tail pays that cost
21
+ detached, off it. Anything added here must keep the stdlib-only property; the
22
+ environment variable name and the client-config resolution are duplicated from
23
+ `octomate_cli/config.py` for the same reason — change them together.
24
+
25
+ Prints nothing on success: a `UserPromptSubmit` hook's stdout is injected into the
26
+ turn's context, so silence is the only correct answer.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import subprocess
34
+ import sys
35
+ import tomllib
36
+ from pathlib import Path
37
+
38
+ OCTOMATE_URL_ENV = "OCTOMATE_CLI_URL"
39
+
40
+ USAGE = (
41
+ "usage: launch.py [--url <stream-url>] [--path <hook-path>] "
42
+ "[--agent <claude|codex>] --octomate <bin>"
43
+ )
44
+
45
+
46
+ def config_files() -> tuple[Path, Path]:
47
+ """Project scope first, then user scope — mirroring `emit.py`'s resolution."""
48
+ return (
49
+ Path.cwd() / ".octomate" / "cli.toml",
50
+ Path.home() / ".config" / "octomate" / "cli.toml",
51
+ )
52
+
53
+
54
+ def configured_base() -> str | None:
55
+ """The server base from the environment, then the config files in scope order. A
56
+ file that does not parse counts as absent — the emit hook on the same event
57
+ reports it."""
58
+ value = os.environ.get(OCTOMATE_URL_ENV)
59
+ if value:
60
+ return value
61
+ for path in config_files():
62
+ try:
63
+ table = tomllib.loads(path.read_text())
64
+ except (OSError, tomllib.TOMLDecodeError):
65
+ continue
66
+ url = table.get("url")
67
+ if isinstance(url, str) and url:
68
+ return url
69
+ return None
70
+
71
+
72
+ def stream_url(url: str | None, path: str | None) -> str | None:
73
+ """The pinned stream URL, or one derived from the resolved base — the same
74
+ `http(s) → ws(s)` + `/stream` derivation the installer's `stream_url_for` does,
75
+ duplicated here because this script cannot import the package."""
76
+ if url is not None:
77
+ return url
78
+ base = configured_base()
79
+ if base is None or path is None:
80
+ return None
81
+ scheme, _, rest = base.rstrip("/").partition("://")
82
+ return f"{'wss' if scheme == 'https' else 'ws'}://{rest}{path}/stream"
83
+
84
+
85
+ def main(url: str | None, path: str | None, agent: str, octomate_bin: str) -> int:
86
+ event = json.load(sys.stdin)
87
+ session_id = event.get("session_id")
88
+ transcript_path = event.get("transcript_path")
89
+ if not isinstance(session_id, str) or not isinstance(transcript_path, str):
90
+ # An event that names no transcript has nothing to tail; the forwarding hooks
91
+ # still carry the ledger, so this is not the place to complain.
92
+ return 0
93
+ target = stream_url(url, path)
94
+ if target is None:
95
+ return 0
96
+ command = [
97
+ octomate_bin,
98
+ agent,
99
+ "tail",
100
+ "--session",
101
+ session_id,
102
+ "--path",
103
+ transcript_path,
104
+ "--url",
105
+ target,
106
+ ]
107
+ cwd = event.get("cwd")
108
+ if isinstance(cwd, str) and cwd:
109
+ command += ["--cwd", cwd]
110
+ agent_path = event.get("agent_transcript_path")
111
+ if isinstance(agent_path, str) and agent_path:
112
+ command += ["--agent-path", agent_path]
113
+ subprocess.Popen(
114
+ command,
115
+ stdin=subprocess.DEVNULL,
116
+ stdout=subprocess.DEVNULL,
117
+ stderr=subprocess.DEVNULL,
118
+ # Detached: the tail outlives this hook and never holds the turn.
119
+ start_new_session=True,
120
+ )
121
+ return 0
122
+
123
+
124
+ if __name__ == "__main__":
125
+ args = sys.argv[1:]
126
+ url: str | None = None
127
+ path: str | None = None
128
+ agent = "claude"
129
+ octomate_bin: str | None = None
130
+ while args:
131
+ flag = args.pop(0)
132
+ if flag == "--url" and args:
133
+ url = args.pop(0)
134
+ elif flag == "--path" and args:
135
+ path = args.pop(0)
136
+ elif flag == "--agent" and args:
137
+ agent = args.pop(0)
138
+ elif flag == "--octomate" and args:
139
+ octomate_bin = args.pop(0)
140
+ else:
141
+ print(USAGE, file=sys.stderr)
142
+ raise SystemExit(2)
143
+ if octomate_bin is None or (url is None and path is None):
144
+ print(USAGE, file=sys.stderr)
145
+ raise SystemExit(2)
146
+ raise SystemExit(main(url, path, agent, octomate_bin))
octomate_cli/main.py ADDED
@@ -0,0 +1,53 @@
1
+ """The root Typer app behind the `octomate` command: each agent's command group,
2
+ configure, serve, and upgrade.
3
+
4
+ Every group is mounted whatever the machine holds — server or client — so the surface
5
+ stays discoverable; a command that needs the absent half says so when invoked rather
6
+ than vanishing.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from importlib.metadata import PackageNotFoundError, version
12
+ from typing import Annotated
13
+
14
+ import typer
15
+
16
+ from octomate_cli.config import configure
17
+ from octomate_cli.serve import serve, upgrade
18
+ from octomate_cli.tentacles.claude import claude_typer
19
+ from octomate_cli.tentacles.codex import codex_typer
20
+ from octomate_cli.tentacles.deepseek import deepseek_typer
21
+
22
+ app = typer.Typer(help="Octomate operator CLI.", no_args_is_help=True)
23
+ app.command("serve")(serve)
24
+ app.command("upgrade")(upgrade)
25
+ # One credential per person, written here and read by every agent's hooks, tail and
26
+ # MCP entry alike.
27
+ app.command("configure")(configure)
28
+ app.add_typer(claude_typer, name="claude")
29
+ app.add_typer(codex_typer, name="codex")
30
+ app.add_typer(deepseek_typer, name="deepseek")
31
+
32
+
33
+ @app.callback(invoke_without_command=True)
34
+ def options(
35
+ show_version: Annotated[
36
+ bool,
37
+ typer.Option(
38
+ "--version", is_eager=True, help="Show installed package versions."
39
+ ),
40
+ ] = False,
41
+ ) -> None:
42
+ if show_version:
43
+ for package in ("octomate-cli", "octomate-protocol", "octomate"):
44
+ try:
45
+ installed = version(package)
46
+ except PackageNotFoundError:
47
+ continue
48
+ typer.echo(f"{package} {installed}")
49
+ raise typer.Exit()
50
+
51
+
52
+ if __name__ == "__main__":
53
+ app()