cdx-manager 0.12.3 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,341 @@
1
+ """Context and memory domain commands: context, memory.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import os
7
+ import sys
8
+
9
+ from ..cli_args import CONTEXT_USAGE, MEMORY_USAGE, _parse_json_flag
10
+ from ..cli_helpers import _format_bytes, _json_success, _write_json
11
+ from ..cli_render import _dim, _pad_table, _success
12
+ from ..context_store import (
13
+ append_context,
14
+ append_context_path,
15
+ clear_context,
16
+ clear_context_path,
17
+ edit_context,
18
+ edit_context_path,
19
+ get_context_path,
20
+ get_global_context_path,
21
+ get_named_project_context_path,
22
+ init_context,
23
+ init_context_path,
24
+ list_named_project_contexts,
25
+ read_context,
26
+ read_context_path,
27
+ write_context,
28
+ write_context_path,
29
+ )
30
+ from ..errors import CdxError
31
+
32
+
33
+ def handle_context(rest, ctx):
34
+ json_flag, args = _parse_json_flag(rest)
35
+ if not args:
36
+ args = ["show"]
37
+ action = args[0]
38
+ base_dir = ctx["service"]["base_dir"]
39
+ cwd = ctx.get("cwd")
40
+
41
+ if action == "show":
42
+ if len(args) != 1:
43
+ raise CdxError(CONTEXT_USAGE)
44
+ content = read_context(base_dir, cwd)
45
+ payload = {
46
+ "path": get_context_path(base_dir, cwd),
47
+ "exists": bool(content.strip()),
48
+ "content": content,
49
+ }
50
+ if json_flag:
51
+ _write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
52
+ return 0
53
+ if content.strip():
54
+ ctx["out"](content if content.endswith("\n") else f"{content}\n")
55
+ else:
56
+ ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
57
+ return 0
58
+
59
+ if action == "path":
60
+ if len(args) != 1:
61
+ raise CdxError(CONTEXT_USAGE)
62
+ path = get_context_path(base_dir, cwd)
63
+ if json_flag:
64
+ _write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
65
+ return 0
66
+ ctx["out"](f"{path}\n")
67
+ return 0
68
+
69
+ if action == "init":
70
+ if len(args) != 1:
71
+ raise CdxError(CONTEXT_USAGE)
72
+ result = init_context(base_dir, cwd)
73
+ message = "Created shared context" if result.get("created") else "Shared context already exists"
74
+ if json_flag:
75
+ _write_json(ctx, _json_success("context.init", message, context=result))
76
+ return 0
77
+ text = f"{message}: {result['path']}"
78
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
79
+ return 0
80
+
81
+ if action == "edit":
82
+ if len(args) != 1:
83
+ raise CdxError(CONTEXT_USAGE)
84
+ result = edit_context(
85
+ base_dir,
86
+ cwd,
87
+ env=ctx.get("env"),
88
+ spawn_sync=ctx.get("spawn_sync"),
89
+ )
90
+ if json_flag:
91
+ _write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
92
+ return 0
93
+ text = f"Edited shared context: {result['path']}"
94
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
95
+ return 0
96
+
97
+ if action == "clear":
98
+ if len(args) != 1:
99
+ raise CdxError(CONTEXT_USAGE)
100
+ result = clear_context(base_dir, cwd)
101
+ message = "Cleared shared context" if result["removed"] else "No shared context to clear"
102
+ if json_flag:
103
+ _write_json(ctx, _json_success("context.clear", message, context=result))
104
+ return 0
105
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
106
+ return 0
107
+
108
+ if action == "set":
109
+ content_args = args[1:]
110
+ if content_args:
111
+ content = " ".join(content_args)
112
+ else:
113
+ stdin = ctx["options"].get("stdin_data")
114
+ if stdin is None and not ctx.get("stdin_is_tty"):
115
+ stdin = sys.stdin.read()
116
+ if stdin is None:
117
+ raise CdxError("Usage: cdx context set <text> [--json]")
118
+ content = stdin
119
+ result = write_context(base_dir, content, cwd)
120
+ if json_flag:
121
+ _write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
122
+ return 0
123
+ text = f"Saved shared context: {result['path']}"
124
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
125
+ return 0
126
+
127
+ if action == "append":
128
+ note = " ".join(args[1:])
129
+ if not note.strip():
130
+ raise CdxError("Usage: cdx context append <text> [--json]")
131
+ result = append_context(base_dir, note, cwd)
132
+ if json_flag:
133
+ _write_json(ctx, _json_success("context.append", "Appended shared context", context=result))
134
+ return 0
135
+ text = f"Appended shared context: {result['path']}"
136
+ ctx["out"](f"{_success(text, ctx['use_color'])}\n")
137
+ return 0
138
+
139
+ raise CdxError(CONTEXT_USAGE)
140
+
141
+ def _parse_memory_args(rest):
142
+ json_flag, args = _parse_json_flag(rest)
143
+ scope = "current"
144
+ project = None
145
+ cleaned = []
146
+ index = 0
147
+ while index < len(args):
148
+ arg = args[index]
149
+ if arg == "--global":
150
+ if scope != "current":
151
+ raise CdxError(MEMORY_USAGE)
152
+ scope = "global"
153
+ index += 1
154
+ continue
155
+ if arg == "--project":
156
+ if scope != "current" or index + 1 >= len(args) or not args[index + 1].strip():
157
+ raise CdxError(MEMORY_USAGE)
158
+ scope = "project"
159
+ project = args[index + 1].strip()
160
+ index += 2
161
+ continue
162
+ if arg.startswith("--project="):
163
+ if scope != "current":
164
+ raise CdxError(MEMORY_USAGE)
165
+ project = arg.split("=", 1)[1].strip()
166
+ if not project:
167
+ raise CdxError(MEMORY_USAGE)
168
+ scope = "project"
169
+ index += 1
170
+ continue
171
+ cleaned.append(arg)
172
+ index += 1
173
+ if not cleaned:
174
+ cleaned = ["show"]
175
+ if cleaned[0] == "view":
176
+ cleaned[0] = "show"
177
+ return json_flag, scope, project, cleaned
178
+
179
+ def _memory_target(base_dir, cwd, scope, project):
180
+ if scope == "global":
181
+ return {"scope": "global", "path": get_global_context_path(base_dir)}
182
+ if scope == "project":
183
+ if os.path.exists(project):
184
+ return {
185
+ "scope": "project",
186
+ "project": project,
187
+ "project_kind": "path",
188
+ "path": get_context_path(base_dir, project),
189
+ }
190
+ return {
191
+ "scope": "project",
192
+ "project": project,
193
+ "project_kind": "name",
194
+ "path": get_named_project_context_path(base_dir, project),
195
+ }
196
+ return {"scope": "current", "path": get_context_path(base_dir, cwd)}
197
+
198
+ def _memory_payload(target, content=None):
199
+ payload = {
200
+ "scope": target["scope"],
201
+ "path": target["path"],
202
+ "exists": os.path.isfile(target["path"]),
203
+ }
204
+ if target.get("project") is not None:
205
+ payload["project"] = target["project"]
206
+ payload["project_kind"] = target.get("project_kind")
207
+ if payload["exists"]:
208
+ payload["bytes"] = os.path.getsize(target["path"])
209
+ if content is not None:
210
+ payload["content"] = content
211
+ return payload
212
+
213
+ def _list_memory_entries(base_dir, cwd):
214
+ entries = []
215
+ global_path = get_global_context_path(base_dir)
216
+ if os.path.isfile(global_path):
217
+ entries.append({"scope": "global", "path": global_path, "bytes": os.path.getsize(global_path)})
218
+ entries.extend(list_named_project_contexts(base_dir))
219
+ current_path = get_context_path(base_dir, cwd)
220
+ if os.path.isfile(current_path):
221
+ entries.append({"scope": "current", "path": current_path, "bytes": os.path.getsize(current_path)})
222
+ return entries
223
+
224
+ def handle_memory(rest, ctx):
225
+ json_flag, scope, project, args = _parse_memory_args(rest)
226
+ action = args[0]
227
+ base_dir = ctx["service"]["base_dir"]
228
+ cwd = ctx.get("cwd")
229
+
230
+ if action == "list":
231
+ if len(args) != 1 or scope != "current":
232
+ raise CdxError(MEMORY_USAGE)
233
+ entries = _list_memory_entries(base_dir, cwd)
234
+ if json_flag:
235
+ _write_json(ctx, _json_success("memory.list", "Listed memory scopes", memories=entries))
236
+ return 0
237
+ if not entries:
238
+ ctx["out"](f"{_dim('No cdx memory files found.', ctx['use_color'])}\n")
239
+ return 0
240
+ rows = [["SCOPE", "PROJECT", "SIZE", "PATH"]]
241
+ for entry in entries:
242
+ rows.append([
243
+ entry["scope"],
244
+ entry.get("project") or "-",
245
+ _format_bytes(entry.get("bytes") or 0),
246
+ entry["path"],
247
+ ])
248
+ ctx["out"](_pad_table(rows) + "\n")
249
+ return 0
250
+
251
+ target = _memory_target(base_dir, cwd, scope, project)
252
+ path = target["path"]
253
+
254
+ if action == "show":
255
+ if len(args) != 1:
256
+ raise CdxError(MEMORY_USAGE)
257
+ content = read_context_path(path)
258
+ payload = _memory_payload(target, content=content)
259
+ if json_flag:
260
+ _write_json(ctx, _json_success("memory.show", "Loaded memory", memory=payload))
261
+ return 0
262
+ if content.strip():
263
+ ctx["out"](content if content.endswith("\n") else f"{content}\n")
264
+ else:
265
+ ctx["out"](f"{_dim('No memory for this scope. Run: cdx memory append <text>', ctx['use_color'])}\n")
266
+ return 0
267
+
268
+ if action == "path":
269
+ if len(args) != 1:
270
+ raise CdxError(MEMORY_USAGE)
271
+ if json_flag:
272
+ _write_json(ctx, _json_success("memory.path", "Resolved memory path", memory=_memory_payload(target)))
273
+ return 0
274
+ ctx["out"](f"{path}\n")
275
+ return 0
276
+
277
+ if action == "init":
278
+ if len(args) != 1:
279
+ raise CdxError(MEMORY_USAGE)
280
+ result = init_context_path(path)
281
+ result = {**result, **{key: value for key, value in target.items() if key != "path"}}
282
+ message = "Created memory" if result.get("created") else "Memory already exists"
283
+ if json_flag:
284
+ _write_json(ctx, _json_success("memory.init", message, memory=result))
285
+ return 0
286
+ ctx["out"](f"{_success(f'{message}: {path}', ctx['use_color'])}\n")
287
+ return 0
288
+
289
+ if action == "edit":
290
+ if len(args) != 1:
291
+ raise CdxError(MEMORY_USAGE)
292
+ result = edit_context_path(path, env=ctx.get("env"), spawn_sync=ctx.get("spawn_sync"))
293
+ result = {**result, **{key: value for key, value in target.items() if key != "path"}}
294
+ if json_flag:
295
+ _write_json(ctx, _json_success("memory.edit", "Edited memory", memory=result))
296
+ return 0
297
+ ctx["out"](f"{_success(f'Edited memory: {path}', ctx['use_color'])}\n")
298
+ return 0
299
+
300
+ if action == "clear":
301
+ if len(args) != 1:
302
+ raise CdxError(MEMORY_USAGE)
303
+ result = clear_context_path(path)
304
+ result = {**result, **{key: value for key, value in target.items() if key != "path"}}
305
+ message = "Cleared memory" if result["removed"] else "No memory to clear"
306
+ if json_flag:
307
+ _write_json(ctx, _json_success("memory.clear", message, memory=result))
308
+ return 0
309
+ ctx["out"](f"{_success(message, ctx['use_color'])}\n")
310
+ return 0
311
+
312
+ if action == "set":
313
+ content_args = args[1:]
314
+ if content_args:
315
+ content = " ".join(content_args)
316
+ else:
317
+ stdin = ctx["options"].get("stdin_data")
318
+ if stdin is None and not ctx.get("stdin_is_tty"):
319
+ stdin = sys.stdin.read()
320
+ if stdin is None:
321
+ raise CdxError("Usage: cdx memory set <text> [--json]")
322
+ content = stdin
323
+ result = write_context_path(path, content)
324
+ result = {**result, **{key: value for key, value in target.items() if key != "path"}}
325
+ if json_flag:
326
+ _write_json(ctx, _json_success("memory.set", "Saved memory", memory=result))
327
+ return 0
328
+ ctx["out"](f"{_success(f'Saved memory: {path}', ctx['use_color'])}\n")
329
+ return 0
330
+
331
+ if action == "append":
332
+ note = " ".join(args[1:])
333
+ result = append_context_path(path, note)
334
+ result = {**result, **{key: value for key, value in target.items() if key != "path"}}
335
+ if json_flag:
336
+ _write_json(ctx, _json_success("memory.append", "Appended memory", memory=result))
337
+ return 0
338
+ ctx["out"](f"{_success(f'Appended memory: {path}', ctx['use_color'])}\n")
339
+ return 0
340
+
341
+ raise CdxError(MEMORY_USAGE)
@@ -0,0 +1,218 @@
1
+ """Launch domain commands: launch, resume, can-resume, handoff.
2
+
3
+ Split out of cli_commands.py. Moved verbatim; re-exported by cli_commands.
4
+ """
5
+
6
+ import os
7
+ import shlex
8
+
9
+ from ..cli_args import CAN_RESUME_USAGE, HANDOFF_USAGE, RESUME_USAGE, _parse_json_flag
10
+ from ..cli_helpers import (
11
+ API_SCHEMA_VERSION,
12
+ _build_handoff_context,
13
+ _handoff_launch_prompt,
14
+ _json_success,
15
+ _latest_handoff_transcript_path,
16
+ _read_handoff_transcript,
17
+ _resume_capability_for_session,
18
+ _update_notice_warnings,
19
+ _warn_if_session_already_running,
20
+ _write_json,
21
+ _write_update_notice,
22
+ )
23
+ from ..cli_render import _dim, _info, _success, _warn
24
+ from ..context_store import install_context_for_session, write_context
25
+ from ..errors import CdxError
26
+ from ..provider_runtime import _ensure_session_authentication, _run_interactive_provider_command
27
+
28
+
29
+ def _format_resume_capability(capability, use_color=False):
30
+ name = capability["session"]
31
+ provider = capability["provider"]
32
+ if capability["resumable"]:
33
+ preview = shlex.join(capability.get("command_preview") or [])
34
+ detail = f"({provider}, {capability['strategy']})"
35
+ return (
36
+ f"{_success(f'{name} can resume', use_color)} "
37
+ f"{_dim(detail, use_color)}\n"
38
+ f"{_dim(preview, use_color)}"
39
+ )
40
+ reason = capability.get("reason") or "not_supported"
41
+ return (
42
+ f"{_warn(f'{name} cannot resume', use_color)} "
43
+ f"{_dim(f'({provider}, {reason})', use_color)}"
44
+ )
45
+
46
+ def handle_can_resume(rest, ctx):
47
+ json_flag, args = _parse_json_flag(rest)
48
+ if len(args) != 1:
49
+ raise CdxError(CAN_RESUME_USAGE)
50
+ session = ctx["service"]["get_session"](args[0])
51
+ if not session:
52
+ raise CdxError(f"Unknown session: {args[0]}")
53
+ if session.get("enabled", True) is False:
54
+ capability = {
55
+ "session": session["name"],
56
+ "provider": session["provider"],
57
+ "resumable": False,
58
+ "strategy": "session_disabled",
59
+ "reason": "session_disabled",
60
+ "command_preview": [],
61
+ }
62
+ else:
63
+ capability = _resume_capability_for_session(session, ctx)
64
+ if json_flag:
65
+ _write_json(ctx, {
66
+ "schema_version": API_SCHEMA_VERSION,
67
+ "ok": True,
68
+ **capability,
69
+ })
70
+ return 0
71
+ ctx["out"](f"{_format_resume_capability(capability, ctx['use_color'])}\n")
72
+ return 0
73
+
74
+ def handle_resume(rest, ctx):
75
+ json_flag, args = _parse_json_flag(rest)
76
+ if len(args) != 1:
77
+ raise CdxError(RESUME_USAGE)
78
+ return handle_launch(args[0], ctx, resume=True, force_json=json_flag)
79
+
80
+ def handle_launch(command, ctx, initial_prompt=None, resume=False, force_json=None):
81
+ json_flag = "--json" in ctx.get("raw_args", ctx["options"].get("raw_args", []))
82
+ if force_json is not None:
83
+ json_flag = force_json
84
+ warnings = _update_notice_warnings(ctx)
85
+ _warn_if_session_already_running(command, ctx)
86
+ session = ctx["service"]["launch_session"](command)
87
+ capability = _resume_capability_for_session(session, ctx) if resume else None
88
+ if capability and not capability["resumable"]:
89
+ raise CdxError(
90
+ f"Provider {session['provider']} does not support native resume through cdx."
91
+ )
92
+ _ensure_session_authentication(
93
+ session,
94
+ ctx["service"],
95
+ spawn=ctx.get("spawn"),
96
+ spawn_sync=ctx.get("spawn_sync"),
97
+ env_override=ctx.get("env"),
98
+ stdin_is_tty=ctx["stdin_is_tty"],
99
+ behavior="launch",
100
+ signal_emitter=ctx.get("signal_emitter"),
101
+ trust_local_credentials=False,
102
+ )
103
+ message = (
104
+ f"Resuming {session['provider']} session {session['name']}"
105
+ if resume else
106
+ f"Launching {session['provider']} session {session['name']}"
107
+ )
108
+ if not json_flag:
109
+ ctx["out"](f"{_info(message, ctx['use_color'])}\n")
110
+ _write_update_notice(ctx)
111
+ cwd = ctx.get("cwd") or os.getcwd()
112
+ runtime_run_id = None
113
+
114
+ def runtime_lifecycle(event, info):
115
+ nonlocal runtime_run_id
116
+ if event == "started":
117
+ runtime = ctx["service"]["start_session_runtime"](session["name"], info)
118
+ runtime_run_id = runtime.get("runId")
119
+ elif event == "finished" and runtime_run_id:
120
+ ctx["service"]["finish_session_runtime"](
121
+ session["name"],
122
+ runtime_run_id,
123
+ {"status": "stopped", "returncode": info.get("returncode")},
124
+ )
125
+
126
+ try:
127
+ run_info = _run_interactive_provider_command(
128
+ session, "resume" if resume else "launch", spawn=ctx.get("spawn"), cwd=cwd, env_override=ctx.get("env"),
129
+ signal_emitter=ctx.get("signal_emitter"), initial_prompt=initial_prompt,
130
+ lifecycle_callback=runtime_lifecycle,
131
+ )
132
+ except CdxError as error:
133
+ run_info = getattr(error, "run_info", {}) or {}
134
+ if runtime_run_id:
135
+ ctx["service"]["finish_session_runtime"](
136
+ session["name"],
137
+ runtime_run_id,
138
+ {"status": "failed", "returncode": run_info.get("returncode")},
139
+ )
140
+ if run_info:
141
+ ctx["service"]["record_launch_history"](session["name"], {
142
+ "status": "failed",
143
+ "cwd": cwd,
144
+ "error": str(error),
145
+ "exit_code": error.exit_code,
146
+ **run_info,
147
+ })
148
+ raise
149
+ ctx["service"]["record_launch_history"](session["name"], {
150
+ "status": "success",
151
+ "action": "resume" if resume else "launch",
152
+ "cwd": cwd,
153
+ "exit_code": 0,
154
+ **run_info,
155
+ })
156
+ if json_flag:
157
+ extra = {"session": ctx["service"]["get_session"](session["name"])}
158
+ if capability:
159
+ extra["resume"] = capability
160
+ _write_json(ctx, _json_success("resume" if resume else "launch", message, warnings=warnings, **extra))
161
+ return 0
162
+
163
+ def handle_handoff(rest, ctx):
164
+ json_flag, args = _parse_json_flag(rest)
165
+ if len(args) not in (1, 2):
166
+ raise CdxError(HANDOFF_USAGE)
167
+ if len(args) == 1:
168
+ name = args[0]
169
+ session = ctx["service"]["get_session"](name)
170
+ if not session:
171
+ raise CdxError(f"Unknown session: {name}")
172
+ install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
173
+ launch_prompt = _handoff_launch_prompt(session, install)
174
+ if json_flag:
175
+ _write_json(ctx, _json_success(
176
+ "handoff",
177
+ f"Installed shared context for {name}",
178
+ context=install,
179
+ launch_prompt=launch_prompt,
180
+ session=session,
181
+ ))
182
+ return 0
183
+ text = f"Shared context installed for {name}: {install['target_path']}"
184
+ ctx["out"](f"{_info(text, ctx['use_color'])}\n")
185
+ return handle_launch(name, ctx, initial_prompt=launch_prompt)
186
+
187
+ source_name, target_name = args
188
+ if source_name == target_name:
189
+ raise CdxError("Source and target sessions must be different")
190
+ source = ctx["service"]["get_session"](source_name)
191
+ if not source:
192
+ raise CdxError(f"Unknown session: {source_name}")
193
+ target = ctx["service"]["get_session"](target_name)
194
+ if not target:
195
+ raise CdxError(f"Unknown session: {target_name}")
196
+ transcript_path = _latest_handoff_transcript_path(source)
197
+ if not transcript_path:
198
+ raise CdxError(f"No transcript found for session: {source_name}")
199
+ transcript, truncated = _read_handoff_transcript(transcript_path)
200
+ context = _build_handoff_context(source, target, transcript_path, transcript, truncated=truncated)
201
+ write_result = write_context(ctx["service"]["base_dir"], context, ctx.get("cwd"))
202
+ install = install_context_for_session(ctx["service"]["base_dir"], target, ctx.get("cwd"))
203
+ launch_prompt = _handoff_launch_prompt(target, install)
204
+ if json_flag:
205
+ _write_json(ctx, _json_success(
206
+ "handoff",
207
+ f"Prepared handoff from {source_name} to {target_name}",
208
+ context=install,
209
+ source_session=source,
210
+ target_session=target,
211
+ source_transcript=transcript_path,
212
+ shared_context=write_result,
213
+ launch_prompt=launch_prompt,
214
+ ))
215
+ return 0
216
+ text = f"Handoff prepared from {source_name} to {target_name}: {install['target_path']}"
217
+ ctx["out"](f"{_info(text, ctx['use_color'])}\n")
218
+ return handle_launch(target_name, ctx, initial_prompt=launch_prompt)