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.
- octomate_cli/__init__.py +8 -0
- octomate_cli/config.py +281 -0
- octomate_cli/emit.py +153 -0
- octomate_cli/launch.py +146 -0
- octomate_cli/main.py +53 -0
- octomate_cli/serve.py +444 -0
- octomate_cli/streaming/__init__.py +1 -0
- octomate_cli/streaming/deepseek.py +372 -0
- octomate_cli/streaming/files.py +367 -0
- octomate_cli/tentacles/__init__.py +1 -0
- octomate_cli/tentacles/claude/__init__.py +68 -0
- octomate_cli/tentacles/claude/config.py +24 -0
- octomate_cli/tentacles/claude/hooks.py +283 -0
- octomate_cli/tentacles/claude/mcp.py +179 -0
- octomate_cli/tentacles/codex/__init__.py +83 -0
- octomate_cli/tentacles/codex/hooks.py +231 -0
- octomate_cli/tentacles/codex/mcp.py +127 -0
- octomate_cli/tentacles/deepseek/__init__.py +97 -0
- octomate_cli/tentacles/deepseek/config.py +88 -0
- octomate_cli/tentacles/deepseek/hooks.py +254 -0
- octomate_cli/tentacles/deepseek/mcp.py +148 -0
- octomate_cli/tentacles/hooks.py +34 -0
- octomate_cli/tentacles/mcp.py +65 -0
- octomate_cli/tentacles/types.py +12 -0
- octomate_cli-0.0.2.dist-info/METADATA +46 -0
- octomate_cli-0.0.2.dist-info/RECORD +29 -0
- octomate_cli-0.0.2.dist-info/WHEEL +4 -0
- octomate_cli-0.0.2.dist-info/entry_points.txt +2 -0
- octomate_cli-0.0.2.dist-info/licenses/LICENSE +661 -0
octomate_cli/__init__.py
ADDED
|
@@ -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()
|