derot 0.1.0__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.
- derot/__init__.py +3 -0
- derot/__main__.py +5 -0
- derot/cli.py +287 -0
- derot/config.py +359 -0
- derot/example_config.yaml +43 -0
- derot/forms.py +713 -0
- derot/hook.py +342 -0
- derot/installer.py +335 -0
- derot/log.py +65 -0
- derot/notify.py +172 -0
- derot/remote.py +174 -0
- derot/sessions.py +1032 -0
- derot/tui.py +978 -0
- derot-0.1.0.dist-info/METADATA +272 -0
- derot-0.1.0.dist-info/RECORD +19 -0
- derot-0.1.0.dist-info/WHEEL +5 -0
- derot-0.1.0.dist-info/entry_points.txt +2 -0
- derot-0.1.0.dist-info/licenses/LICENSE +21 -0
- derot-0.1.0.dist-info/top_level.txt +1 -0
derot/__init__.py
ADDED
derot/__main__.py
ADDED
derot/cli.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
"""derot command-line entry point."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
import time
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
from . import __version__
|
|
11
|
+
from . import config as config_mod
|
|
12
|
+
from . import log as log_mod
|
|
13
|
+
from .config import ConfigError
|
|
14
|
+
from .log import log
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _load(create: bool = False):
|
|
18
|
+
path = config_mod.config_path()
|
|
19
|
+
created = config_mod.ensure_config(path) if create else False
|
|
20
|
+
return config_mod.load(path), created
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _hex_to_ansi(color: str, text: str, bold: bool) -> str:
|
|
24
|
+
r, g, b = (int(color[i:i + 2], 16) for i in (1, 3, 5))
|
|
25
|
+
return f"\033[{'1;' if bold else ''}38;2;{r};{g};{b}m{text}\033[0m"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def cmd_tui(args) -> int:
|
|
29
|
+
cfg, created = _load(create=True)
|
|
30
|
+
if created:
|
|
31
|
+
print(f"Created a starter config at {cfg.path}")
|
|
32
|
+
from . import tui
|
|
33
|
+
tui.run(cfg, created=created)
|
|
34
|
+
return 0
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def cmd_init(args) -> int:
|
|
38
|
+
path = config_mod.config_path()
|
|
39
|
+
if config_mod.ensure_config(path):
|
|
40
|
+
print(f"Created {path}")
|
|
41
|
+
print("Next: edit it, then run `derot login ACCOUNT` and `derot setup`.")
|
|
42
|
+
else:
|
|
43
|
+
print(f"{path} already exists")
|
|
44
|
+
return 0
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def cmd_setup(args) -> int:
|
|
48
|
+
from . import installer
|
|
49
|
+
cfg, _ = _load()
|
|
50
|
+
return installer.setup(cfg, remove=args.remove)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def cmd_ls(args) -> int:
|
|
54
|
+
from . import sessions
|
|
55
|
+
from .notify import brief
|
|
56
|
+
from .sessions import STYLE
|
|
57
|
+
cfg, _ = _load()
|
|
58
|
+
statuses, offline = sessions.poll_full(cfg)
|
|
59
|
+
if not statuses:
|
|
60
|
+
print(f"no sessions yet — add some to {cfg.path}")
|
|
61
|
+
return 0
|
|
62
|
+
color = sys.stdout.isatty()
|
|
63
|
+
now = time.time()
|
|
64
|
+
rows = []
|
|
65
|
+
for st in statuses:
|
|
66
|
+
icon, _, hexcolor, bold = STYLE[st.state]
|
|
67
|
+
name = st.name + (" ◂" if st.attached else "")
|
|
68
|
+
rows.append((icon, name, st.account.name, st.host.name,
|
|
69
|
+
sessions.status_text(st, now), brief(sessions.happening(st), 100),
|
|
70
|
+
hexcolor, bold))
|
|
71
|
+
headers = ("", "SESSION", "ACCOUNT", "MACHINE", "STATUS", "WHAT'S HAPPENING")
|
|
72
|
+
widths = [max(len(headers[i]), *(len(r[i]) for r in rows)) for i in range(5)]
|
|
73
|
+
|
|
74
|
+
def line(cells, hexcolor=None, bold=False) -> str:
|
|
75
|
+
parts = [cells[i].ljust(widths[i]) for i in range(5)] + [cells[5]]
|
|
76
|
+
if color and hexcolor:
|
|
77
|
+
parts[0] = _hex_to_ansi(hexcolor, parts[0], bold)
|
|
78
|
+
parts[4] = _hex_to_ansi(hexcolor, parts[4], bold)
|
|
79
|
+
return " ".join(parts).rstrip()
|
|
80
|
+
|
|
81
|
+
print(line(headers))
|
|
82
|
+
for r in rows:
|
|
83
|
+
print(line(r[:6], r[6], r[7]))
|
|
84
|
+
for host, err in offline.items():
|
|
85
|
+
print(f"✗ can't reach {host}: {err}", file=sys.stderr)
|
|
86
|
+
return 0
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def cmd_start(args) -> int:
|
|
90
|
+
from . import installer, sessions
|
|
91
|
+
cfg, _ = _load()
|
|
92
|
+
cfg.session(args.name)
|
|
93
|
+
try:
|
|
94
|
+
s = cfg.session(args.name)
|
|
95
|
+
if not installer.ensure_hooks(cfg, cfg.host_of(s), cfg.account_of(s)):
|
|
96
|
+
print(f"derot: status hooks for {s.account} on {s.host} couldn't be set up "
|
|
97
|
+
"(see `derot log`)", file=sys.stderr)
|
|
98
|
+
sessions.start(cfg, args.name, cont=args.cont, resume=args.resume or "")
|
|
99
|
+
except sessions.SessionError as e:
|
|
100
|
+
print(f"derot: {e}", file=sys.stderr)
|
|
101
|
+
return 1
|
|
102
|
+
if args.detach:
|
|
103
|
+
print(f"started {args.name}")
|
|
104
|
+
return 0
|
|
105
|
+
return sessions.attach(cfg, args.name)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def cmd_history(args) -> int:
|
|
109
|
+
from . import sessions
|
|
110
|
+
cfg, _ = _load()
|
|
111
|
+
cfg.session(args.name)
|
|
112
|
+
try:
|
|
113
|
+
items = sessions.conversations(cfg, args.name, limit=args.n)
|
|
114
|
+
except sessions.SessionError as e:
|
|
115
|
+
print(f"derot: {e}", file=sys.stderr)
|
|
116
|
+
return 1
|
|
117
|
+
if not items:
|
|
118
|
+
print(f"no past conversations for {args.name}")
|
|
119
|
+
for c in items:
|
|
120
|
+
print(f"{c.id} {sessions.age(c.mtime):>4} ago {' '.join((c.title or c.first).split())[:70]}")
|
|
121
|
+
return 0
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def cmd_attach(args) -> int:
|
|
125
|
+
from . import sessions
|
|
126
|
+
cfg, _ = _load()
|
|
127
|
+
cfg.session(args.name)
|
|
128
|
+
return sessions.attach(cfg, args.name)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def cmd_stop(args) -> int:
|
|
132
|
+
from . import sessions
|
|
133
|
+
cfg, _ = _load()
|
|
134
|
+
cfg.session(args.name)
|
|
135
|
+
try:
|
|
136
|
+
sessions.stop(cfg, args.name)
|
|
137
|
+
except sessions.SessionError as e:
|
|
138
|
+
print(f"derot: {e}", file=sys.stderr)
|
|
139
|
+
return 1
|
|
140
|
+
print(f"stopped {args.name}")
|
|
141
|
+
return 0
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def cmd_watch(args) -> int:
|
|
145
|
+
from . import sessions
|
|
146
|
+
from .notify import Notifier
|
|
147
|
+
from .notify import acquire_watch_lock
|
|
148
|
+
cfg, _ = _load()
|
|
149
|
+
if not acquire_watch_lock():
|
|
150
|
+
print("derot watch is already running", file=sys.stderr)
|
|
151
|
+
return 1
|
|
152
|
+
notifier = Notifier(cfg.notify)
|
|
153
|
+
print(f"watching {len(cfg.sessions)} session(s) every {cfg.refresh:g}s — Ctrl-C to stop")
|
|
154
|
+
mtime = _mtime(cfg.path)
|
|
155
|
+
try:
|
|
156
|
+
while True:
|
|
157
|
+
m = _mtime(cfg.path)
|
|
158
|
+
if m != mtime:
|
|
159
|
+
mtime = m
|
|
160
|
+
try:
|
|
161
|
+
cfg = config_mod.load(cfg.path)
|
|
162
|
+
notifier.settings = cfg.notify
|
|
163
|
+
print("config reloaded")
|
|
164
|
+
except ConfigError as e:
|
|
165
|
+
log.error("watch: config not reloaded: %s", e)
|
|
166
|
+
print(f"config not reloaded: {e}", file=sys.stderr)
|
|
167
|
+
try:
|
|
168
|
+
statuses = sessions.poll(cfg)
|
|
169
|
+
for title, body in notifier.update(statuses):
|
|
170
|
+
print(f"{time.strftime('%H:%M:%S')} {title}: {body}", flush=True)
|
|
171
|
+
except Exception as e: # keep watching; logged too (a service's stderr is unread)
|
|
172
|
+
log.error("watch: poll failed: %s", e, exc_info=e)
|
|
173
|
+
print(f"poll failed: {e}", file=sys.stderr)
|
|
174
|
+
time.sleep(cfg.refresh)
|
|
175
|
+
except KeyboardInterrupt:
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _mtime(path) -> float:
|
|
180
|
+
try:
|
|
181
|
+
return path.stat().st_mtime if path else 0.0
|
|
182
|
+
except OSError:
|
|
183
|
+
return 0.0
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def cmd_log(args) -> int:
|
|
187
|
+
path = log_mod.log_path()
|
|
188
|
+
if args.follow:
|
|
189
|
+
import subprocess
|
|
190
|
+
try:
|
|
191
|
+
return subprocess.call(["tail", "-n", str(args.lines), "-F", str(path)])
|
|
192
|
+
except KeyboardInterrupt:
|
|
193
|
+
return 0
|
|
194
|
+
text = log_mod.tail(args.lines)
|
|
195
|
+
print(text if text else "(empty)")
|
|
196
|
+
print(f"\n— {path} (derot log -f to follow; DEROT_DEBUG=1 logs every command)",
|
|
197
|
+
file=sys.stderr)
|
|
198
|
+
return 0
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def cmd_login(args) -> int:
|
|
202
|
+
from . import sessions
|
|
203
|
+
cfg, _ = _load()
|
|
204
|
+
try:
|
|
205
|
+
return sessions.login(cfg, args.account, args.host)
|
|
206
|
+
except sessions.SessionError as e:
|
|
207
|
+
print(f"derot: {e}", file=sys.stderr)
|
|
208
|
+
return 1
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
212
|
+
p = argparse.ArgumentParser(
|
|
213
|
+
prog="derot",
|
|
214
|
+
description="One console for every Claude Code and Codex session, across "
|
|
215
|
+
"projects, accounts and machines.",
|
|
216
|
+
)
|
|
217
|
+
p.add_argument("--version", action="version", version=f"derot {__version__}")
|
|
218
|
+
p.set_defaults(func=cmd_tui)
|
|
219
|
+
sub = p.add_subparsers(dest="command", metavar="COMMAND")
|
|
220
|
+
|
|
221
|
+
sub.add_parser("init", help="create the config file").set_defaults(func=cmd_init)
|
|
222
|
+
|
|
223
|
+
sp = sub.add_parser("setup", help="install / remove status hooks on every host+account")
|
|
224
|
+
sp.add_argument("--remove", action="store_true", help="remove the hooks instead")
|
|
225
|
+
sp.set_defaults(func=cmd_setup)
|
|
226
|
+
|
|
227
|
+
sub.add_parser("ls", help="print a status table").set_defaults(func=cmd_ls)
|
|
228
|
+
|
|
229
|
+
sp = sub.add_parser("start", help="start a session and attach to it")
|
|
230
|
+
sp.add_argument("name")
|
|
231
|
+
sp.add_argument("-c", "--continue", dest="cont", action="store_true",
|
|
232
|
+
help="resume the last conversation in that folder")
|
|
233
|
+
sp.add_argument("-r", "--resume", metavar="ID",
|
|
234
|
+
help="resume that conversation (ids from `derot history NAME`)")
|
|
235
|
+
sp.add_argument("-d", "--detach", action="store_true", help="don't attach")
|
|
236
|
+
sp.set_defaults(func=cmd_start)
|
|
237
|
+
|
|
238
|
+
sp = sub.add_parser("history", help="list a session's past conversations (newest first)")
|
|
239
|
+
sp.add_argument("name")
|
|
240
|
+
sp.add_argument("-n", type=int, default=20, help="how many (default 20)")
|
|
241
|
+
sp.set_defaults(func=cmd_history)
|
|
242
|
+
|
|
243
|
+
sp = sub.add_parser("attach", help="attach to a running session")
|
|
244
|
+
sp.add_argument("name")
|
|
245
|
+
sp.set_defaults(func=cmd_attach)
|
|
246
|
+
|
|
247
|
+
sp = sub.add_parser("stop", help="stop a session")
|
|
248
|
+
sp.add_argument("name")
|
|
249
|
+
sp.set_defaults(func=cmd_stop)
|
|
250
|
+
|
|
251
|
+
sub.add_parser("watch", help="poll and send notifications without the console"
|
|
252
|
+
).set_defaults(func=cmd_watch)
|
|
253
|
+
|
|
254
|
+
sp = sub.add_parser("log", help="show the log (~/.derot/derot.log), for tracing errors")
|
|
255
|
+
sp.add_argument("-n", "--lines", type=int, default=60, help="how many lines (default 60)")
|
|
256
|
+
sp.add_argument("-f", "--follow", action="store_true", help="keep printing new lines")
|
|
257
|
+
sp.set_defaults(func=cmd_log)
|
|
258
|
+
|
|
259
|
+
sp = sub.add_parser("login", help="log in to an account (Claude Code: /login; Codex: codex login)")
|
|
260
|
+
sp.add_argument("account")
|
|
261
|
+
sp.add_argument("--host", default="local")
|
|
262
|
+
sp.set_defaults(func=cmd_login)
|
|
263
|
+
return p
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
267
|
+
args = build_parser().parse_args(argv)
|
|
268
|
+
log_mod.setup()
|
|
269
|
+
if args.func is not cmd_log:
|
|
270
|
+
log.debug("derot %s", " ".join(sys.argv[1:] if argv is None else argv))
|
|
271
|
+
try:
|
|
272
|
+
return int(args.func(args) or 0)
|
|
273
|
+
except ConfigError as e:
|
|
274
|
+
log.error("config error: %s", e)
|
|
275
|
+
print(f"derot: {e}", file=sys.stderr)
|
|
276
|
+
return 2
|
|
277
|
+
except KeyboardInterrupt:
|
|
278
|
+
return 130
|
|
279
|
+
except Exception as e:
|
|
280
|
+
log.critical("unexpected error in `derot %s`", args.command or "", exc_info=e)
|
|
281
|
+
print(f"derot: unexpected error: {e}\n(full traceback in {log_mod.log_path()})",
|
|
282
|
+
file=sys.stderr)
|
|
283
|
+
return 1
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
if __name__ == "__main__":
|
|
287
|
+
sys.exit(main())
|
derot/config.py
ADDED
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
"""Load and validate ~/.config/derot/config.yaml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import re
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
import yaml
|
|
12
|
+
|
|
13
|
+
from .log import log
|
|
14
|
+
|
|
15
|
+
NAME_RE = re.compile(r"^[A-Za-z0-9_-][A-Za-z0-9_.-]*$")
|
|
16
|
+
DEFAULT_CONFIG_DIR = "~/.claude"
|
|
17
|
+
|
|
18
|
+
# The coding agents derot can run. Each account belongs to one: its folder is that agent's
|
|
19
|
+
# config folder (CLAUDE_CONFIG_DIR / CODEX_HOME).
|
|
20
|
+
AGENTS = {
|
|
21
|
+
"claude": {"label": "Claude Code", "dir": "~/.claude", "env": "CLAUDE_CONFIG_DIR"},
|
|
22
|
+
"codex": {"label": "Codex", "dir": "~/.codex", "env": "CODEX_HOME"},
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ConfigError(Exception):
|
|
27
|
+
"""A one-line, user-facing configuration problem."""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@dataclass
|
|
31
|
+
class Host:
|
|
32
|
+
name: str
|
|
33
|
+
ssh: Optional[str] = None
|
|
34
|
+
claude: str = "claude"
|
|
35
|
+
codex: str = "codex"
|
|
36
|
+
|
|
37
|
+
def command(self, agent: str) -> str:
|
|
38
|
+
"""The command (or full path) for an agent on this machine."""
|
|
39
|
+
return self.codex if agent == "codex" else self.claude
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def is_local(self) -> bool:
|
|
43
|
+
return self.ssh is None
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def label(self) -> str:
|
|
47
|
+
return "this Mac" if self.is_local else str(self.ssh)
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass
|
|
51
|
+
class Account:
|
|
52
|
+
name: str
|
|
53
|
+
config_dir: str = DEFAULT_CONFIG_DIR
|
|
54
|
+
env: Dict[str, str] = field(default_factory=dict)
|
|
55
|
+
agent: str = "claude"
|
|
56
|
+
|
|
57
|
+
@property
|
|
58
|
+
def default_dir(self) -> str:
|
|
59
|
+
return AGENTS[self.agent]["dir"]
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def dir_env(self) -> str:
|
|
63
|
+
"""The variable that points the agent at this account's folder."""
|
|
64
|
+
return AGENTS[self.agent]["env"]
|
|
65
|
+
|
|
66
|
+
@property
|
|
67
|
+
def agent_label(self) -> str:
|
|
68
|
+
return AGENTS[self.agent]["label"]
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def is_default_dir(self) -> bool:
|
|
72
|
+
"""True for the agent's default folder, however it is written (on this machine)."""
|
|
73
|
+
d = self.config_dir.rstrip("/")
|
|
74
|
+
if d == self.default_dir:
|
|
75
|
+
return True
|
|
76
|
+
return os.path.expanduser(d) == os.path.expanduser(self.default_dir)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
@dataclass
|
|
80
|
+
class SessionDef:
|
|
81
|
+
name: str
|
|
82
|
+
host: str
|
|
83
|
+
dir: str
|
|
84
|
+
account: str
|
|
85
|
+
note: str = ""
|
|
86
|
+
args: str = ""
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass
|
|
90
|
+
class Notify:
|
|
91
|
+
on_waiting: bool = True
|
|
92
|
+
on_done: bool = True
|
|
93
|
+
sound: str = "Glass"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@dataclass
|
|
97
|
+
class Config:
|
|
98
|
+
hosts: Dict[str, Host]
|
|
99
|
+
accounts: Dict[str, Account]
|
|
100
|
+
sessions: List[SessionDef]
|
|
101
|
+
notify: Notify = field(default_factory=Notify)
|
|
102
|
+
refresh: float = 3
|
|
103
|
+
detach_key: str = "C-q"
|
|
104
|
+
path: Optional[Path] = None
|
|
105
|
+
|
|
106
|
+
def session(self, name: str) -> SessionDef:
|
|
107
|
+
for s in self.sessions:
|
|
108
|
+
if s.name == name:
|
|
109
|
+
return s
|
|
110
|
+
raise ConfigError(f"no session named '{name}' in the config")
|
|
111
|
+
|
|
112
|
+
def host_of(self, s: SessionDef) -> Host:
|
|
113
|
+
return self.hosts[s.host]
|
|
114
|
+
|
|
115
|
+
def account_of(self, s: SessionDef) -> Account:
|
|
116
|
+
return self.accounts[s.account]
|
|
117
|
+
|
|
118
|
+
def used_hosts(self) -> List[Host]:
|
|
119
|
+
"""Hosts that have at least one session, in config order."""
|
|
120
|
+
seen: List[str] = []
|
|
121
|
+
for s in self.sessions:
|
|
122
|
+
if s.host not in seen:
|
|
123
|
+
seen.append(s.host)
|
|
124
|
+
return [self.hosts[h] for h in seen]
|
|
125
|
+
|
|
126
|
+
def accounts_on(self, host: str) -> List[Account]:
|
|
127
|
+
seen: List[str] = []
|
|
128
|
+
for s in self.sessions:
|
|
129
|
+
if s.host == host and s.account not in seen:
|
|
130
|
+
seen.append(s.account)
|
|
131
|
+
return [self.accounts[a] for a in seen]
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def config_path() -> Path:
|
|
135
|
+
env = os.environ.get("DEROT_CONFIG")
|
|
136
|
+
if env:
|
|
137
|
+
return Path(env).expanduser()
|
|
138
|
+
return Path("~/.config/derot/config.yaml").expanduser()
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def example_text() -> str:
|
|
142
|
+
return (Path(__file__).parent / "example_config.yaml").read_text()
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def ensure_config(path: Optional[Path] = None) -> bool:
|
|
146
|
+
"""Write the example config if missing. Returns True if it was created."""
|
|
147
|
+
path = path or config_path()
|
|
148
|
+
if path.exists():
|
|
149
|
+
return False
|
|
150
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
151
|
+
path.write_text(example_text())
|
|
152
|
+
os.chmod(path, 0o600)
|
|
153
|
+
return True
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def norm_path(path: str) -> str:
|
|
157
|
+
"""`$HOME/x` / `${HOME}/x` -> `~/x`: everything downstream expands `~` (shell and
|
|
158
|
+
Python alike), but a quoted `$HOME` would reach Claude as a literal relative path."""
|
|
159
|
+
for prefix in ("${HOME}", "$HOME"):
|
|
160
|
+
if path == prefix or path.startswith(prefix + "/"):
|
|
161
|
+
return "~" + path[len(prefix):]
|
|
162
|
+
return path
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _mapping(value, where: str) -> dict:
|
|
166
|
+
if value is None:
|
|
167
|
+
return {}
|
|
168
|
+
if not isinstance(value, dict):
|
|
169
|
+
raise ConfigError(f"{where}: expected a mapping")
|
|
170
|
+
return value
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _str(value, where: str, default: str = "") -> str:
|
|
174
|
+
if value is None:
|
|
175
|
+
return default
|
|
176
|
+
if isinstance(value, (dict, list)):
|
|
177
|
+
raise ConfigError(f"{where}: expected a string")
|
|
178
|
+
return str(value)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
def parse(data, path: Optional[Path] = None) -> Config:
|
|
182
|
+
data = _mapping(data, "config")
|
|
183
|
+
|
|
184
|
+
hosts: Dict[str, Host] = {"local": Host("local")}
|
|
185
|
+
for name, h in _mapping(data.get("hosts"), "hosts").items():
|
|
186
|
+
name = str(name)
|
|
187
|
+
h = _mapping(h, f"host '{name}'")
|
|
188
|
+
claude = _str(h.get("claude"), f"host '{name}' claude", "claude") or "claude"
|
|
189
|
+
codex = _str(h.get("codex"), f"host '{name}' codex", "codex") or "codex"
|
|
190
|
+
if name == "local":
|
|
191
|
+
hosts["local"] = Host("local", None, claude, codex)
|
|
192
|
+
continue
|
|
193
|
+
ssh = _str(h.get("ssh"), f"host '{name}' ssh")
|
|
194
|
+
if not ssh:
|
|
195
|
+
raise ConfigError(f"host '{name}': missing 'ssh' (e.g. ssh: user@my-vm)")
|
|
196
|
+
hosts[name] = Host(name, ssh, claude, codex)
|
|
197
|
+
|
|
198
|
+
accounts: Dict[str, Account] = {}
|
|
199
|
+
for name, a in _mapping(data.get("accounts"), "accounts").items():
|
|
200
|
+
name = str(name)
|
|
201
|
+
a = _mapping(a, f"account '{name}'")
|
|
202
|
+
agent = _str(a.get("agent"), f"account '{name}' agent", "claude") or "claude"
|
|
203
|
+
if agent not in AGENTS:
|
|
204
|
+
raise ConfigError(f"account '{name}': unknown agent '{agent}' (use "
|
|
205
|
+
+ " or ".join(AGENTS) + ")")
|
|
206
|
+
default = AGENTS[agent]["dir"]
|
|
207
|
+
cdir = _str(a.get("config_dir"), f"account '{name}' config_dir", default)
|
|
208
|
+
env = {
|
|
209
|
+
str(k): _str(v, f"account '{name}' env {k}")
|
|
210
|
+
for k, v in _mapping(a.get("env"), f"account '{name}' env").items()
|
|
211
|
+
}
|
|
212
|
+
accounts[name] = Account(name, norm_path(cdir or default), env, agent)
|
|
213
|
+
if not accounts:
|
|
214
|
+
accounts["default"] = Account("default", DEFAULT_CONFIG_DIR)
|
|
215
|
+
|
|
216
|
+
raw_sessions = data.get("sessions") or []
|
|
217
|
+
if not isinstance(raw_sessions, list):
|
|
218
|
+
raise ConfigError("sessions: expected a list")
|
|
219
|
+
sessions: List[SessionDef] = []
|
|
220
|
+
names = set()
|
|
221
|
+
tmux_keys: Dict[str, str] = {}
|
|
222
|
+
for i, s in enumerate(raw_sessions):
|
|
223
|
+
s = _mapping(s, f"session #{i + 1}")
|
|
224
|
+
name = _str(s.get("name"), f"session #{i + 1} name")
|
|
225
|
+
label = f"session '{name}'" if name else f"session #{i + 1}"
|
|
226
|
+
if not name or not NAME_RE.match(name):
|
|
227
|
+
raise ConfigError(f"{label}: invalid name (use letters, digits, _ . -; not starting with .)")
|
|
228
|
+
if name in names:
|
|
229
|
+
raise ConfigError(f"{label}: duplicate session name")
|
|
230
|
+
clash = tmux_keys.get(name.replace(".", "_"))
|
|
231
|
+
if clash: # tmux names write `.` as `_`
|
|
232
|
+
raise ConfigError(f"{label}: too similar to session '{clash}' (. and _ count "
|
|
233
|
+
"as the same in tmux session names)")
|
|
234
|
+
names.add(name)
|
|
235
|
+
tmux_keys[name.replace(".", "_")] = name
|
|
236
|
+
host = _str(s.get("host"), f"{label} host", "local") or "local"
|
|
237
|
+
if host not in hosts:
|
|
238
|
+
raise ConfigError(f"{label}: unknown host '{host}'")
|
|
239
|
+
if len(accounts) == 1 and not s.get("account"):
|
|
240
|
+
account = next(iter(accounts))
|
|
241
|
+
else:
|
|
242
|
+
account = _str(s.get("account"), f"{label} account")
|
|
243
|
+
if account not in accounts:
|
|
244
|
+
raise ConfigError(f"{label}: unknown account '{account}'")
|
|
245
|
+
d = _str(s.get("dir"), f"{label} dir")
|
|
246
|
+
if not d:
|
|
247
|
+
raise ConfigError(f"{label}: missing 'dir'")
|
|
248
|
+
sessions.append(SessionDef(
|
|
249
|
+
name=name, host=host, dir=norm_path(d), account=account,
|
|
250
|
+
note=_str(s.get("note"), f"{label} note"),
|
|
251
|
+
args=_str(s.get("args"), f"{label} args"),
|
|
252
|
+
))
|
|
253
|
+
|
|
254
|
+
n = _mapping(data.get("notify"), "notify")
|
|
255
|
+
notify = Notify(
|
|
256
|
+
on_waiting=bool(n.get("on_waiting", True)),
|
|
257
|
+
on_done=bool(n.get("on_done", True)),
|
|
258
|
+
sound=_str(n.get("sound", "Glass"), "notify sound"),
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
try:
|
|
262
|
+
refresh = float(data.get("refresh", 3))
|
|
263
|
+
except (TypeError, ValueError):
|
|
264
|
+
raise ConfigError("refresh: expected a number of seconds")
|
|
265
|
+
if not 0 < refresh <= 3600: # also rejects .inf and .nan
|
|
266
|
+
raise ConfigError("refresh: expected a number of seconds between 1 and 3600")
|
|
267
|
+
refresh = max(1.0, refresh)
|
|
268
|
+
detach_key = _str(data.get("detach_key"), "detach_key", "C-q") or "C-q"
|
|
269
|
+
|
|
270
|
+
return Config(hosts, accounts, sessions, notify, refresh, detach_key, path)
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
SAVED_HEADER = """\
|
|
274
|
+
# derot configuration (edited from the derot console; comments are not kept).
|
|
275
|
+
# Hosts: machines ("local" is this computer). Accounts: Claude Code or Codex folders.
|
|
276
|
+
# Sessions: a project folder + machine + account. See the README for every option.
|
|
277
|
+
"""
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def to_dict(cfg: Config) -> dict:
|
|
281
|
+
"""The YAML-ready form of cfg, leaving out values that are at their defaults."""
|
|
282
|
+
hosts: dict = {}
|
|
283
|
+
for h in cfg.hosts.values():
|
|
284
|
+
entry: dict = {}
|
|
285
|
+
if not h.is_local:
|
|
286
|
+
entry["ssh"] = h.ssh
|
|
287
|
+
if h.claude != "claude":
|
|
288
|
+
entry["claude"] = h.claude
|
|
289
|
+
if h.codex != "codex":
|
|
290
|
+
entry["codex"] = h.codex
|
|
291
|
+
if entry or not h.is_local:
|
|
292
|
+
hosts[h.name] = entry
|
|
293
|
+
accounts: dict = {}
|
|
294
|
+
for a in cfg.accounts.values():
|
|
295
|
+
entry = {"config_dir": a.config_dir}
|
|
296
|
+
if a.agent != "claude":
|
|
297
|
+
entry = {"agent": a.agent, **entry}
|
|
298
|
+
if a.env:
|
|
299
|
+
entry["env"] = dict(a.env)
|
|
300
|
+
accounts[a.name] = entry
|
|
301
|
+
sessions = []
|
|
302
|
+
for s in cfg.sessions:
|
|
303
|
+
entry = {"name": s.name, "host": s.host, "dir": s.dir, "account": s.account}
|
|
304
|
+
if s.note:
|
|
305
|
+
entry["note"] = s.note
|
|
306
|
+
if s.args:
|
|
307
|
+
entry["args"] = s.args
|
|
308
|
+
sessions.append(entry)
|
|
309
|
+
data: dict = {}
|
|
310
|
+
if hosts:
|
|
311
|
+
data["hosts"] = hosts
|
|
312
|
+
data["accounts"] = accounts
|
|
313
|
+
data["sessions"] = sessions
|
|
314
|
+
data["notify"] = {"on_waiting": cfg.notify.on_waiting, "on_done": cfg.notify.on_done,
|
|
315
|
+
"sound": cfg.notify.sound}
|
|
316
|
+
data["refresh"] = int(cfg.refresh) if cfg.refresh == int(cfg.refresh) else cfg.refresh
|
|
317
|
+
data["detach_key"] = cfg.detach_key
|
|
318
|
+
return data
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def save(cfg: Config, path: Optional[Path] = None) -> Config:
|
|
322
|
+
"""Validate and write cfg. Keeps a one-time backup of the hand-written file."""
|
|
323
|
+
path = path or cfg.path or config_path()
|
|
324
|
+
data = to_dict(cfg)
|
|
325
|
+
checked = parse(data, path) # raises ConfigError if the change made it invalid
|
|
326
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
backup = path.with_name(path.name + ".bak")
|
|
328
|
+
if path.exists() and not backup.exists():
|
|
329
|
+
backup.write_text(path.read_text())
|
|
330
|
+
tmp = path.with_name(f".{path.name}.tmp")
|
|
331
|
+
text = SAVED_HEADER + "\n" + yaml.safe_dump(
|
|
332
|
+
data, sort_keys=False, allow_unicode=True, default_flow_style=False)
|
|
333
|
+
# 0600: the config can hold API keys (account env)
|
|
334
|
+
fd = os.open(tmp, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
|
|
335
|
+
with os.fdopen(fd, "w") as f:
|
|
336
|
+
f.write(text)
|
|
337
|
+
os.chmod(tmp, 0o600)
|
|
338
|
+
os.replace(tmp, path)
|
|
339
|
+
if backup.exists():
|
|
340
|
+
os.chmod(backup, 0o600)
|
|
341
|
+
log.info("saved config %s (%d sessions, accounts: %s, hosts: %s)", path,
|
|
342
|
+
len(checked.sessions), ", ".join(checked.accounts), ", ".join(checked.hosts))
|
|
343
|
+
return checked
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def load(path: Optional[Path] = None) -> Config:
|
|
347
|
+
path = path or config_path()
|
|
348
|
+
try:
|
|
349
|
+
text = path.read_text()
|
|
350
|
+
except FileNotFoundError:
|
|
351
|
+
raise ConfigError(f"config not found: {path} (run `derot init`)")
|
|
352
|
+
try:
|
|
353
|
+
data = yaml.safe_load(text)
|
|
354
|
+
except yaml.YAMLError as e:
|
|
355
|
+
mark = getattr(e, "problem_mark", None)
|
|
356
|
+
where = f" at line {mark.line + 1}" if mark else ""
|
|
357
|
+
problem = getattr(e, "problem", None) or "parse error"
|
|
358
|
+
raise ConfigError(f"invalid YAML in {path}{where}: {problem}")
|
|
359
|
+
return parse(data, path)
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# derot configuration
|
|
2
|
+
# Docs: run `derot --help`, or see the README.
|
|
3
|
+
|
|
4
|
+
# Machines. "local" (this computer) always exists; add VMs reachable over SSH.
|
|
5
|
+
hosts:
|
|
6
|
+
local:
|
|
7
|
+
claude: claude # command or full path for Claude Code on this machine
|
|
8
|
+
codex: codex # command or full path for Codex on this machine
|
|
9
|
+
# vm:
|
|
10
|
+
# ssh: user@my-vm # anything `ssh` accepts, incl. ~/.ssh/config aliases
|
|
11
|
+
# claude: claude # optional override
|
|
12
|
+
|
|
13
|
+
# Accounts. Each account is an agent's config folder with its own login: Claude Code
|
|
14
|
+
# (agent: claude, the default; CLAUDE_CONFIG_DIR) or Codex (agent: codex; CODEX_HOME).
|
|
15
|
+
accounts:
|
|
16
|
+
personal:
|
|
17
|
+
config_dir: ~/.claude # Claude Code's default folder
|
|
18
|
+
# work:
|
|
19
|
+
# config_dir: ~/.claude-work
|
|
20
|
+
# env: # optional extra env vars for this account
|
|
21
|
+
# ANTHROPIC_API_KEY: sk-ant-...
|
|
22
|
+
# openai:
|
|
23
|
+
# agent: codex
|
|
24
|
+
# config_dir: ~/.codex # Codex's default folder
|
|
25
|
+
# env:
|
|
26
|
+
# OPENAI_API_KEY: sk-... # optional; otherwise log in with `l` (codex login)
|
|
27
|
+
|
|
28
|
+
# Sessions to manage. Each one runs in its own tmux session on its host.
|
|
29
|
+
sessions: []
|
|
30
|
+
# - name: shop-api # letters, digits, _ . - ; must be unique
|
|
31
|
+
# host: local # "local" or a key of hosts
|
|
32
|
+
# dir: ~/code/shop-api # project folder on that host (~ allowed)
|
|
33
|
+
# account: personal # key of accounts
|
|
34
|
+
# note: payments refactor # optional reminder shown in the UI
|
|
35
|
+
# args: "" # optional extra flags for `claude` / `codex`
|
|
36
|
+
|
|
37
|
+
notify:
|
|
38
|
+
on_waiting: true # alert when a session needs approval/input
|
|
39
|
+
on_done: true # alert when a session finishes
|
|
40
|
+
sound: Glass # macOS sound name, "" = silent
|
|
41
|
+
|
|
42
|
+
refresh: 3 # poll interval in seconds (min 1)
|
|
43
|
+
detach_key: C-q # key that returns to derot from a session
|