live-cmd 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.
live/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
live/cli.py ADDED
@@ -0,0 +1,250 @@
1
+ """Top-level CLI dispatch: `live <verb> ...`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+
8
+ from . import __version__
9
+ from . import verbs
10
+
11
+
12
+ def _count_or_cursor(prefix: str):
13
+ """Build a parser for `N` (count) or `<prefix>N` (cursor) on `-n` / `-c`.
14
+
15
+ `tail` uses `+N` (lines `n >= N`, Unix); `head` uses `-N` (drop last N,
16
+ GNU). The opposite sign is accepted but treated as a plain count.
17
+ """
18
+
19
+ def parse(value: str) -> tuple[str, int]:
20
+ if value.startswith(prefix):
21
+ rest = value[1:]
22
+ if rest.isdigit():
23
+ return ("cursor", int(rest))
24
+ elif value.startswith(("+", "-")) and value[1:].isdigit():
25
+ return ("count", int(value[1:]))
26
+ elif value.isdigit():
27
+ return ("count", int(value))
28
+ raise argparse.ArgumentTypeError(f"expected N or {prefix}N (got {value!r})")
29
+
30
+ return parse
31
+
32
+
33
+ def _make_parser() -> argparse.ArgumentParser:
34
+ p = argparse.ArgumentParser(
35
+ prog="live",
36
+ description="Stream long-lived command output to coding agents.",
37
+ add_help=True,
38
+ )
39
+ p.add_argument("--version", action="version", version=f"live {__version__}")
40
+ sub = p.add_subparsers(dest="verb", metavar="<verb>")
41
+
42
+ # run
43
+ run_p = sub.add_parser("run", help="Run <cmd> under a PTY; record.")
44
+ run_p.add_argument(
45
+ "-n", "--name", default=None, help="Session name."
46
+ )
47
+ run_p.add_argument(
48
+ "cmd",
49
+ nargs=argparse.REMAINDER,
50
+ help="Command to run; `--` for flag-starting commands.",
51
+ )
52
+ run_p.set_defaults(func=verbs.cmd_run)
53
+
54
+ # ls
55
+ ls_p = sub.add_parser("ls", help="List sessions in scope.")
56
+ ls_p.add_argument("-a", "--all", action="store_true", help="Include exited.")
57
+ ls_p.add_argument(
58
+ "-g",
59
+ "--global",
60
+ action="store_true",
61
+ dest="global_",
62
+ help="Global scope.",
63
+ )
64
+ ls_p.add_argument("--json", action="store_true", help="Emit NDJSON.")
65
+ ls_p.add_argument(
66
+ "selector", nargs="?", default=None, help="NAME or UUID-prefix filter."
67
+ )
68
+ ls_p.set_defaults(func=verbs.cmd_ls)
69
+
70
+ # cat
71
+ cat_p = sub.add_parser("cat", help="Concatenate session.")
72
+ cat_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
73
+ cat_p.add_argument(
74
+ "-g",
75
+ "--global",
76
+ action="store_true",
77
+ dest="global_",
78
+ help="Global scope.",
79
+ )
80
+ ag = cat_p.add_mutually_exclusive_group()
81
+ ag.add_argument(
82
+ "--strip-ansi",
83
+ action="store_true",
84
+ dest="strip_ansi",
85
+ help="Strip ANSI.",
86
+ )
87
+ ag.add_argument("--raw", action="store_true", dest="raw", help="Keep ANSI.")
88
+ cat_p.add_argument("selector", help="NAME or UUID-prefix.")
89
+ cat_p.set_defaults(func=verbs.cmd_cat)
90
+
91
+ # head
92
+ head_p = sub.add_parser("head", help="Head session.")
93
+ head_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
94
+ head_p.add_argument(
95
+ "-g",
96
+ "--global",
97
+ action="store_true",
98
+ dest="global_",
99
+ help="Global scope.",
100
+ )
101
+ ag = head_p.add_mutually_exclusive_group()
102
+ ag.add_argument(
103
+ "--strip-ansi",
104
+ action="store_true",
105
+ dest="strip_ansi",
106
+ help="Strip ANSI.",
107
+ )
108
+ ag.add_argument("--raw", action="store_true", dest="raw", help="Keep ANSI.")
109
+ mode = head_p.add_mutually_exclusive_group()
110
+ mode.add_argument(
111
+ "-n",
112
+ "--lines",
113
+ type=_count_or_cursor("-"),
114
+ default=None,
115
+ help="First N lines (default 10); -N drops last N.",
116
+ )
117
+ mode.add_argument(
118
+ "-c",
119
+ "--bytes",
120
+ dest="bytes_",
121
+ metavar="BYTES",
122
+ type=_count_or_cursor("-"),
123
+ default=None,
124
+ help="First K bytes; -K drops last K.",
125
+ )
126
+ mode.add_argument(
127
+ "-t",
128
+ "--time",
129
+ type=float,
130
+ default=None,
131
+ help="Lines with idx t <= T (epoch).",
132
+ )
133
+ head_p.add_argument("selector", help="NAME or UUID-prefix.")
134
+ head_p.set_defaults(func=verbs.cmd_head)
135
+
136
+ # tail
137
+ tail_p = sub.add_parser("tail", help="Tail session.")
138
+ tail_p.add_argument(
139
+ "-f", "--follow", action="store_true", help="Follow until exit."
140
+ )
141
+ tail_p.add_argument("-v", "--verbose", action="store_true", help="Verbose output.")
142
+ tail_p.add_argument(
143
+ "-g",
144
+ "--global",
145
+ action="store_true",
146
+ dest="global_",
147
+ help="Global scope.",
148
+ )
149
+ ag = tail_p.add_mutually_exclusive_group()
150
+ ag.add_argument(
151
+ "--strip-ansi",
152
+ action="store_true",
153
+ dest="strip_ansi",
154
+ help="Strip ANSI.",
155
+ )
156
+ ag.add_argument("--raw", action="store_true", dest="raw", help="Keep ANSI.")
157
+ mode = tail_p.add_mutually_exclusive_group()
158
+ mode.add_argument(
159
+ "-n",
160
+ "--lines",
161
+ type=_count_or_cursor("+"),
162
+ default=None,
163
+ help="Last N lines (default 10); +N for lines n >= N.",
164
+ )
165
+ mode.add_argument(
166
+ "-c",
167
+ "--bytes",
168
+ dest="bytes_",
169
+ metavar="BYTES",
170
+ type=_count_or_cursor("+"),
171
+ default=None,
172
+ help="Last K bytes; +K for bytes after offset K.",
173
+ )
174
+ mode.add_argument(
175
+ "-t",
176
+ "--time",
177
+ type=float,
178
+ default=None,
179
+ help="Lines with idx t > T (epoch).",
180
+ )
181
+ tail_p.add_argument("selector", help="NAME or UUID-prefix.")
182
+ tail_p.set_defaults(func=verbs.cmd_tail)
183
+
184
+ # rm
185
+ rm_p = sub.add_parser("rm", help="Delete sessions.")
186
+ rm_p.add_argument(
187
+ "-f",
188
+ "--force",
189
+ action="store_true",
190
+ help="SIGTERM live runs; ignore missing.",
191
+ )
192
+ rm_p.add_argument(
193
+ "-g",
194
+ "--global",
195
+ action="store_true",
196
+ dest="global_",
197
+ help="Global scope.",
198
+ )
199
+ rm_p.add_argument(
200
+ "--all-exited",
201
+ action="store_true",
202
+ dest="all_exited",
203
+ help="Remove all dead sessions.",
204
+ )
205
+ rm_p.add_argument(
206
+ "selectors", nargs="*", help="NAME(s) or UUID-prefix(es)."
207
+ )
208
+ rm_p.set_defaults(func=verbs.cmd_rm)
209
+
210
+ # llms.txt
211
+ llms_p = sub.add_parser("llms.txt", help="Print agent guide.")
212
+ llms_p.set_defaults(func=verbs.cmd_llms_txt)
213
+
214
+ # completion
215
+ comp_p = sub.add_parser("completion", help="Print shell completion script.")
216
+ comp_p.add_argument("shell", choices=["bash", "zsh", "fish"])
217
+ comp_p.set_defaults(func=verbs.cmd_completion)
218
+
219
+ # update-shell
220
+ up_p = sub.add_parser(
221
+ "update-shell", help="Install completion for the current shell."
222
+ )
223
+ up_p.add_argument(
224
+ "shell",
225
+ nargs="?",
226
+ choices=["bash", "zsh", "fish"],
227
+ default=None,
228
+ help="Target shell (default: $SHELL).",
229
+ )
230
+ up_p.set_defaults(func=verbs.cmd_update_shell)
231
+
232
+ return p
233
+
234
+
235
+ def main(argv: list[str] | None = None) -> int:
236
+ args = argv if argv is not None else sys.argv[1:]
237
+ parser = _make_parser()
238
+
239
+ parsed = parser.parse_args(args)
240
+ if not getattr(parsed, "verb", None):
241
+ parser.print_help()
242
+ return 0
243
+ try:
244
+ return parsed.func(parsed)
245
+ except KeyboardInterrupt:
246
+ return 130
247
+
248
+
249
+ if __name__ == "__main__":
250
+ sys.exit(main())
live/completion.py ADDED
@@ -0,0 +1,312 @@
1
+ """Shell completion script payloads, returned by `live completion <shell>`.
2
+
3
+ Each script offers verb completion, per-verb flag completion, selector
4
+ completion (NAME or UUID via `live ls --json`), and `live run <TAB>` handoff
5
+ to the wrapped command's completion. The selector helper mirrors the verb's
6
+ scope flags: `ls` only suggests active sessions unless `-a` was typed;
7
+ `cat`/`tail`/`rm` always pass `-a` since exited sessions remain valid
8
+ targets. `-g` is honored when present in the command line.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+
14
+ BASH = r"""# bash completion for live
15
+ _live_complete() {
16
+ local cur prev words cword
17
+ _init_completion -n : 2>/dev/null || {
18
+ cur="${COMP_WORDS[COMP_CWORD]}"
19
+ words=("${COMP_WORDS[@]}")
20
+ cword=$COMP_CWORD
21
+ }
22
+
23
+ # Find the verb (first non-flag token after `live`).
24
+ local verb verb_idx
25
+ for ((i=1; i<cword; i++)); do
26
+ case "${words[i]}" in
27
+ -*) continue ;;
28
+ *) verb="${words[i]}"; verb_idx=$i; break ;;
29
+ esac
30
+ done
31
+
32
+ if [ -z "$verb" ]; then
33
+ COMPREPLY=( $(compgen -W "run ls cat head tail rm llms.txt completion update-shell" -- "$cur") )
34
+ return
35
+ fi
36
+
37
+ case "$verb" in
38
+ run)
39
+ # If the user has typed a non-flag arg after `run`, hand off to the wrapped
40
+ # command's completion via _command_offset.
41
+ local seen_cmd=0
42
+ for ((i=verb_idx+1; i<cword; i++)); do
43
+ case "${words[i]}" in
44
+ --) seen_cmd=$((i+1)); break ;;
45
+ -n) i=$((i+1)) ;;
46
+ -*) ;;
47
+ *) seen_cmd=$i; break ;;
48
+ esac
49
+ done
50
+ if [ "$seen_cmd" -gt 0 ] && type _command_offset >/dev/null 2>&1; then
51
+ _command_offset $seen_cmd
52
+ return
53
+ fi
54
+ COMPREPLY=( $(compgen -W "-n --" -- "$cur") )
55
+ ;;
56
+ ls)
57
+ if [[ "$cur" == -* ]]; then
58
+ COMPREPLY=( $(compgen -W "-a --all -g --global --json" -- "$cur") )
59
+ else
60
+ COMPREPLY=( $(compgen -W "$(_live_selectors $(_live_all_flag) $(_live_global_flag))" -- "$cur") )
61
+ fi
62
+ ;;
63
+ cat)
64
+ if [[ "$cur" == -* ]]; then
65
+ COMPREPLY=( $(compgen -W "-v --verbose -g --global --strip-ansi --raw" -- "$cur") )
66
+ else
67
+ COMPREPLY=( $(compgen -W "$(_live_selectors -a $(_live_global_flag))" -- "$cur") )
68
+ fi
69
+ ;;
70
+ head)
71
+ if [[ "$cur" == -* ]]; then
72
+ COMPREPLY=( $(compgen -W "-v --verbose -g --global --strip-ansi --raw -n --lines -c --bytes -t --time" -- "$cur") )
73
+ else
74
+ COMPREPLY=( $(compgen -W "$(_live_selectors -a $(_live_global_flag))" -- "$cur") )
75
+ fi
76
+ ;;
77
+ tail)
78
+ if [[ "$cur" == -* ]]; then
79
+ COMPREPLY=( $(compgen -W "-v --verbose -f --follow -g --global --strip-ansi --raw -n --lines -c --bytes -t --time" -- "$cur") )
80
+ else
81
+ COMPREPLY=( $(compgen -W "$(_live_selectors -a $(_live_global_flag))" -- "$cur") )
82
+ fi
83
+ ;;
84
+ rm)
85
+ if [[ "$cur" == -* ]]; then
86
+ COMPREPLY=( $(compgen -W "-f --force -g --global --all-exited" -- "$cur") )
87
+ else
88
+ COMPREPLY=( $(compgen -W "$(_live_selectors -a $(_live_global_flag))" -- "$cur") )
89
+ fi
90
+ ;;
91
+ completion|update-shell)
92
+ COMPREPLY=( $(compgen -W "bash zsh fish" -- "$cur") )
93
+ ;;
94
+ esac
95
+ }
96
+
97
+ _live_all_flag() {
98
+ local w
99
+ for w in "${words[@]:1}"; do
100
+ case "$w" in -a|--all) echo -a; return ;; esac
101
+ done
102
+ }
103
+
104
+ _live_global_flag() {
105
+ local w
106
+ for w in "${words[@]:1}"; do
107
+ case "$w" in -g|--global) echo -g; return ;; esac
108
+ done
109
+ }
110
+
111
+ _live_selectors() {
112
+ live ls "$@" --json 2>/dev/null \
113
+ | awk -F'"' '{ for (i=1;i<=NF;i++) if ($i=="id"||$i=="name") print $(i+2) }' \
114
+ | sort -u
115
+ }
116
+
117
+ complete -F _live_complete live
118
+ """
119
+
120
+
121
+ ZSH = r"""#compdef live
122
+
123
+ _live() {
124
+ local context state line
125
+ typeset -A opt_args
126
+
127
+ _arguments -C \
128
+ '1: :_live_verbs' \
129
+ '*::arg:->args' \
130
+ && return 0
131
+
132
+ case $state in
133
+ args)
134
+ case $words[1] in
135
+ run)
136
+ # Complete our flag before the wrapped command; any non-flag
137
+ # word triggers `_normal` against the wrapped command.
138
+ _arguments -S \
139
+ '-n+[session name]:name:' \
140
+ '*::command:_normal'
141
+ ;;
142
+ ls)
143
+ _arguments \
144
+ '(-a --all)'{-a,--all} \
145
+ '(-g --global)'{-g,--global} \
146
+ '--json' \
147
+ '1:selector:_live_selectors'
148
+ ;;
149
+ cat)
150
+ _arguments \
151
+ '(-v --verbose)'{-v,--verbose} \
152
+ '(-g --global)'{-g,--global} \
153
+ '(--strip-ansi --raw)--strip-ansi' \
154
+ '(--strip-ansi --raw)--raw' \
155
+ '1:selector:_live_selectors'
156
+ ;;
157
+ head)
158
+ _arguments \
159
+ '(-v --verbose)'{-v,--verbose} \
160
+ '(-g --global)'{-g,--global} \
161
+ '(--strip-ansi --raw)--strip-ansi' \
162
+ '(--strip-ansi --raw)--raw' \
163
+ '(-n --lines)'{-n+,--lines=}':lines:' \
164
+ '(-c --bytes)'{-c+,--bytes=}':bytes:' \
165
+ '(-t --time)'{-t+,--time=}':epoch-seconds:' \
166
+ '1:selector:_live_selectors'
167
+ ;;
168
+ tail)
169
+ _arguments \
170
+ '(-v --verbose)'{-v,--verbose} \
171
+ '(-f --follow)'{-f,--follow} \
172
+ '(-g --global)'{-g,--global} \
173
+ '(--strip-ansi --raw)--strip-ansi' \
174
+ '(--strip-ansi --raw)--raw' \
175
+ '(-n --lines)'{-n+,--lines=}':lines:' \
176
+ '(-c --bytes)'{-c+,--bytes=}':bytes:' \
177
+ '(-t --time)'{-t+,--time=}':epoch-seconds:' \
178
+ '1:selector:_live_selectors'
179
+ ;;
180
+ rm)
181
+ _arguments \
182
+ '(-f --force)'{-f,--force} \
183
+ '(-g --global)'{-g,--global} \
184
+ '--all-exited' \
185
+ '*:selector:_live_selectors'
186
+ ;;
187
+ completion|update-shell)
188
+ _arguments '1:shell:(bash zsh fish)'
189
+ ;;
190
+ esac
191
+ ;;
192
+ esac
193
+ }
194
+
195
+ _live_verbs() {
196
+ local -a verbs
197
+ verbs=(
198
+ 'run:Wrap <cmd> under a PTY and record'
199
+ 'ls:List sessions in scope'
200
+ 'cat:Concatenate stream.*.log for a session'
201
+ 'head:Head first lines of a session'
202
+ 'tail:Tail a session'
203
+ 'rm:Delete sessions'
204
+ 'llms.txt:Print a token-minimal agent guide'
205
+ 'completion:Print shell completion script'
206
+ 'update-shell:Install completion for the current shell'
207
+ )
208
+ _describe -t verbs 'verb' verbs
209
+ }
210
+
211
+ # Selector completion. For `live ls`, only suggest active sessions unless -a
212
+ # was typed; for other verbs, always include exited (still valid targets).
213
+ # Honors -g/--global when present in the command line.
214
+ _live_selectors() {
215
+ local -a sel_args names
216
+ local w want_all=0 want_global=0
217
+ for w in $words[@]; do
218
+ case $w in
219
+ -a|--all) want_all=1 ;;
220
+ -g|--global) want_global=1 ;;
221
+ esac
222
+ done
223
+ if [[ $words[1] != ls ]] || (( want_all )); then
224
+ sel_args+=(-a)
225
+ fi
226
+ (( want_global )) && sel_args+=(-g)
227
+ names=( ${(f)"$(live ls $sel_args --json 2>/dev/null | awk -F'"' '{ for (i=1;i<=NF;i++) if ($i=="id"||$i=="name") print $(i+2) }' | sort -u)"} )
228
+ (( $#names )) && _values 'selector' "${names[@]}"
229
+ }
230
+
231
+ _live "$@"
232
+ """
233
+
234
+
235
+ FISH = r"""# fish completion for live
236
+
237
+ function __live_selectors
238
+ set -l toks (commandline -opc)
239
+ set -l verb $toks[2]
240
+ set -l args
241
+ if test "$verb" != "ls"; or contains -- -a $toks; or contains -- --all $toks
242
+ set -a args -a
243
+ end
244
+ if contains -- -g $toks; or contains -- --global $toks
245
+ set -a args -g
246
+ end
247
+ live ls $args --json 2>/dev/null | string match -rga '"(?:id|name)":"([^"]+)"' | sort -u
248
+ end
249
+
250
+ set -l verbs run ls cat head tail rm llms.txt completion update-shell
251
+
252
+ complete -c live -f
253
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a run -d 'Wrap <cmd> under a PTY'
254
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a ls -d 'List sessions'
255
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a cat -d 'Concatenate stream.*.log'
256
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a head -d 'Head first lines of a session'
257
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a tail -d 'Tail a session'
258
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a rm -d 'Delete sessions'
259
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a llms.txt -d 'Print agent guide'
260
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a completion -d 'Print completion script'
261
+ complete -c live -n "not __fish_seen_subcommand_from $verbs" -a update-shell -d 'Install completion for current shell'
262
+
263
+ # Selector completion for ls / cat / head / tail / rm.
264
+ complete -c live -n "__fish_seen_subcommand_from ls cat head tail rm" -a "(__live_selectors)"
265
+
266
+ # ls
267
+ complete -c live -n "__fish_seen_subcommand_from ls" -s a -l all -d 'Include exited sessions'
268
+ complete -c live -n "__fish_seen_subcommand_from ls" -s g -l global -d 'Show sessions from all directories'
269
+ complete -c live -n "__fish_seen_subcommand_from ls" -l json -d 'Emit NDJSON'
270
+
271
+ # cat
272
+ complete -c live -n "__fish_seen_subcommand_from cat" -s v -l verbose -d 'Add stderr metadata'
273
+ complete -c live -n "__fish_seen_subcommand_from cat" -s g -l global -d 'Resolve selector globally'
274
+ complete -c live -n "__fish_seen_subcommand_from cat" -l strip-ansi -d 'Remove ANSI escapes'
275
+ complete -c live -n "__fish_seen_subcommand_from cat" -l raw -d 'Keep ANSI escapes'
276
+
277
+ # head
278
+ complete -c live -n "__fish_seen_subcommand_from head" -s v -l verbose
279
+ complete -c live -n "__fish_seen_subcommand_from head" -s g -l global -d 'Resolve selector globally'
280
+ complete -c live -n "__fish_seen_subcommand_from head" -l strip-ansi
281
+ complete -c live -n "__fish_seen_subcommand_from head" -l raw
282
+ complete -c live -n "__fish_seen_subcommand_from head" -s n -l lines -r -d 'First N lines (default 10)'
283
+ complete -c live -n "__fish_seen_subcommand_from head" -s c -l bytes -r -d 'First K bytes'
284
+ complete -c live -n "__fish_seen_subcommand_from head" -s t -l time -r -d 'Lines with t <= T (epoch seconds)'
285
+
286
+ # tail
287
+ complete -c live -n "__fish_seen_subcommand_from tail" -s v -l verbose
288
+ complete -c live -n "__fish_seen_subcommand_from tail" -s f -l follow -d 'Follow new lines'
289
+ complete -c live -n "__fish_seen_subcommand_from tail" -s g -l global -d 'Resolve selector globally'
290
+ complete -c live -n "__fish_seen_subcommand_from tail" -l strip-ansi
291
+ complete -c live -n "__fish_seen_subcommand_from tail" -l raw
292
+ complete -c live -n "__fish_seen_subcommand_from tail" -s n -l lines -r -d 'Last N lines'
293
+ complete -c live -n "__fish_seen_subcommand_from tail" -s c -l bytes -r -d 'Last K bytes'
294
+ complete -c live -n "__fish_seen_subcommand_from tail" -s t -l time -r -d 'Time cursor (epoch seconds)'
295
+
296
+ # rm
297
+ complete -c live -n "__fish_seen_subcommand_from rm" -s f -l force -d 'Kill running recorders'
298
+ complete -c live -n "__fish_seen_subcommand_from rm" -s g -l global -d 'Resolve selectors globally'
299
+ complete -c live -n "__fish_seen_subcommand_from rm" -l all-exited -d 'Remove every dead session'
300
+
301
+ # run -- hand off after first non-flag token.
302
+ complete -c live -n "__fish_seen_subcommand_from run" -s n -r -d 'Session name'
303
+ complete -c live -n "__fish_seen_subcommand_from run; and __fish_complete_subcommand --skip 2" \
304
+ -a "(__fish_complete_subcommand --skip 2)"
305
+
306
+ # completion / update-shell
307
+ complete -c live -n "__fish_seen_subcommand_from completion update-shell" -a "bash zsh fish"
308
+ """
309
+
310
+
311
+ def script_for(shell: str) -> str | None:
312
+ return {"bash": BASH, "zsh": ZSH, "fish": FISH}.get(shell)
live/config.py ADDED
@@ -0,0 +1,80 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import sys
5
+ from dataclasses import dataclass
6
+ from pathlib import Path
7
+
8
+ from .paths import config_path
9
+
10
+
11
+ DEFAULTS = {
12
+ "ttlDays": 7,
13
+ "maxKb": 512,
14
+ "segmentKb": 64,
15
+ "heartbeatSec": 30,
16
+ }
17
+
18
+
19
+ _VALIDATORS = {
20
+ "ttlDays": lambda v: isinstance(v, int) and not isinstance(v, bool) and v >= 0,
21
+ "maxKb": lambda v: isinstance(v, int) and not isinstance(v, bool) and v > 0,
22
+ "segmentKb": lambda v: isinstance(v, int) and not isinstance(v, bool) and v > 0,
23
+ "heartbeatSec": lambda v: isinstance(v, int) and not isinstance(v, bool) and v > 0,
24
+ }
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class Config:
29
+ ttl_days: int
30
+ max_kb: int
31
+ segment_kb: int
32
+ heartbeat_sec: int
33
+
34
+ @property
35
+ def max_bytes(self) -> int:
36
+ return self.max_kb * 1024
37
+
38
+ @property
39
+ def segment_bytes(self) -> int:
40
+ return self.segment_kb * 1024
41
+
42
+
43
+ def _load_file(path: Path) -> dict[str, int]:
44
+ """Read config; return {} for missing/malformed (warns on malformed)."""
45
+ if not path.exists():
46
+ return {}
47
+ try:
48
+ with path.open("r", encoding="utf-8") as f:
49
+ raw = json.load(f)
50
+ except (OSError, ValueError) as e:
51
+ print(f"live: malformed config at {path}: {e} — falling back to defaults",
52
+ file=sys.stderr)
53
+ return {}
54
+ if not isinstance(raw, dict):
55
+ return {}
56
+ out: dict[str, int] = {}
57
+ for key, val in raw.items():
58
+ validator = _VALIDATORS.get(key)
59
+ if validator is None:
60
+ continue
61
+ if validator(val):
62
+ out[key] = val
63
+ return out
64
+
65
+
66
+ def load_config() -> Config:
67
+ """Read `~/.live/config.json`, falling back per-field to compiled defaults."""
68
+ cfg_path = config_path()
69
+ if not cfg_path.exists():
70
+ try:
71
+ cfg_path.write_text(json.dumps(DEFAULTS) + "\n")
72
+ except OSError:
73
+ pass
74
+ merged = {**DEFAULTS, **_load_file(cfg_path)}
75
+ return Config(
76
+ ttl_days=int(merged["ttlDays"]),
77
+ max_kb=int(merged["maxKb"]),
78
+ segment_kb=int(merged["segmentKb"]),
79
+ heartbeat_sec=int(merged["heartbeatSec"]),
80
+ )