cdx-manager 0.9.10 → 0.9.11
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.
- package/README.md +1 -1
- package/changelogs/CHANGELOGS_0_9_11.md +44 -0
- package/checksums/release-archives.json +4 -0
- package/package.json +1 -1
- package/pyproject.toml +5 -2
- package/src/backup_bundle.py +0 -1
- package/src/claude_usage.py +19 -3
- package/src/cli.py +27 -9
- package/src/cli_args.py +631 -0
- package/src/cli_commands.py +64 -995
- package/src/cli_helpers.py +385 -0
- package/src/cli_render.py +0 -1
- package/src/cli_view.py +1 -20
- package/src/context_store.py +1 -2
- package/src/logics_view.py +0 -1
- package/src/notify.py +0 -1
- package/src/provider_runtime.py +9 -33
- package/src/run_command.py +1 -1
- package/src/run_registry.py +2 -2
- package/src/run_usage.py +1 -2
- package/src/session_service.py +6 -20
- package/src/session_store.py +2 -2
- package/src/status_source.py +0 -19
- package/src/status_view.py +1 -1
- package/src/update_check.py +1 -2
package/src/cli_commands.py
CHANGED
|
@@ -6,11 +6,69 @@ import re
|
|
|
6
6
|
import shlex
|
|
7
7
|
import sys
|
|
8
8
|
import time
|
|
9
|
-
from datetime import datetime
|
|
9
|
+
from datetime import datetime
|
|
10
10
|
|
|
11
|
+
from .backup_bundle import read_bundle_meta
|
|
11
12
|
from .claude_refresh import _refresh_claude_sessions
|
|
13
|
+
from .cli_args import (
|
|
14
|
+
CAN_RESUME_USAGE,
|
|
15
|
+
CONTEXT_USAGE,
|
|
16
|
+
DOCTOR_USAGE,
|
|
17
|
+
HANDOFF_USAGE,
|
|
18
|
+
LAST_USAGE,
|
|
19
|
+
REPAIR_USAGE,
|
|
20
|
+
RESUME_USAGE,
|
|
21
|
+
RUN_REPORT_USAGE,
|
|
22
|
+
RUN_STATUS_USAGE,
|
|
23
|
+
RUNS_USAGE,
|
|
24
|
+
STATUS_USAGE,
|
|
25
|
+
_filter_history_period,
|
|
26
|
+
_has_history_period,
|
|
27
|
+
_parse_add_args,
|
|
28
|
+
_parse_config_args,
|
|
29
|
+
_parse_configs_args,
|
|
30
|
+
_parse_copy_args,
|
|
31
|
+
_parse_export_args,
|
|
32
|
+
_parse_flag_args,
|
|
33
|
+
_parse_history_args,
|
|
34
|
+
_parse_import_args,
|
|
35
|
+
_parse_json_flag,
|
|
36
|
+
_parse_launch_setting_alias_args,
|
|
37
|
+
_parse_next_args,
|
|
38
|
+
_parse_remove_args,
|
|
39
|
+
_parse_rename_args,
|
|
40
|
+
_parse_run_args,
|
|
41
|
+
_parse_run_id_json_args,
|
|
42
|
+
_parse_runs_args,
|
|
43
|
+
_parse_select_args,
|
|
44
|
+
_parse_set_args,
|
|
45
|
+
_parse_stats_args,
|
|
46
|
+
_parse_timeout_seconds,
|
|
47
|
+
_parse_toggle_args,
|
|
48
|
+
_parse_unset_args,
|
|
49
|
+
_parse_update_args,
|
|
50
|
+
_public_history_period,
|
|
51
|
+
)
|
|
52
|
+
from .cli_helpers import (
|
|
53
|
+
API_SCHEMA_VERSION,
|
|
54
|
+
_build_handoff_context,
|
|
55
|
+
_format_export_report,
|
|
56
|
+
_handoff_launch_prompt,
|
|
57
|
+
_json_failure,
|
|
58
|
+
_json_success,
|
|
59
|
+
_latest_handoff_transcript_path,
|
|
60
|
+
_local_now_iso,
|
|
61
|
+
_make_export_progress,
|
|
62
|
+
_make_notify_progress,
|
|
63
|
+
_make_status_progress,
|
|
64
|
+
_read_handoff_transcript,
|
|
65
|
+
_update_notice_warnings,
|
|
66
|
+
_write_json,
|
|
67
|
+
_write_update_notice,
|
|
68
|
+
)
|
|
12
69
|
from .cli_render import _dim, _info, _style, _success, _warn
|
|
13
|
-
from .
|
|
70
|
+
from .cli_view import handle_view as handle_view # re-export for cli.py / tests
|
|
71
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_OLLAMA
|
|
14
72
|
from .context_store import (
|
|
15
73
|
clear_context,
|
|
16
74
|
edit_context,
|
|
@@ -22,7 +80,6 @@ from .context_store import (
|
|
|
22
80
|
)
|
|
23
81
|
from .errors import CdxError
|
|
24
82
|
from .health import collect_health_report, format_health_report
|
|
25
|
-
from .cli_view import handle_view
|
|
26
83
|
from .notify import (
|
|
27
84
|
format_notify_event,
|
|
28
85
|
format_scheduled_notification,
|
|
@@ -36,992 +93,20 @@ from .provider_runtime import (
|
|
|
36
93
|
_ensure_session_authentication,
|
|
37
94
|
_headless_artifact_paths,
|
|
38
95
|
_list_launch_transcript_paths,
|
|
39
|
-
_normalize_reasoning_effort,
|
|
40
96
|
_probe_provider_auth,
|
|
41
|
-
get_resume_capability,
|
|
42
97
|
_run_headless_provider_command,
|
|
43
98
|
_run_interactive_provider_command,
|
|
99
|
+
get_resume_capability,
|
|
44
100
|
)
|
|
45
101
|
from .repair import format_repair_report, repair_health
|
|
46
|
-
from .
|
|
102
|
+
from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
|
|
47
103
|
from .run_registry import RunRegistry, build_code_review_report
|
|
48
104
|
from .run_usage import extract_run_usage
|
|
49
|
-
from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
|
|
50
105
|
from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
51
106
|
from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
|
|
52
107
|
from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
|
|
53
108
|
|
|
54
109
|
|
|
55
|
-
STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECONDS] | cdx status --small|-s [--refresh|--cached] [--timeout SECONDS] | cdx status <name> [--json] [--refresh|--cached] [--timeout SECONDS]"
|
|
56
|
-
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
57
|
-
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
58
|
-
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
59
|
-
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
60
|
-
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
61
|
-
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
62
|
-
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
63
|
-
SET_USAGE = "Usage: cdx set <name>|--sessions all|a,b|--provider PROVIDER [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full] [--fast on|off] [--rtk on|off] [--logics on|off] [--model MODEL] [--priority 0..100] [--json]"
|
|
64
|
-
UNSET_USAGE = "Usage: cdx unset <name>|--sessions all|a,b|--provider PROVIDER (--power|--permission|--fast|--rtk|--logics|--model|--priority|--all) [--json]"
|
|
65
|
-
SETTING_ALIAS_USAGE = "Usage: cdx power|perm|fast|model <name|all|provider:PROVIDER|a,b> <value|default> [--json]"
|
|
66
|
-
CONFIG_USAGE = "Usage: cdx config <name> [--json]"
|
|
67
|
-
CONFIGS_USAGE = "Usage: cdx configs [--json]"
|
|
68
|
-
HISTORY_USAGE = "Usage: cdx history [name] [--limit N] [--summary] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
69
|
-
STATS_USAGE = "Usage: cdx stats [name] [--since 7d|today|DATE] [--from DATE] [--to DATE] [--json]"
|
|
70
|
-
LAST_USAGE = "Usage: cdx last [--json]"
|
|
71
|
-
RESUME_USAGE = "Usage: cdx resume <name> [--json]"
|
|
72
|
-
CAN_RESUME_USAGE = "Usage: cdx can-resume <name> [--json]"
|
|
73
|
-
SELECT_USAGE = "Usage: cdx select --provider PROVIDER [--min-reasoning-effort minimal|low|medium|high|xhigh] [--min-power minimal|low|medium|high|xhigh] [--require-ready] [--refresh] --json"
|
|
74
|
-
NEXT_USAGE = "Usage: cdx next [--json] [--refresh]"
|
|
75
|
-
RUN_USAGE = "Usage: cdx run [session] --cwd PATH (--prompt-file PATH|--prompt TEXT) [--provider PROVIDER] [--model MODEL] [--kind assistant|code-review] [--reasoning-effort minimal|low|medium|high|xhigh] [--power minimal|low|medium|high|xhigh] [--permission review|default|auto|full|workspace-write|read-only|danger-full-access] [--timeout-seconds N] --json"
|
|
76
|
-
RUNS_USAGE = "Usage: cdx runs [--limit N] --json"
|
|
77
|
-
RUN_STATUS_USAGE = "Usage: cdx run-status <run_id> --json"
|
|
78
|
-
RUN_REPORT_USAGE = "Usage: cdx run-report <run_id> --json"
|
|
79
|
-
API_SCHEMA_VERSION = 1
|
|
80
|
-
HANDOFF_TRANSCRIPT_CHARS = 120000
|
|
81
|
-
HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES = 64
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def _local_now_iso():
|
|
85
|
-
return datetime.now().astimezone().isoformat()
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
def _json_success(action, message, warnings=None, **extra):
|
|
89
|
-
payload = {
|
|
90
|
-
"schema_version": API_SCHEMA_VERSION,
|
|
91
|
-
"ok": True,
|
|
92
|
-
"action": action,
|
|
93
|
-
"message": message,
|
|
94
|
-
"warnings": warnings or [],
|
|
95
|
-
}
|
|
96
|
-
payload.update(extra)
|
|
97
|
-
return payload
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def _json_failure(action, code, message, source="cdx", exit_code=1, warnings=None, **extra):
|
|
101
|
-
payload = {
|
|
102
|
-
"schema_version": API_SCHEMA_VERSION,
|
|
103
|
-
"ok": False,
|
|
104
|
-
"action": action,
|
|
105
|
-
"warnings": warnings or [],
|
|
106
|
-
"error": {
|
|
107
|
-
"source": source,
|
|
108
|
-
"code": code,
|
|
109
|
-
"message": message,
|
|
110
|
-
"exit_code": exit_code,
|
|
111
|
-
},
|
|
112
|
-
}
|
|
113
|
-
payload.update(extra)
|
|
114
|
-
return payload
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
def _write_json(ctx, payload):
|
|
118
|
-
ctx["out"](f"{json.dumps(payload, indent=2)}\n")
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def _update_notice_warning(ctx):
|
|
122
|
-
notices = ctx.get("update_notices") or []
|
|
123
|
-
if not notices:
|
|
124
|
-
return None
|
|
125
|
-
warnings = _update_notice_warnings(ctx)
|
|
126
|
-
return warnings[0] if warnings else None
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
def _update_notice_warnings(ctx):
|
|
130
|
-
warnings = []
|
|
131
|
-
for notice in ctx.get("update_notices") or []:
|
|
132
|
-
tool = notice.get("tool") or "cdx-manager"
|
|
133
|
-
current = notice.get("current_version") or ctx.get("version")
|
|
134
|
-
command = notice.get("update_command") or ("cdx update" if tool == "cdx-manager" else None)
|
|
135
|
-
message = f"Update available: {tool} {notice['latest_version']}"
|
|
136
|
-
if current:
|
|
137
|
-
message = f"{message} (current {current})"
|
|
138
|
-
if command:
|
|
139
|
-
message = f"{message}. Run: {command}"
|
|
140
|
-
warnings.append({
|
|
141
|
-
"code": "update_available" if tool == "cdx-manager" else f"{tool.replace('-', '_')}_update_available",
|
|
142
|
-
"message": message,
|
|
143
|
-
"tool": tool,
|
|
144
|
-
"latest_version": notice["latest_version"],
|
|
145
|
-
"current_version": current,
|
|
146
|
-
"update_command": command,
|
|
147
|
-
"url": notice.get("url"),
|
|
148
|
-
})
|
|
149
|
-
return warnings
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
def _write_update_notice(ctx):
|
|
153
|
-
warnings = _update_notice_warnings(ctx)
|
|
154
|
-
if not warnings:
|
|
155
|
-
return
|
|
156
|
-
for warning in warnings:
|
|
157
|
-
ctx["out"](f"{_warn(warning['message'], ctx['use_color'])}\n")
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
def _format_bytes(value):
|
|
161
|
-
if value is None:
|
|
162
|
-
return "n/a"
|
|
163
|
-
try:
|
|
164
|
-
amount = float(value)
|
|
165
|
-
except (TypeError, ValueError):
|
|
166
|
-
return str(value)
|
|
167
|
-
units = ["B", "KB", "MB", "GB", "TB"]
|
|
168
|
-
unit = units[0]
|
|
169
|
-
for unit in units:
|
|
170
|
-
if amount < 1024 or unit == units[-1]:
|
|
171
|
-
break
|
|
172
|
-
amount /= 1024
|
|
173
|
-
if unit == "B":
|
|
174
|
-
return f"{int(amount)} B"
|
|
175
|
-
return f"{amount:.1f} {unit}"
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
def _format_export_report(result):
|
|
179
|
-
lines = [
|
|
180
|
-
f"Path: {result['path']}",
|
|
181
|
-
f"Sessions: {', '.join(result['session_names']) or '-'}",
|
|
182
|
-
f"Auth: {'included and encrypted' if result['include_auth'] else 'not included'}",
|
|
183
|
-
f"Bundle size: {_format_bytes(result.get('bundle_size_bytes'))}",
|
|
184
|
-
]
|
|
185
|
-
if result.get("include_auth"):
|
|
186
|
-
lines.extend([
|
|
187
|
-
f"Auth files: {result.get('profile_file_count', 0)}",
|
|
188
|
-
f"Auth data: {_format_bytes(result.get('profile_bytes'))}",
|
|
189
|
-
])
|
|
190
|
-
return "\n".join(lines)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
def _make_export_progress(ctx):
|
|
194
|
-
def progress(event):
|
|
195
|
-
kind = event.get("event")
|
|
196
|
-
if kind == "export_started":
|
|
197
|
-
auth = " with auth" if event.get("include_auth") else ""
|
|
198
|
-
message = f"Exporting {event.get('session_count', 0)} session(s){auth}..."
|
|
199
|
-
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
200
|
-
elif kind == "session_started":
|
|
201
|
-
message = f"Collecting {event.get('session_name')}..."
|
|
202
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
203
|
-
elif kind == "profile_progress":
|
|
204
|
-
message = f" {event.get('session_name')}: {event.get('file_count', 0)} files, {_format_bytes(event.get('bytes'))}"
|
|
205
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
206
|
-
elif kind == "encoding_started":
|
|
207
|
-
ctx["out"](f"{_dim('Encoding and encrypting bundle...', ctx['use_color'])}\n")
|
|
208
|
-
elif kind == "writing_started":
|
|
209
|
-
message = f"Writing {_format_bytes(event.get('bundle_size_bytes'))}..."
|
|
210
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
211
|
-
return progress
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
def _make_status_progress(ctx):
|
|
215
|
-
progress_state = {"checked": 0, "total": 0}
|
|
216
|
-
|
|
217
|
-
def progress(event):
|
|
218
|
-
kind = event.get("event")
|
|
219
|
-
if kind == "status_started":
|
|
220
|
-
progress_state["checked"] = 0
|
|
221
|
-
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
222
|
-
message = f"Resolving status for {event.get('session_count', 0)} session(s)..."
|
|
223
|
-
ctx["out"](f"{_info(message, ctx['use_color'])}\n")
|
|
224
|
-
elif kind == "session_started":
|
|
225
|
-
provider = event.get("provider") or "session"
|
|
226
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
227
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
228
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
229
|
-
progress_state["checked"] += 1
|
|
230
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
231
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
232
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
233
|
-
elif kind == "status_finished":
|
|
234
|
-
message = f"Resolved {event.get('row_count', 0)} status row(s)."
|
|
235
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
236
|
-
return progress
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
def _make_notify_progress(ctx):
|
|
240
|
-
progress_state = {"checked": 0, "total": 0}
|
|
241
|
-
|
|
242
|
-
def progress(event):
|
|
243
|
-
kind = event.get("event")
|
|
244
|
-
if kind == "notify_check_started":
|
|
245
|
-
target = event.get("session_name") or "next ready session"
|
|
246
|
-
ctx["out"](f"{_info(f'Checking notification target: {target}...', ctx['use_color'])}\n")
|
|
247
|
-
elif kind == "status_started":
|
|
248
|
-
progress_state["checked"] = 0
|
|
249
|
-
progress_state["total"] = event.get("check_count", event.get("session_count", 0)) or 0
|
|
250
|
-
message = f"Loading status for {event.get('session_count', 0)} session(s)..."
|
|
251
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
252
|
-
elif kind == "session_started":
|
|
253
|
-
provider = event.get("provider") or "session"
|
|
254
|
-
message = f"Checking {event.get('session_name')} ({provider})..."
|
|
255
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
256
|
-
elif kind == "session_finished" and not event.get("cache_hit"):
|
|
257
|
-
progress_state["checked"] += 1
|
|
258
|
-
total = progress_state["total"] or progress_state["checked"]
|
|
259
|
-
message = f"Checked {event.get('session_name')} ({progress_state['checked']}/{total})."
|
|
260
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
261
|
-
elif kind == "notify_waiting":
|
|
262
|
-
message = f"{event.get('message')}; checking again in {event.get('poll')}s..."
|
|
263
|
-
ctx["out"](f"{_dim(message, ctx['use_color'])}\n")
|
|
264
|
-
return progress
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
def _latest_launch_transcript_path(session):
|
|
268
|
-
paths = _list_launch_transcript_paths(session)
|
|
269
|
-
if not paths:
|
|
270
|
-
return None
|
|
271
|
-
return max(paths, key=lambda path: (os.path.getmtime(path), path))
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
def _get_session_home(session):
|
|
275
|
-
return session.get("authHome") or session.get("sessionRoot") or session.get("codexHome", "")
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
def _safe_stat(path):
|
|
279
|
-
try:
|
|
280
|
-
return os.stat(path)
|
|
281
|
-
except OSError:
|
|
282
|
-
return None
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
def _sort_recent_paths(paths):
|
|
286
|
-
stats = {path: stat for path, stat in ((_path, _safe_stat(_path)) for _path in set(paths)) if stat}
|
|
287
|
-
return sorted(stats, key=lambda path: (stats[path].st_mtime, path), reverse=True)
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
def _collect_native_handoff_transcript_paths(session):
|
|
291
|
-
root = _get_session_home(session)
|
|
292
|
-
if not root:
|
|
293
|
-
return []
|
|
294
|
-
candidates = []
|
|
295
|
-
direct = [
|
|
296
|
-
os.path.join(root, "history.jsonl"),
|
|
297
|
-
os.path.join(root, "session_index.jsonl"),
|
|
298
|
-
]
|
|
299
|
-
candidates.extend(path for path in direct if _safe_stat(path))
|
|
300
|
-
|
|
301
|
-
scan_roots = [
|
|
302
|
-
os.path.join(root, "sessions"),
|
|
303
|
-
os.path.join(root, ".claude", "projects"),
|
|
304
|
-
os.path.join(root, "projects"),
|
|
305
|
-
]
|
|
306
|
-
skip_dirs = {"cache", "plugins", "skills", "memories", "sqlite", "shell_snapshots", "tmp", "__pycache__"}
|
|
307
|
-
for scan_root in scan_roots:
|
|
308
|
-
if not os.path.isdir(scan_root):
|
|
309
|
-
continue
|
|
310
|
-
for dirpath, dirnames, filenames in os.walk(scan_root):
|
|
311
|
-
dirnames[:] = [name for name in dirnames if not name.startswith(".") and name not in skip_dirs]
|
|
312
|
-
for filename in filenames:
|
|
313
|
-
if filename.endswith(".jsonl") or filename.endswith(".log"):
|
|
314
|
-
candidates.append(os.path.join(dirpath, filename))
|
|
315
|
-
return _sort_recent_paths(candidates)[:HANDOFF_NATIVE_TRANSCRIPT_CANDIDATES]
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def _latest_handoff_transcript_path(session):
|
|
319
|
-
launch_path = _latest_launch_transcript_path(session)
|
|
320
|
-
if launch_path:
|
|
321
|
-
return launch_path
|
|
322
|
-
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
323
|
-
return native_paths[0] if native_paths else None
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
def _collect_handoff_text_fragments(value):
|
|
327
|
-
fragments = []
|
|
328
|
-
if isinstance(value, str):
|
|
329
|
-
text = value.strip()
|
|
330
|
-
if text:
|
|
331
|
-
fragments.append(text)
|
|
332
|
-
elif isinstance(value, list):
|
|
333
|
-
for item in value:
|
|
334
|
-
fragments.extend(_collect_handoff_text_fragments(item))
|
|
335
|
-
elif isinstance(value, dict):
|
|
336
|
-
if isinstance(value.get("text"), str):
|
|
337
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("text")))
|
|
338
|
-
if isinstance(value.get("content"), (str, list, dict)):
|
|
339
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("content")))
|
|
340
|
-
if isinstance(value.get("message"), dict):
|
|
341
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("message")))
|
|
342
|
-
if isinstance(value.get("payload"), dict):
|
|
343
|
-
fragments.extend(_collect_handoff_text_fragments(value.get("payload")))
|
|
344
|
-
return fragments
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
def _jsonl_record_to_handoff_text(record):
|
|
348
|
-
if not isinstance(record, dict):
|
|
349
|
-
return None
|
|
350
|
-
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
351
|
-
role = (
|
|
352
|
-
message.get("role")
|
|
353
|
-
or record.get("role")
|
|
354
|
-
or record.get("type")
|
|
355
|
-
or record.get("sender")
|
|
356
|
-
or "entry"
|
|
357
|
-
)
|
|
358
|
-
text_sources = []
|
|
359
|
-
for key in ("content", "text", "payload"):
|
|
360
|
-
if key in record:
|
|
361
|
-
text_sources.append(record.get(key))
|
|
362
|
-
for key in ("content", "text"):
|
|
363
|
-
if key in message:
|
|
364
|
-
text_sources.append(message.get(key))
|
|
365
|
-
fragments = []
|
|
366
|
-
for value in text_sources:
|
|
367
|
-
fragments.extend(_collect_handoff_text_fragments(value))
|
|
368
|
-
text = "\n".join(fragment for fragment in fragments if fragment).strip()
|
|
369
|
-
if not text:
|
|
370
|
-
return None
|
|
371
|
-
role = re.sub(r"[^A-Za-z0-9_-]+", "-", str(role).strip()).strip("-") or "entry"
|
|
372
|
-
return f"[{role}]\n{text}"
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
def _read_jsonl_handoff_transcript(path):
|
|
376
|
-
entries = []
|
|
377
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
378
|
-
for line in handle:
|
|
379
|
-
line = line.strip()
|
|
380
|
-
if not line:
|
|
381
|
-
continue
|
|
382
|
-
try:
|
|
383
|
-
text = _jsonl_record_to_handoff_text(json.loads(line))
|
|
384
|
-
except json.JSONDecodeError:
|
|
385
|
-
text = None
|
|
386
|
-
if text:
|
|
387
|
-
entries.append(text)
|
|
388
|
-
if entries:
|
|
389
|
-
return "\n\n".join(entries)
|
|
390
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
391
|
-
return handle.read()
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
def _read_handoff_transcript(path):
|
|
395
|
-
if path.endswith(".jsonl"):
|
|
396
|
-
content = _read_jsonl_handoff_transcript(path)
|
|
397
|
-
else:
|
|
398
|
-
with open(path, "r", encoding="utf-8", errors="replace") as handle:
|
|
399
|
-
content = handle.read()
|
|
400
|
-
if len(content) <= HANDOFF_TRANSCRIPT_CHARS:
|
|
401
|
-
return content, False
|
|
402
|
-
return content[-HANDOFF_TRANSCRIPT_CHARS:], True
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
def _build_handoff_context(source, target, transcript_path, transcript, truncated=False):
|
|
406
|
-
truncation_note = (
|
|
407
|
-
"Only the tail of the previous transcript is included because the log was large."
|
|
408
|
-
if truncated else
|
|
409
|
-
"The previous transcript is included below."
|
|
410
|
-
)
|
|
411
|
-
return "\n".join([
|
|
412
|
-
"# Shared Context",
|
|
413
|
-
"",
|
|
414
|
-
"## Goal",
|
|
415
|
-
f"Resume the work from `{source['name']}` in `{target['name']}` because the source account has no usage left.",
|
|
416
|
-
"",
|
|
417
|
-
"## Current State",
|
|
418
|
-
f"- Source session: `{source['name']}` ({source['provider']})",
|
|
419
|
-
f"- Target session: `{target['name']}` ({target['provider']})",
|
|
420
|
-
f"- Transcript source: `{transcript_path}`",
|
|
421
|
-
f"- {truncation_note}",
|
|
422
|
-
"",
|
|
423
|
-
"## Previous Session Transcript",
|
|
424
|
-
transcript.rstrip(),
|
|
425
|
-
"",
|
|
426
|
-
"## Next Steps",
|
|
427
|
-
"- Read the transcript above.",
|
|
428
|
-
"- Continue the task from the latest actionable state.",
|
|
429
|
-
"- Preserve the target account authentication; do not copy source credentials.",
|
|
430
|
-
])
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
def _handoff_launch_prompt(session, install=None):
|
|
434
|
-
if session.get("provider") == PROVIDER_CODEX:
|
|
435
|
-
context_ref = "$CODEX_HOME/shared-context.md"
|
|
436
|
-
else:
|
|
437
|
-
context_ref = (install or {}).get("target_path") or os.path.join(
|
|
438
|
-
session.get("authHome") or session.get("sessionRoot") or "~",
|
|
439
|
-
"shared-context.md",
|
|
440
|
-
)
|
|
441
|
-
return (
|
|
442
|
-
f"Read {context_ref} first, then resume the previous session "
|
|
443
|
-
"from the latest actionable state. Do not ask me to paste the context again."
|
|
444
|
-
)
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
def _parse_json_flag(args):
|
|
448
|
-
json_flag = "--json" in args
|
|
449
|
-
cleaned = [arg for arg in args if arg != "--json"]
|
|
450
|
-
return json_flag, cleaned
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
def _parse_flag_args(args, schema, usage, positionals_key=None, max_positionals=0):
|
|
454
|
-
parsed = {spec["key"]: spec.get("default") for spec in schema.values()}
|
|
455
|
-
positionals = []
|
|
456
|
-
index = 0
|
|
457
|
-
while index < len(args):
|
|
458
|
-
arg = args[index]
|
|
459
|
-
if arg in schema:
|
|
460
|
-
spec = schema[arg]
|
|
461
|
-
if spec["type"] == "bool":
|
|
462
|
-
parsed[spec["key"]] = True
|
|
463
|
-
index += 1
|
|
464
|
-
continue
|
|
465
|
-
value, index = _read_option_value(args, index, usage)
|
|
466
|
-
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
467
|
-
continue
|
|
468
|
-
if arg.startswith("--") and "=" in arg:
|
|
469
|
-
flag, value = arg.split("=", 1)
|
|
470
|
-
spec = schema.get(flag)
|
|
471
|
-
if not spec or spec["type"] == "bool":
|
|
472
|
-
raise CdxError(usage)
|
|
473
|
-
parsed[spec["key"]] = spec.get("transform", lambda item: item)(value)
|
|
474
|
-
index += 1
|
|
475
|
-
continue
|
|
476
|
-
if arg.startswith("-"):
|
|
477
|
-
raise CdxError(usage)
|
|
478
|
-
positionals.append(arg)
|
|
479
|
-
if len(positionals) > max_positionals:
|
|
480
|
-
raise CdxError(usage)
|
|
481
|
-
index += 1
|
|
482
|
-
|
|
483
|
-
if positionals_key is not None:
|
|
484
|
-
parsed[positionals_key] = positionals
|
|
485
|
-
return parsed
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
def _parse_add_args(args):
|
|
489
|
-
parsed = _parse_flag_args(
|
|
490
|
-
args,
|
|
491
|
-
{"--model": {"key": "model", "type": "str", "default": None}},
|
|
492
|
-
"Usage: cdx add [provider] <name> [--model MODEL] [--json]",
|
|
493
|
-
positionals_key="values",
|
|
494
|
-
max_positionals=2,
|
|
495
|
-
)
|
|
496
|
-
values = parsed["values"]
|
|
497
|
-
if len(values) == 1:
|
|
498
|
-
return {"provider": PROVIDER_CODEX, "name": values[0], "model": parsed["model"]}
|
|
499
|
-
if len(values) == 2:
|
|
500
|
-
return {"provider": values[0], "name": values[1], "model": parsed["model"]}
|
|
501
|
-
raise CdxError("Usage: cdx add [provider] <name> [--model MODEL] [--json]")
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
def _parse_copy_args(args):
|
|
505
|
-
if len(args) != 2:
|
|
506
|
-
raise CdxError("Usage: cdx cp <source> <dest> [--json]")
|
|
507
|
-
return {"source": args[0], "dest": args[1]}
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
def _parse_rename_args(args):
|
|
511
|
-
if len(args) != 2:
|
|
512
|
-
raise CdxError("Usage: cdx ren <source> <dest> [--json]")
|
|
513
|
-
return {"source": args[0], "dest": args[1]}
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
def _parse_remove_args(args):
|
|
517
|
-
parsed = _parse_flag_args(args, {
|
|
518
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
519
|
-
}, "Usage: cdx rmv <name> [--force] [--json]", positionals_key="names", max_positionals=1)
|
|
520
|
-
if len(parsed["names"]) != 1:
|
|
521
|
-
raise CdxError("Usage: cdx rmv <name> [--force] [--json]")
|
|
522
|
-
return {"name": parsed["names"][0], "force": parsed["force"]}
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
def _parse_toggle_args(args, usage):
|
|
526
|
-
parsed = _parse_flag_args(args, {
|
|
527
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
528
|
-
}, usage, positionals_key="names", max_positionals=1)
|
|
529
|
-
if len(parsed["names"]) != 1:
|
|
530
|
-
raise CdxError(usage)
|
|
531
|
-
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
def _parse_fast_value(value):
|
|
535
|
-
text = str(value).strip().lower()
|
|
536
|
-
if text in ("on", "true", "1", "yes"):
|
|
537
|
-
return True
|
|
538
|
-
if text in ("off", "false", "0", "no"):
|
|
539
|
-
return False
|
|
540
|
-
raise CdxError(SET_USAGE)
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
def _parse_priority_value(value):
|
|
544
|
-
try:
|
|
545
|
-
priority = int(value)
|
|
546
|
-
except (TypeError, ValueError) as error:
|
|
547
|
-
raise CdxError(SET_USAGE) from error
|
|
548
|
-
if priority < 0 or priority > 100:
|
|
549
|
-
raise CdxError(SET_USAGE)
|
|
550
|
-
return priority
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
def _parse_set_args(args):
|
|
554
|
-
parsed = _parse_flag_args(args, {
|
|
555
|
-
"--power": {"key": "power", "type": "str", "default": None},
|
|
556
|
-
"--permission": {"key": "permission", "type": "str", "default": None},
|
|
557
|
-
"--fast": {"key": "fast", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
558
|
-
"--rtk": {"key": "rtk", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
559
|
-
"--logics": {"key": "logics", "type": "str", "default": None, "transform": _parse_fast_value},
|
|
560
|
-
"--model": {"key": "model", "type": "str", "default": None},
|
|
561
|
-
"--priority": {"key": "priority", "type": "str", "default": None, "transform": _parse_priority_value},
|
|
562
|
-
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
563
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SET_USAGE)},
|
|
564
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
565
|
-
}, SET_USAGE, positionals_key="names", max_positionals=1)
|
|
566
|
-
if len(parsed["names"]) > 1:
|
|
567
|
-
raise CdxError(SET_USAGE)
|
|
568
|
-
if parsed["names"] and parsed["sessions"]:
|
|
569
|
-
raise CdxError(SET_USAGE)
|
|
570
|
-
if parsed["names"] and parsed["provider"]:
|
|
571
|
-
raise CdxError(SET_USAGE)
|
|
572
|
-
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
573
|
-
raise CdxError(SET_USAGE)
|
|
574
|
-
settings = {
|
|
575
|
-
key: parsed[key]
|
|
576
|
-
for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority")
|
|
577
|
-
if parsed[key] is not None
|
|
578
|
-
}
|
|
579
|
-
if not settings:
|
|
580
|
-
raise CdxError(SET_USAGE)
|
|
581
|
-
return {
|
|
582
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
583
|
-
"sessions": parsed["sessions"],
|
|
584
|
-
"provider": parsed["provider"],
|
|
585
|
-
"settings": settings,
|
|
586
|
-
"json": parsed["json"],
|
|
587
|
-
}
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
def _parse_unset_args(args):
|
|
591
|
-
parsed = _parse_flag_args(args, {
|
|
592
|
-
"--power": {"key": "power", "type": "bool", "default": False},
|
|
593
|
-
"--permission": {"key": "permission", "type": "bool", "default": False},
|
|
594
|
-
"--fast": {"key": "fast", "type": "bool", "default": False},
|
|
595
|
-
"--rtk": {"key": "rtk", "type": "bool", "default": False},
|
|
596
|
-
"--logics": {"key": "logics", "type": "bool", "default": False},
|
|
597
|
-
"--model": {"key": "model", "type": "bool", "default": False},
|
|
598
|
-
"--priority": {"key": "priority", "type": "bool", "default": False},
|
|
599
|
-
"--all": {"key": "all", "type": "bool", "default": False},
|
|
600
|
-
"--sessions": {"key": "sessions", "type": "str", "default": None, "transform": _parse_set_unset_sessions},
|
|
601
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, UNSET_USAGE)},
|
|
602
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
603
|
-
}, UNSET_USAGE, positionals_key="names", max_positionals=1)
|
|
604
|
-
if len(parsed["names"]) > 1:
|
|
605
|
-
raise CdxError(UNSET_USAGE)
|
|
606
|
-
if parsed["names"] and parsed["sessions"]:
|
|
607
|
-
raise CdxError(UNSET_USAGE)
|
|
608
|
-
if parsed["names"] and parsed["provider"]:
|
|
609
|
-
raise CdxError(UNSET_USAGE)
|
|
610
|
-
if not parsed["names"] and not parsed["sessions"] and not parsed["provider"]:
|
|
611
|
-
raise CdxError(UNSET_USAGE)
|
|
612
|
-
keys = ["power", "permission", "fast", "rtk", "logics", "model", "priority"] if parsed["all"] else [
|
|
613
|
-
key for key in ("power", "permission", "fast", "rtk", "logics", "model", "priority") if parsed[key]
|
|
614
|
-
]
|
|
615
|
-
if not keys:
|
|
616
|
-
raise CdxError(UNSET_USAGE)
|
|
617
|
-
return {
|
|
618
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
619
|
-
"sessions": parsed["sessions"],
|
|
620
|
-
"provider": parsed["provider"],
|
|
621
|
-
"keys": keys,
|
|
622
|
-
"json": parsed["json"],
|
|
623
|
-
}
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
def _parse_config_args(args):
|
|
627
|
-
parsed = _parse_flag_args(args, {
|
|
628
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
629
|
-
}, CONFIG_USAGE, positionals_key="names", max_positionals=1)
|
|
630
|
-
if len(parsed["names"]) != 1:
|
|
631
|
-
raise CdxError(CONFIG_USAGE)
|
|
632
|
-
return {"name": parsed["names"][0], "json": parsed["json"]}
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
def _parse_configs_args(args):
|
|
636
|
-
parsed = _parse_flag_args(args, {
|
|
637
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
638
|
-
}, CONFIGS_USAGE)
|
|
639
|
-
return {"json": parsed["json"]}
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
def _parse_select_args(args):
|
|
643
|
-
parsed = _parse_flag_args(args, {
|
|
644
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, SELECT_USAGE)},
|
|
645
|
-
"--min-reasoning-effort": {"key": "min_reasoning_effort", "type": "str", "default": None},
|
|
646
|
-
"--min-power": {"key": "min_power", "type": "str", "default": None},
|
|
647
|
-
"--require-ready": {"key": "require_ready", "type": "bool", "default": False},
|
|
648
|
-
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
649
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
650
|
-
}, SELECT_USAGE)
|
|
651
|
-
if not parsed["provider"] or not parsed["json"]:
|
|
652
|
-
raise CdxError(SELECT_USAGE)
|
|
653
|
-
effort = _normalize_reasoning_effort(
|
|
654
|
-
reasoning_effort=parsed["min_reasoning_effort"],
|
|
655
|
-
power=parsed["min_power"],
|
|
656
|
-
usage=SELECT_USAGE,
|
|
657
|
-
).get("reasoning_effort")
|
|
658
|
-
return {
|
|
659
|
-
"provider": parsed["provider"],
|
|
660
|
-
"min_reasoning_effort": effort,
|
|
661
|
-
"require_ready": parsed["require_ready"],
|
|
662
|
-
"refresh": parsed["refresh"],
|
|
663
|
-
"json": parsed["json"],
|
|
664
|
-
}
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
def _parse_next_args(args):
|
|
668
|
-
parsed = _parse_flag_args(args, {
|
|
669
|
-
"--refresh": {"key": "refresh", "type": "bool", "default": False},
|
|
670
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
671
|
-
}, NEXT_USAGE)
|
|
672
|
-
return {"json": parsed["json"], "refresh": parsed["refresh"]}
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
def _parse_timeout_seconds(value, usage=RUN_USAGE):
|
|
676
|
-
try:
|
|
677
|
-
parsed = float(value)
|
|
678
|
-
except (TypeError, ValueError) as error:
|
|
679
|
-
raise CdxError(usage) from error
|
|
680
|
-
if parsed <= 0:
|
|
681
|
-
raise CdxError(usage)
|
|
682
|
-
return parsed
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
def _normalize_run_permission(value):
|
|
686
|
-
if value is None:
|
|
687
|
-
return None
|
|
688
|
-
text = str(value).strip().lower()
|
|
689
|
-
aliases = {
|
|
690
|
-
"workspace-write": "default",
|
|
691
|
-
"read-only": "review",
|
|
692
|
-
"danger-full-access": "full",
|
|
693
|
-
}
|
|
694
|
-
text = aliases.get(text, text)
|
|
695
|
-
if text not in ("review", "default", "auto", "full"):
|
|
696
|
-
raise CdxError(RUN_USAGE)
|
|
697
|
-
return text
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
def _parse_run_args(args):
|
|
701
|
-
parsed = _parse_flag_args(args, {
|
|
702
|
-
"--cwd": {"key": "cwd", "type": "str", "default": None},
|
|
703
|
-
"--prompt-file": {"key": "prompt_file", "type": "str", "default": None},
|
|
704
|
-
"--prompt": {"key": "prompt", "type": "str", "default": None},
|
|
705
|
-
"--provider": {"key": "provider", "type": "str", "default": None, "transform": lambda value: _parse_provider_filter(value, RUN_USAGE)},
|
|
706
|
-
"--model": {"key": "model", "type": "str", "default": None},
|
|
707
|
-
"--kind": {"key": "kind", "type": "str", "default": "assistant"},
|
|
708
|
-
"--reasoning-effort": {"key": "reasoning_effort", "type": "str", "default": None},
|
|
709
|
-
"--power": {"key": "power", "type": "str", "default": None},
|
|
710
|
-
"--permission": {"key": "permission", "type": "str", "default": None, "transform": _normalize_run_permission},
|
|
711
|
-
"--timeout-seconds": {"key": "timeout_seconds", "type": "str", "default": None, "transform": _parse_timeout_seconds},
|
|
712
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
713
|
-
}, RUN_USAGE, positionals_key="names", max_positionals=1)
|
|
714
|
-
if not parsed["json"]:
|
|
715
|
-
raise CdxError(RUN_USAGE)
|
|
716
|
-
name = parsed["names"][0] if parsed["names"] else None
|
|
717
|
-
if name and parsed["provider"]:
|
|
718
|
-
raise CdxError(RUN_USAGE)
|
|
719
|
-
if not name and not parsed["provider"]:
|
|
720
|
-
raise CdxError(RUN_USAGE)
|
|
721
|
-
if not parsed["cwd"]:
|
|
722
|
-
raise CdxError(RUN_USAGE)
|
|
723
|
-
if bool(parsed["prompt_file"]) == bool(parsed["prompt"]):
|
|
724
|
-
raise CdxError(RUN_USAGE)
|
|
725
|
-
if parsed["kind"] not in ("assistant", "code-review"):
|
|
726
|
-
raise CdxError(RUN_USAGE)
|
|
727
|
-
effort = _normalize_reasoning_effort(
|
|
728
|
-
reasoning_effort=parsed["reasoning_effort"],
|
|
729
|
-
power=parsed["power"],
|
|
730
|
-
usage=RUN_USAGE,
|
|
731
|
-
)
|
|
732
|
-
return {
|
|
733
|
-
"name": name,
|
|
734
|
-
"provider": parsed["provider"],
|
|
735
|
-
"cwd": parsed["cwd"],
|
|
736
|
-
"prompt_file": parsed["prompt_file"],
|
|
737
|
-
"prompt": parsed["prompt"],
|
|
738
|
-
"kind": parsed["kind"],
|
|
739
|
-
"model": parsed["model"],
|
|
740
|
-
"permission": parsed["permission"],
|
|
741
|
-
"timeout_seconds": parsed["timeout_seconds"],
|
|
742
|
-
"reasoning_effort": effort.get("reasoning_effort"),
|
|
743
|
-
"power": effort.get("power"),
|
|
744
|
-
}
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
def _parse_positive_int(value):
|
|
748
|
-
try:
|
|
749
|
-
parsed = int(value)
|
|
750
|
-
except (TypeError, ValueError) as error:
|
|
751
|
-
raise CdxError(HISTORY_USAGE) from error
|
|
752
|
-
if parsed < 1:
|
|
753
|
-
raise CdxError(HISTORY_USAGE)
|
|
754
|
-
return parsed
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
def _local_timezone():
|
|
758
|
-
return datetime.now().astimezone().tzinfo
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
def _parse_datetime_value(value, usage, end_of_day=False):
|
|
762
|
-
text = str(value or "").strip()
|
|
763
|
-
if not text:
|
|
764
|
-
raise CdxError(usage)
|
|
765
|
-
if text.endswith("Z"):
|
|
766
|
-
text = f"{text[:-1]}+00:00"
|
|
767
|
-
is_date_only = len(text) == 10 and text[4] == "-" and text[7] == "-"
|
|
768
|
-
try:
|
|
769
|
-
parsed = datetime.fromisoformat(text)
|
|
770
|
-
except ValueError as error:
|
|
771
|
-
raise CdxError(usage) from error
|
|
772
|
-
if is_date_only and end_of_day:
|
|
773
|
-
parsed = parsed.replace(hour=23, minute=59, second=59, microsecond=999999)
|
|
774
|
-
if parsed.tzinfo is None:
|
|
775
|
-
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
776
|
-
return parsed.astimezone()
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
def _parse_since_value(value, now, usage):
|
|
780
|
-
text = str(value or "").strip().lower()
|
|
781
|
-
if not text:
|
|
782
|
-
raise CdxError(usage)
|
|
783
|
-
if text == "today":
|
|
784
|
-
return now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
785
|
-
if text == "yesterday":
|
|
786
|
-
today = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
|
787
|
-
return today - timedelta(days=1)
|
|
788
|
-
unit = text[-1:]
|
|
789
|
-
amount_text = text[:-1]
|
|
790
|
-
if unit in ("m", "h", "d", "w") and amount_text.isdigit():
|
|
791
|
-
amount = int(amount_text)
|
|
792
|
-
if amount < 1:
|
|
793
|
-
raise CdxError(usage)
|
|
794
|
-
multipliers = {
|
|
795
|
-
"m": timedelta(minutes=amount),
|
|
796
|
-
"h": timedelta(hours=amount),
|
|
797
|
-
"d": timedelta(days=amount),
|
|
798
|
-
"w": timedelta(weeks=amount),
|
|
799
|
-
}
|
|
800
|
-
return now - multipliers[unit]
|
|
801
|
-
return _parse_datetime_value(value, usage, end_of_day=False)
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
def _format_period_datetime(value):
|
|
805
|
-
if value is None:
|
|
806
|
-
return None
|
|
807
|
-
return value.isoformat(timespec="seconds")
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
def _parse_history_period(parsed, now):
|
|
811
|
-
since = parsed.get("since")
|
|
812
|
-
from_value = parsed.get("from")
|
|
813
|
-
to_value = parsed.get("to")
|
|
814
|
-
if since and from_value:
|
|
815
|
-
raise CdxError("Usage: cdx history cannot combine --since and --from.")
|
|
816
|
-
start = _parse_since_value(since, now, HISTORY_USAGE) if since else None
|
|
817
|
-
if from_value:
|
|
818
|
-
start = _parse_datetime_value(from_value, HISTORY_USAGE, end_of_day=False)
|
|
819
|
-
end = _parse_datetime_value(to_value, HISTORY_USAGE, end_of_day=True) if to_value else None
|
|
820
|
-
if start and end and start.timestamp() > end.timestamp():
|
|
821
|
-
raise CdxError("Usage: cdx history period start must be before period end.")
|
|
822
|
-
return {
|
|
823
|
-
"from": _format_period_datetime(start),
|
|
824
|
-
"to": _format_period_datetime(end),
|
|
825
|
-
"from_ts": start.timestamp() if start else None,
|
|
826
|
-
"to_ts": end.timestamp() if end else None,
|
|
827
|
-
}
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
def _has_history_period(period):
|
|
831
|
-
return period.get("from_ts") is not None or period.get("to_ts") is not None
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
def _parse_entry_timestamp(entry):
|
|
835
|
-
value = entry.get("started_at")
|
|
836
|
-
if not value:
|
|
837
|
-
return None
|
|
838
|
-
text = str(value)
|
|
839
|
-
if text.endswith("Z"):
|
|
840
|
-
text = f"{text[:-1]}+00:00"
|
|
841
|
-
try:
|
|
842
|
-
parsed = datetime.fromisoformat(text)
|
|
843
|
-
except ValueError:
|
|
844
|
-
return None
|
|
845
|
-
if parsed.tzinfo is None:
|
|
846
|
-
parsed = parsed.replace(tzinfo=_local_timezone())
|
|
847
|
-
return parsed.timestamp()
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
def _filter_history_period(entries, period):
|
|
851
|
-
if not _has_history_period(period):
|
|
852
|
-
return entries
|
|
853
|
-
filtered = []
|
|
854
|
-
start = period.get("from_ts")
|
|
855
|
-
end = period.get("to_ts")
|
|
856
|
-
for entry in entries:
|
|
857
|
-
timestamp = _parse_entry_timestamp(entry)
|
|
858
|
-
if timestamp is None:
|
|
859
|
-
continue
|
|
860
|
-
if start is not None and timestamp < start:
|
|
861
|
-
continue
|
|
862
|
-
if end is not None and timestamp > end:
|
|
863
|
-
continue
|
|
864
|
-
filtered.append(entry)
|
|
865
|
-
return filtered
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
def _public_history_period(period):
|
|
869
|
-
return {
|
|
870
|
-
"from": period.get("from"),
|
|
871
|
-
"to": period.get("to"),
|
|
872
|
-
}
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
def _parse_history_args(args, now=None):
|
|
876
|
-
now = now or datetime.now().astimezone()
|
|
877
|
-
parsed = _parse_flag_args(args, {
|
|
878
|
-
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
879
|
-
"--summary": {"key": "summary", "type": "bool", "default": False},
|
|
880
|
-
"--since": {"key": "since", "type": "str", "default": None},
|
|
881
|
-
"--from": {"key": "from", "type": "str", "default": None},
|
|
882
|
-
"--to": {"key": "to", "type": "str", "default": None},
|
|
883
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
884
|
-
}, HISTORY_USAGE, positionals_key="names", max_positionals=1)
|
|
885
|
-
period = _parse_history_period(parsed, now)
|
|
886
|
-
return {
|
|
887
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
888
|
-
"limit": parsed["limit"],
|
|
889
|
-
"summary": parsed["summary"],
|
|
890
|
-
"period": period,
|
|
891
|
-
"json": parsed["json"],
|
|
892
|
-
}
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
def _parse_stats_args(args, now=None):
|
|
896
|
-
now = now or datetime.now().astimezone()
|
|
897
|
-
parsed = _parse_flag_args(args, {
|
|
898
|
-
"--since": {"key": "since", "type": "str", "default": None},
|
|
899
|
-
"--from": {"key": "from", "type": "str", "default": None},
|
|
900
|
-
"--to": {"key": "to", "type": "str", "default": None},
|
|
901
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
902
|
-
}, STATS_USAGE, positionals_key="names", max_positionals=1)
|
|
903
|
-
period = _parse_history_period(parsed, now)
|
|
904
|
-
return {
|
|
905
|
-
"name": parsed["names"][0] if parsed["names"] else None,
|
|
906
|
-
"period": period,
|
|
907
|
-
"json": parsed["json"],
|
|
908
|
-
}
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
def _read_option_value(args, index, usage):
|
|
912
|
-
if index + 1 >= len(args):
|
|
913
|
-
raise CdxError(usage)
|
|
914
|
-
return args[index + 1], index + 2
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
def _parse_session_names(value):
|
|
918
|
-
if value is None:
|
|
919
|
-
return None
|
|
920
|
-
names = [item.strip() for item in value.split(",") if item.strip()]
|
|
921
|
-
if not names:
|
|
922
|
-
raise CdxError("At least one session name is required in --sessions.")
|
|
923
|
-
return names
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
def _parse_set_unset_sessions(value):
|
|
927
|
-
text = str(value or "").strip()
|
|
928
|
-
if not text:
|
|
929
|
-
raise CdxError("At least one session name is required in --sessions.")
|
|
930
|
-
if text.lower() == "all":
|
|
931
|
-
return "all"
|
|
932
|
-
return _parse_session_names(text)
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
def _parse_provider_filter(value, usage):
|
|
936
|
-
provider = str(value or "").strip()
|
|
937
|
-
if provider not in PROVIDERS:
|
|
938
|
-
raise CdxError(usage)
|
|
939
|
-
return provider
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
def _parse_launch_setting_target(value, usage):
|
|
943
|
-
text = str(value or "").strip()
|
|
944
|
-
if not text:
|
|
945
|
-
raise CdxError(usage)
|
|
946
|
-
if text == "all":
|
|
947
|
-
return {"name": None, "sessions": "all", "provider": None}
|
|
948
|
-
if text.startswith("provider:"):
|
|
949
|
-
return {"name": None, "sessions": None, "provider": _parse_provider_filter(text.split(":", 1)[1], usage)}
|
|
950
|
-
if "," in text:
|
|
951
|
-
return {"name": None, "sessions": _parse_session_names(text), "provider": None}
|
|
952
|
-
return {"name": text, "sessions": None, "provider": None}
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
def _parse_launch_setting_alias_args(kind, args):
|
|
956
|
-
json_flag, cleaned = _parse_json_flag(args)
|
|
957
|
-
if len(cleaned) != 2:
|
|
958
|
-
raise CdxError(SETTING_ALIAS_USAGE)
|
|
959
|
-
target = _parse_launch_setting_target(cleaned[0], SETTING_ALIAS_USAGE)
|
|
960
|
-
value = cleaned[1]
|
|
961
|
-
field = "permission" if kind == "perm" else kind
|
|
962
|
-
if value == "default":
|
|
963
|
-
return {**target, "keys": [field], "json": json_flag, "alias": kind}
|
|
964
|
-
if field == "fast":
|
|
965
|
-
value = _parse_fast_value(value)
|
|
966
|
-
return {**target, "settings": {field: value}, "json": json_flag, "alias": kind}
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
def _parse_update_args(args):
|
|
970
|
-
parsed = _parse_flag_args(args, {
|
|
971
|
-
"--check": {"key": "check", "type": "bool", "default": False},
|
|
972
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
973
|
-
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
974
|
-
"--version": {"key": "version", "type": "str", "default": None},
|
|
975
|
-
}, UPDATE_USAGE)
|
|
976
|
-
if parsed["check"] and parsed["version"]:
|
|
977
|
-
raise CdxError("Usage: cdx update --check cannot be combined with --version.")
|
|
978
|
-
if parsed["version"] is not None and not parsed["version"].strip():
|
|
979
|
-
raise CdxError("Usage: cdx update [--check] [--yes] [--json] [--version TAG]")
|
|
980
|
-
return parsed
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
def _parse_export_args(args):
|
|
984
|
-
parsed = _parse_flag_args(args, {
|
|
985
|
-
"--include-auth": {"key": "include_auth", "type": "bool", "default": False},
|
|
986
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
987
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
988
|
-
"--sessions": {
|
|
989
|
-
"key": "session_names",
|
|
990
|
-
"type": "str",
|
|
991
|
-
"default": None,
|
|
992
|
-
"transform": _parse_session_names,
|
|
993
|
-
},
|
|
994
|
-
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
995
|
-
}, EXPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
996
|
-
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
997
|
-
if not parsed["file_path"]:
|
|
998
|
-
raise CdxError(EXPORT_USAGE)
|
|
999
|
-
if parsed["passphrase_env"] and not parsed["include_auth"]:
|
|
1000
|
-
raise CdxError("--passphrase-env requires --include-auth for export.")
|
|
1001
|
-
return parsed
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
def _parse_import_args(args):
|
|
1005
|
-
parsed = _parse_flag_args(args, {
|
|
1006
|
-
"--force": {"key": "force", "type": "bool", "default": False},
|
|
1007
|
-
"--merge": {"key": "merge", "type": "bool", "default": False},
|
|
1008
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1009
|
-
"--sessions": {
|
|
1010
|
-
"key": "session_names",
|
|
1011
|
-
"type": "str",
|
|
1012
|
-
"default": None,
|
|
1013
|
-
"transform": _parse_session_names,
|
|
1014
|
-
},
|
|
1015
|
-
"--passphrase-env": {"key": "passphrase_env", "type": "str", "default": None},
|
|
1016
|
-
}, IMPORT_USAGE, positionals_key="positionals", max_positionals=1)
|
|
1017
|
-
parsed["file_path"] = parsed.pop("positionals")[0] if parsed["positionals"] else None
|
|
1018
|
-
if not parsed["file_path"]:
|
|
1019
|
-
raise CdxError(IMPORT_USAGE)
|
|
1020
|
-
if parsed["force"] and parsed["merge"]:
|
|
1021
|
-
raise CdxError("--force and --merge are mutually exclusive.")
|
|
1022
|
-
return parsed
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
110
|
def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False):
|
|
1026
111
|
env = ctx.get("env", {})
|
|
1027
112
|
if env_var:
|
|
@@ -1112,7 +197,7 @@ def _bootstrap_claude_setup_token(session, ctx):
|
|
|
1112
197
|
"Claude setup-token completed, but cdx could not capture the token. "
|
|
1113
198
|
"Run claude setup-token and save the token under credentials/default.json."
|
|
1114
199
|
)
|
|
1115
|
-
with open(transcript_path,
|
|
200
|
+
with open(transcript_path, encoding="utf-8", errors="replace") as handle:
|
|
1116
201
|
transcript = handle.read()
|
|
1117
202
|
token = _extract_claude_oauth_token(transcript)
|
|
1118
203
|
if not token:
|
|
@@ -1354,22 +439,6 @@ def handle_next(rest, ctx):
|
|
|
1354
439
|
return 0
|
|
1355
440
|
|
|
1356
441
|
|
|
1357
|
-
def _parse_runs_args(rest):
|
|
1358
|
-
return _parse_flag_args(rest, {
|
|
1359
|
-
"--limit": {"key": "limit", "type": "str", "default": 20, "transform": _parse_positive_int},
|
|
1360
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1361
|
-
}, RUNS_USAGE, max_positionals=0)
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
def _parse_run_id_json_args(rest, usage):
|
|
1365
|
-
parsed = _parse_flag_args(rest, {
|
|
1366
|
-
"--json": {"key": "json", "type": "bool", "default": False},
|
|
1367
|
-
}, usage, positionals_key="ids", max_positionals=1)
|
|
1368
|
-
if not parsed["json"] or len(parsed["ids"]) != 1:
|
|
1369
|
-
raise CdxError(usage)
|
|
1370
|
-
return {"run_id": parsed["ids"][0], "json": True}
|
|
1371
|
-
|
|
1372
|
-
|
|
1373
442
|
def _run_registry(ctx):
|
|
1374
443
|
return RunRegistry(ctx["service"]["base_dir"])
|
|
1375
444
|
|
|
@@ -2595,7 +1664,7 @@ def _refresh_claude_auth_states(service, target_names=None, spawn_sync=None, env
|
|
|
2595
1664
|
env_override=env_override,
|
|
2596
1665
|
)
|
|
2597
1666
|
now = _local_now_iso()
|
|
2598
|
-
service["update_auth_state"](session["name"], lambda auth: {
|
|
1667
|
+
service["update_auth_state"](session["name"], lambda auth, authenticated=authenticated, now=now: {
|
|
2599
1668
|
**auth,
|
|
2600
1669
|
"status": "authenticated" if authenticated else "logged_out",
|
|
2601
1670
|
"lastCheckedAt": now,
|