cdx-manager 0.4.4 → 0.5.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.
- package/README.md +13 -2
- package/changelogs/CHANGELOGS_0_5_0.md +45 -0
- package/package.json +3 -1
- package/pyproject.toml +1 -1
- package/src/claude_refresh.py +1 -0
- package/src/cli.py +25 -2
- package/src/cli_commands.py +161 -0
- package/src/cli_render.py +7 -0
- package/src/context_store.py +127 -0
- package/src/session_service.py +40 -2
- package/src/status_view.py +15 -7
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex and Claude sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -36,6 +36,8 @@ One command to launch any session. Zero auth juggling.
|
|
|
36
36
|
- **Instant launch.** `cdx work` opens your "work" session. `cdx personal` opens another. No config files to edit mid-flow.
|
|
37
37
|
- **Auth guardrails.** `cdx` checks authentication before launching. If a session is not logged in, it tells you exactly what to run — no silent failures.
|
|
38
38
|
- **Usage at a glance.** `cdx status` shows token usage, 5-hour window quota, weekly quota, and last-updated timestamps for every session in one aligned table.
|
|
39
|
+
- **Session control.** Disable a session without deleting it when an account is temporarily out of credits; disabled sessions remain visible and sort last.
|
|
40
|
+
- **Shared handoff context.** Keep a per-workspace Markdown context and install it into another assistant session before switching providers or accounts.
|
|
39
41
|
- **Passive status resolution.** If a session has no recorded status, `cdx` reads it directly from the provider's session logs and JSONL history — no manual sync required.
|
|
40
42
|
- **Session transcript capture.** Every launch is recorded to a local log file via `script`, giving you a full terminal transcript for each session.
|
|
41
43
|
- **Clean removal.** `cdx rmv` wipes a session and its entire auth directory. No orphaned files, no stale credentials.
|
|
@@ -52,6 +54,7 @@ One command to launch any session. Zero auth juggling.
|
|
|
52
54
|
- Persistence:
|
|
53
55
|
- Session registry at `~/.cdx/sessions.json` (versioned JSON store).
|
|
54
56
|
- Per-session state at `~/.cdx/state/<name>.json`.
|
|
57
|
+
- Per-workspace shared context at `~/.cdx/contexts/<workspace-hash>/context.md`.
|
|
55
58
|
- Auth and provider data under `~/.cdx/profiles/<name>/`.
|
|
56
59
|
- All paths are URL-encoded to support arbitrary session names.
|
|
57
60
|
- Status resolution pipeline:
|
|
@@ -122,7 +125,7 @@ For a specific version:
|
|
|
122
125
|
|
|
123
126
|
```bash
|
|
124
127
|
curl -fsSL https://raw.githubusercontent.com/AlexAgo83/cdx-manager/main/install.sh -o install.sh
|
|
125
|
-
CDX_VERSION=v0.
|
|
128
|
+
CDX_VERSION=v0.5.0 sh install.sh
|
|
126
129
|
```
|
|
127
130
|
|
|
128
131
|
From source:
|
|
@@ -242,6 +245,10 @@ cdx status
|
|
|
242
245
|
| `cdx ren <source> <dest> [--json]` | Rename a session and move its auth data |
|
|
243
246
|
| `cdx login <name> [--json]` | Re-authenticate a session (logout + login) |
|
|
244
247
|
| `cdx logout <name> [--json]` | Log out of a session |
|
|
248
|
+
| `cdx disable <name> [--json]` | Disable a session without deleting it; disabled sessions stay visible and cannot launch |
|
|
249
|
+
| `cdx enable <name> [--json]` | Re-enable a disabled session |
|
|
250
|
+
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
251
|
+
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
245
252
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
246
253
|
| `cdx clean [name] [--json]` | Clear launch transcript logs for one session or all sessions |
|
|
247
254
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
@@ -277,6 +284,10 @@ Commands with machine-readable output:
|
|
|
277
284
|
- `cdx import ... --json`
|
|
278
285
|
- `cdx login ... --json`
|
|
279
286
|
- `cdx logout ... --json`
|
|
287
|
+
- `cdx disable ... --json`
|
|
288
|
+
- `cdx enable ... --json`
|
|
289
|
+
- `cdx context ... --json`
|
|
290
|
+
- `cdx handoff ... --json`
|
|
280
291
|
- `cdx doctor --json`
|
|
281
292
|
- `cdx repair --json`
|
|
282
293
|
- `cdx update --json`
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# Changelog (`0.4.4 -> 0.5.0`)
|
|
2
|
+
|
|
3
|
+
Release date: 2026-05-20
|
|
4
|
+
|
|
5
|
+
## Major Highlights
|
|
6
|
+
|
|
7
|
+
- Added session disable/enable controls so an assistant can be kept in the registry while temporarily removed from launch rotation.
|
|
8
|
+
- Added shared workspace context commands to make provider/account handoff explicit when moving between Codex and Claude sessions.
|
|
9
|
+
- Prepared the package metadata for the 0.5.0 release across npm, PyPI, CLI version output, and README install examples.
|
|
10
|
+
|
|
11
|
+
## Session Disable Toggle
|
|
12
|
+
|
|
13
|
+
- Added `cdx disable <name> [--json]` and `cdx enable <name> [--json]`.
|
|
14
|
+
- Disabled sessions remain visible in the main list and status views with a `disabled` status.
|
|
15
|
+
- Disabled sessions sort after enabled sessions and are excluded from status priority recommendations.
|
|
16
|
+
- Launching a disabled session now fails with a clear `Session is disabled: <name>` error.
|
|
17
|
+
- Claude live status refresh skips disabled sessions.
|
|
18
|
+
|
|
19
|
+
## Shared Context Handoff
|
|
20
|
+
|
|
21
|
+
- Added per-workspace shared context storage at `~/.cdx/contexts/<workspace-hash>/context.md`.
|
|
22
|
+
- Added `cdx context show`, `path`, `init`, `edit`, `clear`, and `set`.
|
|
23
|
+
- Added `cdx handoff <name>` to install the current workspace context into the target session as `shared-context.md` before launching it.
|
|
24
|
+
- Added `cdx handoff <name> --json` for non-interactive integrations that only need to install the context and inspect the resulting paths.
|
|
25
|
+
- Reserved `context` and `handoff` as session names to avoid command collisions.
|
|
26
|
+
|
|
27
|
+
## Packaging
|
|
28
|
+
|
|
29
|
+
- Bumped `package.json`, `package-lock.json`, `pyproject.toml`, and `src/cli.py` to `0.5.0`.
|
|
30
|
+
- Updated the README version badge, pinned installer example, command table, JSON command list, and data layout notes.
|
|
31
|
+
- Removed the accidental npm self-dependency from `package.json` and regenerated the lockfile.
|
|
32
|
+
|
|
33
|
+
## Validation and Regression Coverage
|
|
34
|
+
|
|
35
|
+
- Added CLI coverage for context storage, workspace isolation, and handoff installation.
|
|
36
|
+
- Added session-service coverage for disabled-session ordering, launch blocking, and new reserved command names.
|
|
37
|
+
- Kept the full Python test suite green.
|
|
38
|
+
|
|
39
|
+
## Validation and Regression Evidence
|
|
40
|
+
|
|
41
|
+
- `python -m pytest`
|
|
42
|
+
- `npm run lint`
|
|
43
|
+
- `npm test`
|
|
44
|
+
- `node bin/cdx.js --version`
|
|
45
|
+
- `npm pack --dry-run`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "cdx-manager",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Terminal session manager for Codex and Claude accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Alexandre Agostini",
|
|
@@ -28,6 +28,8 @@
|
|
|
28
28
|
"checksums",
|
|
29
29
|
"changelogs",
|
|
30
30
|
"src",
|
|
31
|
+
"!src/__pycache__",
|
|
32
|
+
"!src/**/*.pyc",
|
|
31
33
|
"install.sh",
|
|
32
34
|
"install.ps1",
|
|
33
35
|
"pyproject.toml",
|
package/pyproject.toml
CHANGED
package/src/claude_refresh.py
CHANGED
|
@@ -36,6 +36,7 @@ def _refresh_claude_sessions(service, refresh_fn=None, target_names=None, force=
|
|
|
36
36
|
claude_sessions = [
|
|
37
37
|
s for s in sessions
|
|
38
38
|
if s["provider"] == "claude"
|
|
39
|
+
and s.get("enabled", True) is not False
|
|
39
40
|
and (not target_names or s["name"] in target_names)
|
|
40
41
|
and (force or _is_stale(s, ttl_seconds=ttl_seconds))
|
|
41
42
|
]
|
package/src/cli.py
CHANGED
|
@@ -9,10 +9,14 @@ from .cli_commands import (
|
|
|
9
9
|
STATUS_USAGE,
|
|
10
10
|
handle_add,
|
|
11
11
|
handle_clean,
|
|
12
|
+
handle_context,
|
|
12
13
|
handle_copy,
|
|
13
14
|
handle_doctor,
|
|
15
|
+
handle_disable,
|
|
16
|
+
handle_enable,
|
|
14
17
|
handle_export,
|
|
15
18
|
handle_import,
|
|
19
|
+
handle_handoff,
|
|
16
20
|
handle_launch,
|
|
17
21
|
handle_login,
|
|
18
22
|
handle_logout,
|
|
@@ -45,7 +49,7 @@ from .status_view import (
|
|
|
45
49
|
)
|
|
46
50
|
from .update_check import check_for_update
|
|
47
51
|
|
|
48
|
-
VERSION = "0.
|
|
52
|
+
VERSION = "0.5.0"
|
|
49
53
|
|
|
50
54
|
|
|
51
55
|
# ---------------------------------------------------------------------------
|
|
@@ -62,11 +66,15 @@ def _print_help(use_color=False):
|
|
|
62
66
|
f" {_style('cdx status [--json] [--refresh]', '36', use_color)}",
|
|
63
67
|
f" {_style('cdx status --small|-s [--refresh]', '36', use_color)}",
|
|
64
68
|
f" {_style('cdx status <name> [--json] [--refresh]', '36', use_color)}",
|
|
69
|
+
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
70
|
+
f" {_style('cdx handoff <name> [--json]', '36', use_color)}",
|
|
65
71
|
f" {_style('cdx add [provider] <name> [--json]', '36', use_color)}",
|
|
66
72
|
f" {_style('cdx cp <source> <dest> [--json]', '36', use_color)}",
|
|
67
73
|
f" {_style('cdx ren <source> <dest> [--json]', '36', use_color)}",
|
|
68
74
|
f" {_style('cdx login <name> [--json]', '36', use_color)}",
|
|
69
75
|
f" {_style('cdx logout <name> [--json]', '36', use_color)}",
|
|
76
|
+
f" {_style('cdx disable <name> [--json]', '36', use_color)}",
|
|
77
|
+
f" {_style('cdx enable <name> [--json]', '36', use_color)}",
|
|
70
78
|
f" {_style('cdx rmv <name> [--force] [--json]', '36', use_color)}",
|
|
71
79
|
f" {_style('cdx clean [name] [--json]', '36', use_color)}",
|
|
72
80
|
f" {_style('cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR] [--force] [--json]', '36', use_color)}",
|
|
@@ -101,6 +109,8 @@ def format_json_error(error):
|
|
|
101
109
|
code = "unknown_command"
|
|
102
110
|
elif message.startswith("Session already exists:"):
|
|
103
111
|
code = "session_exists"
|
|
112
|
+
elif message.startswith("Session is disabled:"):
|
|
113
|
+
code = "session_disabled"
|
|
104
114
|
elif "requires an interactive terminal" in message or "requires confirmation" in message:
|
|
105
115
|
code = "interactive_terminal_required"
|
|
106
116
|
return json.dumps({
|
|
@@ -208,8 +218,9 @@ def main(argv, options=None):
|
|
|
208
218
|
"spawn_sync": spawn_sync,
|
|
209
219
|
"stdin_is_tty": stdin_is_tty,
|
|
210
220
|
"version": VERSION,
|
|
221
|
+
"cwd": options.get("cwd") or os.getcwd(),
|
|
211
222
|
"update_notice": _get_update_notice(service, env, options) if command not in (
|
|
212
|
-
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "login", "logout", "export", "import", "help", "version"
|
|
223
|
+
"add", "cp", "ren", "rename", "mv", "rmv", "clean", "doctor", "repair", "update", "notify", "status", "context", "handoff", "login", "logout", "disable", "enable", "export", "import", "help", "version"
|
|
213
224
|
) else None,
|
|
214
225
|
"use_color": use_color,
|
|
215
226
|
}
|
|
@@ -226,6 +237,12 @@ def main(argv, options=None):
|
|
|
226
237
|
if command == "rmv":
|
|
227
238
|
return handle_remove(rest, ctx)
|
|
228
239
|
|
|
240
|
+
if command == "disable":
|
|
241
|
+
return handle_disable(rest, ctx)
|
|
242
|
+
|
|
243
|
+
if command == "enable":
|
|
244
|
+
return handle_enable(rest, ctx)
|
|
245
|
+
|
|
229
246
|
if command == "clean":
|
|
230
247
|
return handle_clean(rest, ctx)
|
|
231
248
|
|
|
@@ -247,6 +264,12 @@ def main(argv, options=None):
|
|
|
247
264
|
if command == "notify":
|
|
248
265
|
return handle_notify(rest, ctx)
|
|
249
266
|
|
|
267
|
+
if command == "context":
|
|
268
|
+
return handle_context(rest, ctx)
|
|
269
|
+
|
|
270
|
+
if command == "handoff":
|
|
271
|
+
return handle_handoff(rest, ctx)
|
|
272
|
+
|
|
250
273
|
if command == "status":
|
|
251
274
|
return handle_status(rest, ctx)
|
|
252
275
|
|
package/src/cli_commands.py
CHANGED
|
@@ -2,10 +2,20 @@ import asyncio
|
|
|
2
2
|
import getpass
|
|
3
3
|
import json
|
|
4
4
|
import os
|
|
5
|
+
import sys
|
|
5
6
|
from datetime import datetime
|
|
6
7
|
|
|
7
8
|
from .claude_refresh import _refresh_claude_sessions
|
|
8
9
|
from .cli_render import _dim, _info, _success, _warn
|
|
10
|
+
from .context_store import (
|
|
11
|
+
clear_context,
|
|
12
|
+
edit_context,
|
|
13
|
+
get_context_path,
|
|
14
|
+
init_context,
|
|
15
|
+
install_context_for_session,
|
|
16
|
+
read_context,
|
|
17
|
+
write_context,
|
|
18
|
+
)
|
|
9
19
|
from .errors import CdxError
|
|
10
20
|
from .health import collect_health_report, format_health_report
|
|
11
21
|
from .notify import (
|
|
@@ -32,6 +42,8 @@ REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
|
32
42
|
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
33
43
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
34
44
|
IMPORT_USAGE = "Usage: cdx import <file> [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR]"
|
|
45
|
+
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
46
|
+
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json]"
|
|
35
47
|
API_SCHEMA_VERSION = 1
|
|
36
48
|
|
|
37
49
|
|
|
@@ -90,6 +102,13 @@ def _parse_remove_args(args):
|
|
|
90
102
|
return {"name": names[0], "force": force}
|
|
91
103
|
|
|
92
104
|
|
|
105
|
+
def _parse_toggle_args(args, usage):
|
|
106
|
+
json_flag, cleaned = _parse_json_flag(args)
|
|
107
|
+
if len(cleaned) != 1:
|
|
108
|
+
raise CdxError(usage)
|
|
109
|
+
return {"name": cleaned[0], "json": json_flag}
|
|
110
|
+
|
|
111
|
+
|
|
93
112
|
def _read_option_value(args, index, usage):
|
|
94
113
|
if index + 1 >= len(args):
|
|
95
114
|
raise CdxError(usage)
|
|
@@ -371,6 +390,28 @@ def handle_remove(rest, ctx):
|
|
|
371
390
|
return 0
|
|
372
391
|
|
|
373
392
|
|
|
393
|
+
def handle_disable(rest, ctx):
|
|
394
|
+
parsed = _parse_toggle_args(rest, "Usage: cdx disable <name> [--json]")
|
|
395
|
+
session = ctx["service"]["set_session_enabled"](parsed["name"], False)
|
|
396
|
+
message = f"Disabled session {parsed['name']}"
|
|
397
|
+
if parsed["json"]:
|
|
398
|
+
_write_json(ctx, _json_success("disable", message, session=session))
|
|
399
|
+
return 0
|
|
400
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
401
|
+
return 0
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def handle_enable(rest, ctx):
|
|
405
|
+
parsed = _parse_toggle_args(rest, "Usage: cdx enable <name> [--json]")
|
|
406
|
+
session = ctx["service"]["set_session_enabled"](parsed["name"], True)
|
|
407
|
+
message = f"Enabled session {parsed['name']}"
|
|
408
|
+
if parsed["json"]:
|
|
409
|
+
_write_json(ctx, _json_success("enable", message, session=session))
|
|
410
|
+
return 0
|
|
411
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
412
|
+
return 0
|
|
413
|
+
|
|
414
|
+
|
|
374
415
|
def handle_clean(rest, ctx):
|
|
375
416
|
json_flag, args = _parse_json_flag(rest)
|
|
376
417
|
service = ctx["service"]
|
|
@@ -507,6 +548,103 @@ def handle_notify(rest, ctx):
|
|
|
507
548
|
return 0
|
|
508
549
|
|
|
509
550
|
|
|
551
|
+
def handle_context(rest, ctx):
|
|
552
|
+
json_flag, args = _parse_json_flag(rest)
|
|
553
|
+
if not args:
|
|
554
|
+
args = ["show"]
|
|
555
|
+
action = args[0]
|
|
556
|
+
base_dir = ctx["service"]["base_dir"]
|
|
557
|
+
cwd = ctx.get("cwd")
|
|
558
|
+
|
|
559
|
+
if action == "show":
|
|
560
|
+
if len(args) != 1:
|
|
561
|
+
raise CdxError(CONTEXT_USAGE)
|
|
562
|
+
content = read_context(base_dir, cwd)
|
|
563
|
+
payload = {
|
|
564
|
+
"path": get_context_path(base_dir, cwd),
|
|
565
|
+
"exists": bool(content.strip()),
|
|
566
|
+
"content": content,
|
|
567
|
+
}
|
|
568
|
+
if json_flag:
|
|
569
|
+
_write_json(ctx, _json_success("context.show", "Loaded shared context", context=payload))
|
|
570
|
+
return 0
|
|
571
|
+
if content.strip():
|
|
572
|
+
ctx["out"](content if content.endswith("\n") else f"{content}\n")
|
|
573
|
+
else:
|
|
574
|
+
ctx["out"](f"{_dim('No shared context for this workspace. Run: cdx context init or cdx context set <text>', ctx['use_color'])}\n")
|
|
575
|
+
return 0
|
|
576
|
+
|
|
577
|
+
if action == "path":
|
|
578
|
+
if len(args) != 1:
|
|
579
|
+
raise CdxError(CONTEXT_USAGE)
|
|
580
|
+
path = get_context_path(base_dir, cwd)
|
|
581
|
+
if json_flag:
|
|
582
|
+
_write_json(ctx, _json_success("context.path", "Resolved shared context path", path=path))
|
|
583
|
+
return 0
|
|
584
|
+
ctx["out"](f"{path}\n")
|
|
585
|
+
return 0
|
|
586
|
+
|
|
587
|
+
if action == "init":
|
|
588
|
+
if len(args) != 1:
|
|
589
|
+
raise CdxError(CONTEXT_USAGE)
|
|
590
|
+
result = init_context(base_dir, cwd)
|
|
591
|
+
message = "Created shared context" if result.get("created") else "Shared context already exists"
|
|
592
|
+
if json_flag:
|
|
593
|
+
_write_json(ctx, _json_success("context.init", message, context=result))
|
|
594
|
+
return 0
|
|
595
|
+
text = f"{message}: {result['path']}"
|
|
596
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
597
|
+
return 0
|
|
598
|
+
|
|
599
|
+
if action == "edit":
|
|
600
|
+
if len(args) != 1:
|
|
601
|
+
raise CdxError(CONTEXT_USAGE)
|
|
602
|
+
result = edit_context(
|
|
603
|
+
base_dir,
|
|
604
|
+
cwd,
|
|
605
|
+
env=ctx.get("env"),
|
|
606
|
+
spawn_sync=ctx.get("spawn_sync"),
|
|
607
|
+
)
|
|
608
|
+
if json_flag:
|
|
609
|
+
_write_json(ctx, _json_success("context.edit", "Edited shared context", context=result))
|
|
610
|
+
return 0
|
|
611
|
+
text = f"Edited shared context: {result['path']}"
|
|
612
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
613
|
+
return 0
|
|
614
|
+
|
|
615
|
+
if action == "clear":
|
|
616
|
+
if len(args) != 1:
|
|
617
|
+
raise CdxError(CONTEXT_USAGE)
|
|
618
|
+
result = clear_context(base_dir, cwd)
|
|
619
|
+
message = "Cleared shared context" if result["removed"] else "No shared context to clear"
|
|
620
|
+
if json_flag:
|
|
621
|
+
_write_json(ctx, _json_success("context.clear", message, context=result))
|
|
622
|
+
return 0
|
|
623
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
624
|
+
return 0
|
|
625
|
+
|
|
626
|
+
if action == "set":
|
|
627
|
+
content_args = args[1:]
|
|
628
|
+
if content_args:
|
|
629
|
+
content = " ".join(content_args)
|
|
630
|
+
else:
|
|
631
|
+
stdin = ctx["options"].get("stdin_data")
|
|
632
|
+
if stdin is None and not ctx.get("stdin_is_tty"):
|
|
633
|
+
stdin = sys.stdin.read()
|
|
634
|
+
if stdin is None:
|
|
635
|
+
raise CdxError("Usage: cdx context set <text> [--json]")
|
|
636
|
+
content = stdin
|
|
637
|
+
result = write_context(base_dir, content, cwd)
|
|
638
|
+
if json_flag:
|
|
639
|
+
_write_json(ctx, _json_success("context.set", "Saved shared context", context=result))
|
|
640
|
+
return 0
|
|
641
|
+
text = f"Saved shared context: {result['path']}"
|
|
642
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
643
|
+
return 0
|
|
644
|
+
|
|
645
|
+
raise CdxError(CONTEXT_USAGE)
|
|
646
|
+
|
|
647
|
+
|
|
510
648
|
def handle_status(rest, ctx):
|
|
511
649
|
json_flag = "--json" in rest
|
|
512
650
|
small_flag = "--small" in rest or "-s" in rest
|
|
@@ -816,3 +954,26 @@ def handle_launch(command, ctx):
|
|
|
816
954
|
if json_flag:
|
|
817
955
|
_write_json(ctx, _json_success("launch", message, warnings=warnings, session=ctx["service"]["get_session"](session["name"])))
|
|
818
956
|
return 0
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
def handle_handoff(rest, ctx):
|
|
960
|
+
json_flag, args = _parse_json_flag(rest)
|
|
961
|
+
if len(args) != 1:
|
|
962
|
+
raise CdxError(HANDOFF_USAGE)
|
|
963
|
+
name = args[0]
|
|
964
|
+
session = ctx["service"]["get_session"](name)
|
|
965
|
+
if not session:
|
|
966
|
+
raise CdxError(f"Unknown session: {name}")
|
|
967
|
+
install = install_context_for_session(ctx["service"]["base_dir"], session, ctx.get("cwd"))
|
|
968
|
+
if json_flag:
|
|
969
|
+
_write_json(ctx, _json_success(
|
|
970
|
+
"handoff",
|
|
971
|
+
f"Installed shared context for {name}",
|
|
972
|
+
context=install,
|
|
973
|
+
session=session,
|
|
974
|
+
))
|
|
975
|
+
return 0
|
|
976
|
+
text = f"Shared context installed for {name}: {install['target_path']}"
|
|
977
|
+
ctx["out"](f"{_info(text, ctx['use_color'])}\n")
|
|
978
|
+
ctx["out"](f"{_dim('Ask the assistant to read shared-context.md if it does not pick it up automatically.', ctx['use_color'])}\n")
|
|
979
|
+
return handle_launch(name, ctx)
|
package/src/cli_render.py
CHANGED
|
@@ -108,6 +108,7 @@ def _format_sessions(service, use_color=False):
|
|
|
108
108
|
headers = ["SESSION"]
|
|
109
109
|
if has_provider:
|
|
110
110
|
headers.append("PROVIDER")
|
|
111
|
+
headers.append("STATUS")
|
|
111
112
|
headers.append("UPDATED")
|
|
112
113
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
113
114
|
table_rows = []
|
|
@@ -115,6 +116,8 @@ def _format_sessions(service, use_color=False):
|
|
|
115
116
|
parts = [r["name"]]
|
|
116
117
|
if has_provider:
|
|
117
118
|
parts.append(r.get("provider") or "n/a")
|
|
119
|
+
status = r.get("enabled_status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
120
|
+
parts.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
118
121
|
parts.append(_dim(_format_relative_age(r.get("updated_at")), use_color))
|
|
119
122
|
table_rows.append(parts)
|
|
120
123
|
lines = [_style("Known sessions:", "1", use_color), _pad_table([headers] + table_rows), ""]
|
|
@@ -124,6 +127,10 @@ def _format_sessions(service, use_color=False):
|
|
|
124
127
|
f" {_style('cdx <name>', '36', use_color)}",
|
|
125
128
|
f" {_style('cdx login <name>', '36', use_color)}",
|
|
126
129
|
f" {_style('cdx logout <name>', '36', use_color)}",
|
|
130
|
+
f" {_style('cdx context show', '36', use_color)}",
|
|
131
|
+
f" {_style('cdx handoff <name>', '36', use_color)}",
|
|
132
|
+
f" {_style('cdx disable <name>', '36', use_color)}",
|
|
133
|
+
f" {_style('cdx enable <name>', '36', use_color)}",
|
|
127
134
|
f" {_style('cdx ren <source> <dest>', '36', use_color)}",
|
|
128
135
|
f" {_style('cdx rmv <name>', '36', use_color)}",
|
|
129
136
|
f" {_style('cdx status', '36', use_color)}",
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import hashlib
|
|
2
|
+
import os
|
|
3
|
+
import shlex
|
|
4
|
+
import subprocess
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from .errors import CdxError
|
|
8
|
+
from .session_store import _ensure_dir
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
DEFAULT_CONTEXT_TEMPLATE = """# Shared Context
|
|
12
|
+
|
|
13
|
+
## Goal
|
|
14
|
+
|
|
15
|
+
## Current State
|
|
16
|
+
|
|
17
|
+
## Decisions
|
|
18
|
+
|
|
19
|
+
## Files Changed
|
|
20
|
+
|
|
21
|
+
## Open Questions
|
|
22
|
+
|
|
23
|
+
## Next Steps
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _local_now_iso():
|
|
28
|
+
return datetime.now().astimezone().isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _workspace_key(cwd):
|
|
32
|
+
normalized = os.path.realpath(cwd or os.getcwd())
|
|
33
|
+
digest = hashlib.sha256(normalized.encode("utf-8")).hexdigest()[:16]
|
|
34
|
+
return digest
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _context_dir(base_dir, cwd):
|
|
38
|
+
return os.path.join(base_dir, "contexts", _workspace_key(cwd))
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_context_path(base_dir, cwd=None):
|
|
42
|
+
return os.path.join(_context_dir(base_dir, cwd), "context.md")
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_context_meta_path(base_dir, cwd=None):
|
|
46
|
+
return os.path.join(_context_dir(base_dir, cwd), "meta.json")
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def read_context(base_dir, cwd=None):
|
|
50
|
+
path = get_context_path(base_dir, cwd)
|
|
51
|
+
try:
|
|
52
|
+
with open(path, "r", encoding="utf-8") as handle:
|
|
53
|
+
return handle.read()
|
|
54
|
+
except FileNotFoundError:
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def write_context(base_dir, content, cwd=None):
|
|
59
|
+
path = get_context_path(base_dir, cwd)
|
|
60
|
+
_ensure_dir(os.path.dirname(path))
|
|
61
|
+
with open(path, "w", encoding="utf-8") as handle:
|
|
62
|
+
handle.write(content.rstrip() + "\n")
|
|
63
|
+
return {
|
|
64
|
+
"path": path,
|
|
65
|
+
"updated_at": _local_now_iso(),
|
|
66
|
+
"bytes": os.path.getsize(path),
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def init_context(base_dir, cwd=None):
|
|
71
|
+
current = read_context(base_dir, cwd)
|
|
72
|
+
if current.strip():
|
|
73
|
+
return {
|
|
74
|
+
"path": get_context_path(base_dir, cwd),
|
|
75
|
+
"created": False,
|
|
76
|
+
"bytes": len(current.encode("utf-8")),
|
|
77
|
+
}
|
|
78
|
+
result = write_context(base_dir, DEFAULT_CONTEXT_TEMPLATE, cwd)
|
|
79
|
+
return {**result, "created": True}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def clear_context(base_dir, cwd=None):
|
|
83
|
+
path = get_context_path(base_dir, cwd)
|
|
84
|
+
try:
|
|
85
|
+
os.remove(path)
|
|
86
|
+
removed = True
|
|
87
|
+
except FileNotFoundError:
|
|
88
|
+
removed = False
|
|
89
|
+
return {"path": path, "removed": removed}
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
|
|
93
|
+
result = init_context(base_dir, cwd)
|
|
94
|
+
path = result["path"]
|
|
95
|
+
env = env or os.environ
|
|
96
|
+
editor = editor or env.get("VISUAL") or env.get("EDITOR")
|
|
97
|
+
if not editor:
|
|
98
|
+
raise CdxError("Context edit requires VISUAL or EDITOR.")
|
|
99
|
+
spawn_sync = spawn_sync or subprocess.run
|
|
100
|
+
argv = shlex.split(editor) + [path]
|
|
101
|
+
if spawn_sync is subprocess.run:
|
|
102
|
+
completed = subprocess.run(argv)
|
|
103
|
+
returncode = completed.returncode
|
|
104
|
+
else:
|
|
105
|
+
completed = spawn_sync(argv[0], argv[1:], {"env": env})
|
|
106
|
+
returncode = completed.get("returncode", 0) if isinstance(completed, dict) else getattr(completed, "returncode", 0)
|
|
107
|
+
if returncode not in (0, None):
|
|
108
|
+
raise CdxError(f"Context editor exited with code {returncode}")
|
|
109
|
+
return {"path": path, "edited": True}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def install_context_for_session(base_dir, session, cwd=None):
|
|
113
|
+
content = read_context(base_dir, cwd)
|
|
114
|
+
if not content.strip():
|
|
115
|
+
raise CdxError("No shared context for this workspace. Run: cdx context init or cdx context set <text>")
|
|
116
|
+
auth_home = session.get("authHome") or session.get("sessionRoot")
|
|
117
|
+
if not auth_home:
|
|
118
|
+
raise CdxError(f"Session auth home missing for {session['name']}")
|
|
119
|
+
target_path = os.path.join(auth_home, "shared-context.md")
|
|
120
|
+
_ensure_dir(os.path.dirname(target_path))
|
|
121
|
+
with open(target_path, "w", encoding="utf-8") as handle:
|
|
122
|
+
handle.write(content.rstrip() + "\n")
|
|
123
|
+
return {
|
|
124
|
+
"source_path": get_context_path(base_dir, cwd),
|
|
125
|
+
"target_path": target_path,
|
|
126
|
+
"bytes": os.path.getsize(target_path),
|
|
127
|
+
}
|
package/src/session_service.py
CHANGED
|
@@ -19,10 +19,14 @@ MAX_SESSION_NAME_LENGTH = 64
|
|
|
19
19
|
RESERVED_SESSION_NAMES = {
|
|
20
20
|
"add",
|
|
21
21
|
"clean",
|
|
22
|
+
"context",
|
|
22
23
|
"cp",
|
|
24
|
+
"disable",
|
|
23
25
|
"doctor",
|
|
26
|
+
"enable",
|
|
24
27
|
"export",
|
|
25
28
|
"help",
|
|
29
|
+
"handoff",
|
|
26
30
|
"import",
|
|
27
31
|
"login",
|
|
28
32
|
"logout",
|
|
@@ -273,6 +277,7 @@ def create_session_service(options=None):
|
|
|
273
277
|
return {
|
|
274
278
|
"name": session["name"],
|
|
275
279
|
"provider": session["provider"],
|
|
280
|
+
"enabled": session.get("enabled", True) is not False,
|
|
276
281
|
"createdAt": session.get("createdAt"),
|
|
277
282
|
"updatedAt": session.get("updatedAt"),
|
|
278
283
|
"lastLaunchedAt": session.get("lastLaunchedAt"),
|
|
@@ -325,6 +330,7 @@ def create_session_service(options=None):
|
|
|
325
330
|
session = {
|
|
326
331
|
"name": name,
|
|
327
332
|
"provider": normalized_provider,
|
|
333
|
+
"enabled": True,
|
|
328
334
|
"sessionRoot": session_root,
|
|
329
335
|
"authHome": auth_home,
|
|
330
336
|
"createdAt": now,
|
|
@@ -399,6 +405,7 @@ def create_session_service(options=None):
|
|
|
399
405
|
replacement = {
|
|
400
406
|
"name": dest_name,
|
|
401
407
|
"provider": source["provider"],
|
|
408
|
+
"enabled": True,
|
|
402
409
|
"sessionRoot": dest_root,
|
|
403
410
|
"authHome": dest_auth_home,
|
|
404
411
|
"createdAt": now,
|
|
@@ -485,6 +492,8 @@ def create_session_service(options=None):
|
|
|
485
492
|
session = store["get_session"](name)
|
|
486
493
|
if not session:
|
|
487
494
|
raise CdxError(f"Unknown session: {name}")
|
|
495
|
+
if session.get("enabled", True) is False:
|
|
496
|
+
raise CdxError(f"Session is disabled: {name}")
|
|
488
497
|
state = store["read_session_state"](name)
|
|
489
498
|
if not state:
|
|
490
499
|
raise CdxError(f"Session state missing for {name}. Reconnect required.")
|
|
@@ -515,6 +524,17 @@ def create_session_service(options=None):
|
|
|
515
524
|
def get_session(name):
|
|
516
525
|
return store["get_session"](name)
|
|
517
526
|
|
|
527
|
+
def set_session_enabled(name, enabled):
|
|
528
|
+
session = store["get_session"](name)
|
|
529
|
+
if not session:
|
|
530
|
+
raise CdxError(f"Unknown session: {name}")
|
|
531
|
+
now = _local_now_iso()
|
|
532
|
+
return store["update_session"](name, lambda s: {
|
|
533
|
+
**s,
|
|
534
|
+
"enabled": bool(enabled),
|
|
535
|
+
"updatedAt": now,
|
|
536
|
+
})
|
|
537
|
+
|
|
518
538
|
def record_status(name, payload):
|
|
519
539
|
normalized = _normalize_status_payload(payload)
|
|
520
540
|
updated = store["update_session"](name, lambda s: {
|
|
@@ -605,10 +625,15 @@ def create_session_service(options=None):
|
|
|
605
625
|
|
|
606
626
|
def sort_key(s):
|
|
607
627
|
at = s.get("lastStatusAt") or ""
|
|
608
|
-
|
|
628
|
+
disabled_rank = 1 if s.get("enabled", True) is False else 0
|
|
629
|
+
return (disabled_rank, "" if at else "\xff", at, s["name"])
|
|
609
630
|
|
|
610
631
|
resolved.sort(key=sort_key)
|
|
611
|
-
resolved.
|
|
632
|
+
enabled = [s for s in resolved if s.get("enabled", True) is not False]
|
|
633
|
+
disabled = [s for s in resolved if s.get("enabled", True) is False]
|
|
634
|
+
enabled.reverse()
|
|
635
|
+
disabled.sort(key=lambda s: s["name"])
|
|
636
|
+
resolved = enabled + disabled
|
|
612
637
|
|
|
613
638
|
rows = []
|
|
614
639
|
for s in resolved:
|
|
@@ -616,6 +641,8 @@ def create_session_service(options=None):
|
|
|
616
641
|
rows.append({
|
|
617
642
|
"session_name": s["name"],
|
|
618
643
|
"provider": s["provider"],
|
|
644
|
+
"enabled": s.get("enabled", True) is not False,
|
|
645
|
+
"status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
619
646
|
"remaining_5h_pct": status.get("remaining_5h_pct") if status else None,
|
|
620
647
|
"remaining_week_pct": status.get("remaining_week_pct") if status else None,
|
|
621
648
|
"credits": status.get("credits") if status else None,
|
|
@@ -631,9 +658,18 @@ def create_session_service(options=None):
|
|
|
631
658
|
sessions = list_sessions()
|
|
632
659
|
providers = {s["provider"] for s in sessions}
|
|
633
660
|
has_multiple = len(providers) > 1
|
|
661
|
+
sessions = sorted(
|
|
662
|
+
sessions,
|
|
663
|
+
key=lambda s: (
|
|
664
|
+
1 if s.get("enabled", True) is False else 0,
|
|
665
|
+
s.get("name", ""),
|
|
666
|
+
),
|
|
667
|
+
)
|
|
634
668
|
return [{
|
|
635
669
|
"name": s["name"],
|
|
636
670
|
"provider": s["provider"] if has_multiple else None,
|
|
671
|
+
"enabled": s.get("enabled", True) is not False,
|
|
672
|
+
"enabled_status": "disabled" if s.get("enabled", True) is False else "enabled",
|
|
637
673
|
"status": s.get("lastStatus"),
|
|
638
674
|
"updated_at": _to_local_iso(s.get("updatedAt")),
|
|
639
675
|
} for s in sessions]
|
|
@@ -723,6 +759,7 @@ def create_session_service(options=None):
|
|
|
723
759
|
session_record = {
|
|
724
760
|
**session_payload,
|
|
725
761
|
"provider": provider,
|
|
762
|
+
"enabled": session_payload.get("enabled", True) is not False,
|
|
726
763
|
"sessionRoot": session_root,
|
|
727
764
|
"authHome": auth_home,
|
|
728
765
|
}
|
|
@@ -763,6 +800,7 @@ def create_session_service(options=None):
|
|
|
763
800
|
"ensure_session_state": ensure_session_state,
|
|
764
801
|
"list_sessions": list_sessions,
|
|
765
802
|
"get_session": get_session,
|
|
803
|
+
"set_session_enabled": set_session_enabled,
|
|
766
804
|
"record_status": record_status,
|
|
767
805
|
"update_auth_state": update_auth_state,
|
|
768
806
|
"get_status_rows": get_status_rows,
|
package/src/status_view.py
CHANGED
|
@@ -55,22 +55,29 @@ def _style_reset_time(value, use_color=False):
|
|
|
55
55
|
def _format_status_rows(rows, use_color=False, small=False):
|
|
56
56
|
has_provider = len({r["provider"] for r in rows}) > 1 and not small
|
|
57
57
|
if small:
|
|
58
|
-
headers = ["SESSION", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
|
|
58
|
+
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "RESET 5H", "RESET WEEK"]
|
|
59
59
|
elif has_provider:
|
|
60
|
-
headers = ["SESSION", "PROV.", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
60
|
+
headers = ["SESSION", "PROV.", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
61
61
|
else:
|
|
62
|
-
headers = ["SESSION", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
62
|
+
headers = ["SESSION", "STATUS", "OK", "5H", "WEEK", "BLOCK", "CR", "RESET 5H", "RESET WEEK", "UPDATED"]
|
|
63
63
|
if not rows:
|
|
64
64
|
if small:
|
|
65
|
-
return "SESSION OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
66
|
-
return "SESSION OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
65
|
+
return "SESSION STATUS OK 5H WEEK RESET 5H RESET WEEK\nNo saved sessions yet."
|
|
66
|
+
return "SESSION STATUS OK 5H WEEK BLOCK CR RESET 5H RESET WEEK UPDATED\nNo saved sessions yet."
|
|
67
67
|
headers = [_style(header, "1", use_color) for header in headers]
|
|
68
|
-
|
|
68
|
+
active_rows = [r for r in rows if r.get("enabled", True) is not False]
|
|
69
|
+
disabled_rows = sorted(
|
|
70
|
+
[r for r in rows if r.get("enabled", True) is False],
|
|
71
|
+
key=lambda r: r.get("session_name") or "",
|
|
72
|
+
)
|
|
73
|
+
priority = _recommend_priority_sessions(active_rows)
|
|
69
74
|
table_rows = []
|
|
70
|
-
for r in priority:
|
|
75
|
+
for r in priority + disabled_rows:
|
|
71
76
|
base = [r["session_name"]]
|
|
72
77
|
if has_provider:
|
|
73
78
|
base.append(r.get("provider") or "n/a")
|
|
79
|
+
status = r.get("status") or ("enabled" if r.get("enabled", True) else "disabled")
|
|
80
|
+
base.append(_style(status, "2" if status == "disabled" else "32", use_color))
|
|
74
81
|
usage_columns = [
|
|
75
82
|
_style_pct(r.get("available_pct"), use_color),
|
|
76
83
|
_style_pct(r.get("remaining_5h_pct"), use_color),
|
|
@@ -258,6 +265,7 @@ def _format_status_detail(row, use_color=False):
|
|
|
258
265
|
lines = [
|
|
259
266
|
f"{_style('Session:', '1', use_color)} {row['session_name']}",
|
|
260
267
|
f"{_style('Provider:', '1', use_color)} {row.get('provider') or 'n/a'}",
|
|
268
|
+
f"{_style('Status:', '1', use_color)} {_style(row.get('status') or ('enabled' if row.get('enabled', True) else 'disabled'), '2' if row.get('enabled', True) is False else '32', use_color)}",
|
|
261
269
|
f"{_style('Available:', '1', use_color)} {_style_pct(row.get('available_pct'), use_color)}",
|
|
262
270
|
f"{_style('5h left:', '1', use_color)} {_style_pct(row.get('remaining_5h_pct'), use_color)}",
|
|
263
271
|
f"{_style('Week left:', '1', use_color)} {_style_pct(row.get('remaining_week_pct'), use_color)}",
|