cdx-manager 0.11.2 → 0.12.1
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 +15 -3
- package/changelogs/CHANGELOGS_0_12_0.md +42 -0
- package/changelogs/CHANGELOGS_0_12_1.md +25 -0
- package/checksums/release-archives.json +12 -0
- package/package.json +1 -1
- package/pyproject.toml +1 -1
- package/src/cli.py +6 -3
- package/src/cli_args.py +10 -4
- package/src/cli_commands.py +298 -0
- package/src/cli_helpers.py +9 -4
- package/src/context_store.py +66 -7
- package/src/health.py +64 -0
- package/src/session_service.py +1 -0
- package/src/update_all.py +184 -0
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# CDX Manager
|
|
2
2
|
|
|
3
|
-
[](LICENSE) ](LICENSE)  
|
|
4
4
|
|
|
5
5
|
**Run multiple Codex, Claude, Antigravity, and Ollama sessions from one terminal. Switch between accounts instantly.**
|
|
6
6
|
|
|
@@ -361,7 +361,8 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
361
361
|
| `cdx last [--json]` | Launch the most recent existing session from launch history |
|
|
362
362
|
| `cdx resume <name> [--json]` | Resume the provider-native conversation for a session using the named command form |
|
|
363
363
|
| `cdx can-resume <name> [--json]` | Check whether a session supports native resume without launching the provider |
|
|
364
|
-
| `cdx context show\|path\|init\|edit\|clear\|set [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
364
|
+
| `cdx context show\|path\|init\|edit\|clear\|set\|append [text...] [--json]` | Manage the shared Markdown context for the current workspace |
|
|
365
|
+
| `cdx memory [--global\|--project NAME_OR_PATH] [show\|view\|path\|init\|edit\|clear\|set\|append\|list] [text...] [--json]` | Manage explicit user-controlled memory for the current workspace, global scope, or a named/path project |
|
|
365
366
|
| `cdx handoff <name> [--json]` | Install the current workspace context into a target session and launch it unless `--json` is used |
|
|
366
367
|
| `cdx handoff <source> <target> [--json]` | Build shared context from the source session's latest launch transcript, install it into the target session, and launch the target unless `--json` is used; supports cross-provider handoff |
|
|
367
368
|
| `cdx rmv <name> [--force] [--json]` | Remove a session and its auth data (prompts for confirmation unless `--force`) |
|
|
@@ -371,10 +372,11 @@ cdx history --summary --from 2026-05-01 --to 2026-05-28
|
|
|
371
372
|
| `cdx reset <name> [--yes] [--json]` | Explicitly consume one available banked Codex rate-limit reset; confirmation is required unless `--yes` is supplied |
|
|
372
373
|
| `cdx export <file> [--include-auth] [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Export sessions to a portable bundle; `--include-auth` encrypts auth data with a passphrase |
|
|
373
374
|
| `cdx import <file> [--sessions a,b] [--passphrase-env VAR\|--passphrase-stdin] [--force] [--json]` | Import sessions from a bundle into the current `CDX_HOME` |
|
|
374
|
-
| `cdx doctor [--json]` | Inspect CLI dependencies, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
375
|
+
| `cdx doctor [--json]` | Inspect CLI dependencies, provider CLI versions/capability hints, CDX_HOME permissions, missing state, orphan profiles, and pending quarantines |
|
|
375
376
|
| `cdx repair [--dry-run] [--force] [--json]` | Plan or apply safe repairs for missing state files, quarantines, and orphan profiles |
|
|
376
377
|
| `cdx view [--json] [--lan] [--lan-rw] [--focus <ref>] [--read] [--port <port>] [--host <host>] [--refresh-interval <s>] [--tls] [--tls-cert <path>] [--tls-key <path>] [--open] [--no-open]` | Open the Logics browser/focus viewer by delegating to `logics-manager view`; all viewer flags are forwarded; JSON mode reports diagnostics without launching it |
|
|
377
378
|
| `cdx update [--check] [--yes] [--json] [--version TAG]` | Update cdx-manager using the installer that matches how it was installed |
|
|
379
|
+
| `cdx update all [--yes] [--json]` | Check installed providers, RTK, and Ponytail across Codex profiles; show the plan and ask once before applying safe native/Homebrew updates and missing RTK/Ponytail setup |
|
|
378
380
|
| `cdx ready [--refresh] [--json]` | Schedule an OS notification for the next cooling-down assistant that becomes ready, then return immediately |
|
|
379
381
|
| `cdx notify <name> --at-reset [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait for a session reset time or schedule an OS wake-up notification when due |
|
|
380
382
|
| `cdx notify --next-ready [--poll seconds] [--once] [--schedule] [--refresh] [--json]` | Wait until the recommended session is usable, or schedule the next known reset notification |
|
|
@@ -414,6 +416,7 @@ Commands with machine-readable output:
|
|
|
414
416
|
- `cdx disable ... --json`
|
|
415
417
|
- `cdx enable ... --json`
|
|
416
418
|
- `cdx context ... --json`
|
|
419
|
+
- `cdx memory ... --json`
|
|
417
420
|
- `cdx handoff ... --json`
|
|
418
421
|
- `cdx history ... --json`
|
|
419
422
|
- `cdx stats ... --json`
|
|
@@ -558,6 +561,7 @@ Notes:
|
|
|
558
561
|
- if a compatible `script` command is available and exposed via `CDX_SCRIPT_BIN`, `cdx` uses it
|
|
559
562
|
- otherwise Codex launches without transcript capture and the session still works normally
|
|
560
563
|
- `cdx doctor` reports the transcript-capture fallback explicitly so missing `script` on Windows is visible without being treated as a hard failure.
|
|
564
|
+
- `cdx doctor --json` includes detected `codex --version` and `claude --version` output when available, plus conservative capability hints for recent provider memory, model, and headless diagnostic surfaces. These hints are informational; `cdx` does not edit provider-private memory stores.
|
|
561
565
|
|
|
562
566
|
---
|
|
563
567
|
|
|
@@ -605,6 +609,10 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
605
609
|
state/
|
|
606
610
|
<encoded-name>.json # Per-session rehydration state
|
|
607
611
|
launch_history.jsonl # Append-only launch history
|
|
612
|
+
contexts/
|
|
613
|
+
<workspace-hash>/context.md # Current-workspace shared context / memory
|
|
614
|
+
global/context.md # Global cdx memory
|
|
615
|
+
projects/<encoded-name>/context.md # Named project memory from cdx memory --project NAME
|
|
608
616
|
profiles/
|
|
609
617
|
<encoded-name>/ # Codex session: CODEX_HOME points here
|
|
610
618
|
log/
|
|
@@ -617,6 +625,10 @@ All session data lives under `CDX_HOME` (default: `~/.cdx/`):
|
|
|
617
625
|
|
|
618
626
|
Session names are URL-encoded when used as directory or file names. CLI command names such as `add`, `disk`, `status`, and `login` are reserved and cannot be used as new session names.
|
|
619
627
|
|
|
628
|
+
`cdx memory` is the user-facing interface for explicit memory. `--project NAME` uses a stable free-form project name; `--project /path/to/repo` resolves to the same workspace-hash memory that repo would use as the current workspace. `append` adds the text as-is with a clean newline boundary; it does not add dates or headings. `list` reports global memory, named project memories, and the current workspace memory when present; it does not scan your filesystem for repos.
|
|
629
|
+
|
|
630
|
+
Provider-private memory files, such as Codex SQLite memory databases or Claude Code project memory files, are not edited by `cdx memory`.
|
|
631
|
+
|
|
620
632
|
### Disk maintenance
|
|
621
633
|
|
|
622
634
|
Use `cdx disk` for a total `CDX_HOME` measurement, and `cdx disk profiles` for the `profiles/` total plus a per-profile breakdown.
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# CDX Manager 0.12.0
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Added explicit `cdx memory` management for current workspace, global, and project-scoped memory.
|
|
6
|
+
- Added append/list workflows for user-controlled Markdown memory without touching provider-private memory stores.
|
|
7
|
+
- Improved `cdx doctor` provider visibility with Codex and Claude CLI versions plus conservative capability hints.
|
|
8
|
+
|
|
9
|
+
## Changes
|
|
10
|
+
|
|
11
|
+
### Scoped memory
|
|
12
|
+
|
|
13
|
+
`cdx memory` now manages explicit local memory using the existing shared context store:
|
|
14
|
+
|
|
15
|
+
- `cdx memory append <text>` appends to the current workspace memory.
|
|
16
|
+
- `cdx memory --global append <text>` appends to a global operator memory.
|
|
17
|
+
- `cdx memory --project <name> append <text>` appends to a stable named project memory.
|
|
18
|
+
- `cdx memory --project <path> ...` resolves to the same workspace-hash memory used by that repo path.
|
|
19
|
+
- `cdx memory list [--json]` lists global memory, named project memories, and the current workspace memory when present.
|
|
20
|
+
|
|
21
|
+
Append is intentionally plain text with a clean newline boundary; it does not add dates or headings automatically.
|
|
22
|
+
|
|
23
|
+
### Context compatibility
|
|
24
|
+
|
|
25
|
+
`cdx context append <text>` was added for the existing workspace context command. Existing `cdx context` and `cdx handoff` behavior remains compatible.
|
|
26
|
+
|
|
27
|
+
### Doctor provider versions
|
|
28
|
+
|
|
29
|
+
`cdx doctor` now records provider CLI versions when available:
|
|
30
|
+
|
|
31
|
+
- `codex --version`
|
|
32
|
+
- `claude --version`
|
|
33
|
+
|
|
34
|
+
JSON output includes conservative capability hints for recent provider memory and diagnostic surfaces. These hints are informational; `cdx` still does not read or write Codex SQLite memory, Claude Code project memory, `CLAUDE.md`, or provider plugin files.
|
|
35
|
+
|
|
36
|
+
## Validation
|
|
37
|
+
|
|
38
|
+
- `PYTHONPATH=src python3 -m pytest -q`
|
|
39
|
+
- `python3 -m ruff check src test`
|
|
40
|
+
- `logics-manager flow validate req_013_add_a_small_workspace_memory_command_to_cdx`
|
|
41
|
+
- `logics-manager lint --require-status`
|
|
42
|
+
- `logics-manager audit --group-by-doc`
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# CDX Manager 0.12.1
|
|
2
|
+
|
|
3
|
+
## Highlights
|
|
4
|
+
|
|
5
|
+
- Added `cdx update all` to inventory and safely update the installed provider CLIs, RTK, and Ponytail across Codex profiles.
|
|
6
|
+
- Made account handoffs use native provider transcripts before terminal capture, preserving actionable conversation instead of terminal noise.
|
|
7
|
+
|
|
8
|
+
## Changes
|
|
9
|
+
|
|
10
|
+
### Workstation updates
|
|
11
|
+
|
|
12
|
+
`cdx update all` now reports installed versions and session setup, asks once before applying its plan, and shows progress plus the command and error output when an action fails. It follows the actual installer provenance of each CLI, including the Claude Code cask rather than the separate Claude desktop app.
|
|
13
|
+
|
|
14
|
+
RTK is enabled for sessions that lack it when available. Ponytail marketplaces and plugins are checked per Codex profile; current Codex marketplace caches without a `last_revision` field are resolved from their Git checkout.
|
|
15
|
+
|
|
16
|
+
### Reliable handoffs
|
|
17
|
+
|
|
18
|
+
Cross-session handoffs now prefer native JSONL transcripts and retain only user and assistant messages. Script-captured terminal logs remain a fallback, but no longer displace structured provider conversation or tool-output noise into the shared context.
|
|
19
|
+
|
|
20
|
+
## Validation
|
|
21
|
+
|
|
22
|
+
- `npm run lint`
|
|
23
|
+
- `npm test`
|
|
24
|
+
- `logics-manager lint --require-status`
|
|
25
|
+
- `logics-manager audit --group-by-doc`
|
|
@@ -144,6 +144,18 @@
|
|
|
144
144
|
"v0.11.1": {
|
|
145
145
|
"github_tarball_sha256": "33ee5a76cd217007468ea620fa725644dfd62179585551b5703129c00a5094d5",
|
|
146
146
|
"github_zip_sha256": "8b53832ad2af109c22a898782bd36e1408356fc2bd0c3f99ca2ffb1262e2325b"
|
|
147
|
+
},
|
|
148
|
+
"v0.11.2": {
|
|
149
|
+
"github_tarball_sha256": "513d7c586a662da5ac5daf792fc76f746e924bbd95157a9d0cbbeba34f2991e0",
|
|
150
|
+
"github_zip_sha256": "d6d633efcbdc28818f68bd87efe3f246fe2983a9f4aa24d03868c978645c593e"
|
|
151
|
+
},
|
|
152
|
+
"v0.12.0": {
|
|
153
|
+
"github_tarball_sha256": "de1bac90e790b5d557f90fd22f7dd317c45646d0e07320c05c8b2336ed9bbced",
|
|
154
|
+
"github_zip_sha256": "213f6217dfd8c7aa979af691c2d63598b4589ca40f36e816f4ae9c9f2da27073"
|
|
155
|
+
},
|
|
156
|
+
"v0.12.1": {
|
|
157
|
+
"github_tarball_sha256": "87e3b11886a8e7fb10233d425f6a1986e62395c89049947678a50cfb20384278",
|
|
158
|
+
"github_zip_sha256": "249f75eb240de7cc421f17579e0040d35eb06e3213568673f743c92191dda0f0"
|
|
147
159
|
}
|
|
148
160
|
}
|
|
149
161
|
}
|
package/package.json
CHANGED
package/pyproject.toml
CHANGED
package/src/cli.py
CHANGED
|
@@ -32,6 +32,7 @@ from .cli_commands import (
|
|
|
32
32
|
handle_launch_setting_alias,
|
|
33
33
|
handle_login,
|
|
34
34
|
handle_logout,
|
|
35
|
+
handle_memory,
|
|
35
36
|
handle_next,
|
|
36
37
|
handle_notify,
|
|
37
38
|
handle_remove,
|
|
@@ -77,7 +78,7 @@ from .status_view import (
|
|
|
77
78
|
)
|
|
78
79
|
from .update_check import check_for_update, check_logics_manager_for_update
|
|
79
80
|
|
|
80
|
-
VERSION = "0.
|
|
81
|
+
VERSION = "0.12.1"
|
|
81
82
|
|
|
82
83
|
# Public surface: this module is a facade. Names below are imported above
|
|
83
84
|
# purely to be re-exported (consumed by tests and external callers); listing
|
|
@@ -117,6 +118,7 @@ _COMMAND_HANDLERS = {
|
|
|
117
118
|
"last": handle_last,
|
|
118
119
|
"login": handle_login,
|
|
119
120
|
"logout": handle_logout,
|
|
121
|
+
"memory": handle_memory,
|
|
120
122
|
"next": handle_next,
|
|
121
123
|
"notify": handle_notify,
|
|
122
124
|
"repair": handle_repair,
|
|
@@ -185,7 +187,8 @@ def _print_help(use_color=False):
|
|
|
185
187
|
f" {_style('cdx runs [--limit N] --json', '36', use_color)}",
|
|
186
188
|
f" {_style('cdx run-status <run_id> --json', '36', use_color)}",
|
|
187
189
|
f" {_style('cdx run-report <run_id> --json', '36', use_color)}",
|
|
188
|
-
f" {_style('cdx context show|path|init|edit|clear|set [text...] [--json]', '36', use_color)}",
|
|
190
|
+
f" {_style('cdx context show|path|init|edit|clear|set|append [text...] [--json]', '36', use_color)}",
|
|
191
|
+
f" {_style('cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]', '36', use_color)}",
|
|
189
192
|
f" {_style('cdx disk [profiles] [--candidates] [--json]', '36', use_color)}",
|
|
190
193
|
f" {_style('cdx config <name> [--json]', '36', use_color)}",
|
|
191
194
|
f" {_style('cdx configs [--json]', '36', use_color)}",
|
|
@@ -216,7 +219,7 @@ def _print_help(use_color=False):
|
|
|
216
219
|
f" {_style('cdx doctor [--json]', '36', use_color)}",
|
|
217
220
|
f" {_style('cdx repair [--dry-run] [--force] [--json]', '36', use_color)}",
|
|
218
221
|
f" {_style('cdx view [--json]', '36', use_color)}",
|
|
219
|
-
f" {_style('cdx update [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
222
|
+
f" {_style('cdx update [all] [--check] [--yes] [--json] [--version TAG]', '36', use_color)}",
|
|
220
223
|
f" {_style('cdx ready [--refresh] [--json]', '36', use_color)}",
|
|
221
224
|
f" {_style('cdx notify <name> --at-reset [--schedule] [--refresh] [--json]', '36', use_color)}",
|
|
222
225
|
f" {_style('cdx notify --next-ready [--schedule] [--refresh] [--json]', '36', use_color)}",
|
package/src/cli_args.py
CHANGED
|
@@ -15,10 +15,11 @@ STATUS_USAGE = "Usage: cdx status [--json] [--refresh|--cached] [--timeout SECON
|
|
|
15
15
|
DOCTOR_USAGE = "Usage: cdx doctor [--json]"
|
|
16
16
|
DISK_USAGE = "Usage: cdx disk [profiles] [--candidates] [--json]"
|
|
17
17
|
REPAIR_USAGE = "Usage: cdx repair [--dry-run] [--force] [--json]"
|
|
18
|
-
UPDATE_USAGE = "Usage: cdx update [--check] [--yes] [--json] [--version TAG]"
|
|
18
|
+
UPDATE_USAGE = "Usage: cdx update [all] [--check] [--yes] [--json] [--version TAG]"
|
|
19
19
|
EXPORT_USAGE = "Usage: cdx export <file> [--include-auth] [--force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
20
20
|
IMPORT_USAGE = "Usage: cdx import <file> [--force|--merge] [--allow-authless-force] [--json] [--sessions name1,name2] [--passphrase-env VAR|--passphrase-stdin]"
|
|
21
|
-
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set [text...] [--json]"
|
|
21
|
+
CONTEXT_USAGE = "Usage: cdx context show|path|init|edit|clear|set|append [text...] [--json]"
|
|
22
|
+
MEMORY_USAGE = "Usage: cdx memory [--global|--project NAME_OR_PATH] [show|view|path|init|edit|clear|set|append|list] [text...] [--json]"
|
|
22
23
|
HANDOFF_USAGE = "Usage: cdx handoff <name> [--json] | cdx handoff <source> <target> [--json]"
|
|
23
24
|
LABEL_USAGE = "Usage: cdx label <name> <label> [--json] | cdx label <name> --clear [--json]"
|
|
24
25
|
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]"
|
|
@@ -593,11 +594,16 @@ def _parse_update_args(args):
|
|
|
593
594
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
594
595
|
"--yes": {"key": "yes", "type": "bool", "default": False},
|
|
595
596
|
"--version": {"key": "version", "type": "str", "default": None},
|
|
596
|
-
}, UPDATE_USAGE)
|
|
597
|
+
}, UPDATE_USAGE, positionals_key="values", max_positionals=1)
|
|
598
|
+
if parsed["values"] not in ([], ["all"]):
|
|
599
|
+
raise CdxError(UPDATE_USAGE)
|
|
600
|
+
parsed["all"] = bool(parsed["values"])
|
|
601
|
+
if parsed["all"] and (parsed["check"] or parsed["version"]):
|
|
602
|
+
raise CdxError("Usage: cdx update all [--yes] [--json]")
|
|
597
603
|
if parsed["check"] and parsed["version"]:
|
|
598
604
|
raise CdxError("Usage: cdx update --check cannot be combined with --version.")
|
|
599
605
|
if parsed["version"] is not None and not parsed["version"].strip():
|
|
600
|
-
raise CdxError(
|
|
606
|
+
raise CdxError(UPDATE_USAGE)
|
|
601
607
|
return parsed
|
|
602
608
|
|
|
603
609
|
|
package/src/cli_commands.py
CHANGED
|
@@ -20,6 +20,7 @@ from .cli_args import (
|
|
|
20
20
|
DOCTOR_USAGE,
|
|
21
21
|
HANDOFF_USAGE,
|
|
22
22
|
LAST_USAGE,
|
|
23
|
+
MEMORY_USAGE,
|
|
23
24
|
REPAIR_USAGE,
|
|
24
25
|
RESUME_USAGE,
|
|
25
26
|
RUN_REPORT_USAGE,
|
|
@@ -77,13 +78,23 @@ from .cli_view import handle_view as handle_view # re-export for cli.py / tests
|
|
|
77
78
|
from .codex_usage import consume_codex_rate_limit_reset_credit
|
|
78
79
|
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
79
80
|
from .context_store import (
|
|
81
|
+
append_context,
|
|
82
|
+
append_context_path,
|
|
80
83
|
clear_context,
|
|
84
|
+
clear_context_path,
|
|
81
85
|
edit_context,
|
|
86
|
+
edit_context_path,
|
|
82
87
|
get_context_path,
|
|
88
|
+
get_global_context_path,
|
|
89
|
+
get_named_project_context_path,
|
|
83
90
|
init_context,
|
|
91
|
+
init_context_path,
|
|
84
92
|
install_context_for_session,
|
|
93
|
+
list_named_project_contexts,
|
|
85
94
|
read_context,
|
|
95
|
+
read_context_path,
|
|
86
96
|
write_context,
|
|
97
|
+
write_context_path,
|
|
87
98
|
)
|
|
88
99
|
from .errors import CdxError
|
|
89
100
|
from .fs_utils import atomic_write
|
|
@@ -113,10 +124,46 @@ from .run_command import read_run_prompt, run_cdx_error_code, run_result_payload
|
|
|
113
124
|
from .run_registry import RunRegistry, build_code_review_report
|
|
114
125
|
from .run_usage import extract_run_usage
|
|
115
126
|
from .status_view import _format_status_detail, _format_status_rows, format_priority_instruction, recommend_priority_rows
|
|
127
|
+
from .update_all import apply_update_all_plan, collect_update_all_plan
|
|
116
128
|
from .update_check import LatestReleaseCheckError, fetch_latest_release, fetch_latest_release_or_raise, is_newer_version
|
|
117
129
|
from .update_manager import build_update_plan, format_update_failure, run_update_plan, verify_updated_command
|
|
118
130
|
|
|
119
131
|
|
|
132
|
+
def _format_update_all(plan, use_color=False):
|
|
133
|
+
labels = {
|
|
134
|
+
"up_to_date": _success("OK", use_color),
|
|
135
|
+
"update_available": _warn("UPDATE", use_color),
|
|
136
|
+
"not_installed": _warn("MISSING", use_color),
|
|
137
|
+
"check_with_native_updater": _info("CHECK", use_color),
|
|
138
|
+
}
|
|
139
|
+
rows = [[_style("TOOL", "1", use_color), _style("CURRENT", "1", use_color), _style("LATEST", "1", use_color), _style("STATUS", "1", use_color)]]
|
|
140
|
+
for item in plan["items"]:
|
|
141
|
+
rows.append([_info(item["name"], use_color), item.get("version") or _dim("-", use_color), item.get("latest_version") or _dim("-", use_color), labels.get(item["status"], item["status"].replace("_", " "))])
|
|
142
|
+
lines = [_style("Workstation update", "1", use_color), _dim("Inventory only — nothing has changed.", use_color), "", _pad_table(rows)]
|
|
143
|
+
missing_rtk = plan["setup"]["rtk_missing_sessions"]
|
|
144
|
+
setup = [f"RTK: enable for {', '.join(missing_rtk)}" if missing_rtk else "RTK: configured for every session"]
|
|
145
|
+
setup.extend(f"Ponytail {plugin['session']}: {labels.get(plugin['status'], plugin['status'].replace('_', ' '))}" for plugin in plan["setup"]["ponytail"])
|
|
146
|
+
lines.extend(["", _style("Session setup", "1", use_color), *setup])
|
|
147
|
+
if plan["steps"]:
|
|
148
|
+
lines.extend(["", _warn(f"{len(plan['steps'])} action(s) ready", use_color), _dim("Confirm to update only the tools listed above.", use_color)])
|
|
149
|
+
else:
|
|
150
|
+
lines.extend(["", _success("Everything managed by CDX is current and configured.", use_color)])
|
|
151
|
+
return "\n".join(lines)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def _format_update_all_result(result, use_color=False):
|
|
155
|
+
if result.get("skipped"):
|
|
156
|
+
return _warn(f"skipped: {result['name']} (blocked by {result['blocked_by']})", use_color)
|
|
157
|
+
if result.get("returncode") == 0:
|
|
158
|
+
return _success(f"done: {result['name']}", use_color)
|
|
159
|
+
detail = str(result.get("stderr") or result.get("stdout") or "No error output.").strip().replace("\n", " ")
|
|
160
|
+
detail = detail[-500:]
|
|
161
|
+
command = shlex.join(result.get("command") or [])
|
|
162
|
+
suffix = f"\n {_dim(command, use_color)}" if command else ""
|
|
163
|
+
label = _warn(f"failed: {result['name']} (exit {result.get('returncode')})", use_color)
|
|
164
|
+
return f"{label}\n {detail}{suffix}"
|
|
165
|
+
|
|
166
|
+
|
|
120
167
|
def _resolve_bundle_passphrase(ctx, env_var, prompt, confirm=False, use_stdin=False):
|
|
121
168
|
env = ctx.get("env", {})
|
|
122
169
|
if env_var:
|
|
@@ -2038,9 +2085,228 @@ def handle_context(rest, ctx):
|
|
|
2038
2085
|
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
2039
2086
|
return 0
|
|
2040
2087
|
|
|
2088
|
+
if action == "append":
|
|
2089
|
+
note = " ".join(args[1:])
|
|
2090
|
+
if not note.strip():
|
|
2091
|
+
raise CdxError("Usage: cdx context append <text> [--json]")
|
|
2092
|
+
result = append_context(base_dir, note, cwd)
|
|
2093
|
+
if json_flag:
|
|
2094
|
+
_write_json(ctx, _json_success("context.append", "Appended shared context", context=result))
|
|
2095
|
+
return 0
|
|
2096
|
+
text = f"Appended shared context: {result['path']}"
|
|
2097
|
+
ctx["out"](f"{_success(text, ctx['use_color'])}\n")
|
|
2098
|
+
return 0
|
|
2099
|
+
|
|
2041
2100
|
raise CdxError(CONTEXT_USAGE)
|
|
2042
2101
|
|
|
2043
2102
|
|
|
2103
|
+
def _parse_memory_args(rest):
|
|
2104
|
+
json_flag, args = _parse_json_flag(rest)
|
|
2105
|
+
scope = "current"
|
|
2106
|
+
project = None
|
|
2107
|
+
cleaned = []
|
|
2108
|
+
index = 0
|
|
2109
|
+
while index < len(args):
|
|
2110
|
+
arg = args[index]
|
|
2111
|
+
if arg == "--global":
|
|
2112
|
+
if scope != "current":
|
|
2113
|
+
raise CdxError(MEMORY_USAGE)
|
|
2114
|
+
scope = "global"
|
|
2115
|
+
index += 1
|
|
2116
|
+
continue
|
|
2117
|
+
if arg == "--project":
|
|
2118
|
+
if scope != "current" or index + 1 >= len(args) or not args[index + 1].strip():
|
|
2119
|
+
raise CdxError(MEMORY_USAGE)
|
|
2120
|
+
scope = "project"
|
|
2121
|
+
project = args[index + 1].strip()
|
|
2122
|
+
index += 2
|
|
2123
|
+
continue
|
|
2124
|
+
if arg.startswith("--project="):
|
|
2125
|
+
if scope != "current":
|
|
2126
|
+
raise CdxError(MEMORY_USAGE)
|
|
2127
|
+
project = arg.split("=", 1)[1].strip()
|
|
2128
|
+
if not project:
|
|
2129
|
+
raise CdxError(MEMORY_USAGE)
|
|
2130
|
+
scope = "project"
|
|
2131
|
+
index += 1
|
|
2132
|
+
continue
|
|
2133
|
+
cleaned.append(arg)
|
|
2134
|
+
index += 1
|
|
2135
|
+
if not cleaned:
|
|
2136
|
+
cleaned = ["show"]
|
|
2137
|
+
if cleaned[0] == "view":
|
|
2138
|
+
cleaned[0] = "show"
|
|
2139
|
+
return json_flag, scope, project, cleaned
|
|
2140
|
+
|
|
2141
|
+
|
|
2142
|
+
def _memory_target(base_dir, cwd, scope, project):
|
|
2143
|
+
if scope == "global":
|
|
2144
|
+
return {"scope": "global", "path": get_global_context_path(base_dir)}
|
|
2145
|
+
if scope == "project":
|
|
2146
|
+
if os.path.exists(project):
|
|
2147
|
+
return {
|
|
2148
|
+
"scope": "project",
|
|
2149
|
+
"project": project,
|
|
2150
|
+
"project_kind": "path",
|
|
2151
|
+
"path": get_context_path(base_dir, project),
|
|
2152
|
+
}
|
|
2153
|
+
return {
|
|
2154
|
+
"scope": "project",
|
|
2155
|
+
"project": project,
|
|
2156
|
+
"project_kind": "name",
|
|
2157
|
+
"path": get_named_project_context_path(base_dir, project),
|
|
2158
|
+
}
|
|
2159
|
+
return {"scope": "current", "path": get_context_path(base_dir, cwd)}
|
|
2160
|
+
|
|
2161
|
+
|
|
2162
|
+
def _memory_payload(target, content=None):
|
|
2163
|
+
payload = {
|
|
2164
|
+
"scope": target["scope"],
|
|
2165
|
+
"path": target["path"],
|
|
2166
|
+
"exists": os.path.isfile(target["path"]),
|
|
2167
|
+
}
|
|
2168
|
+
if target.get("project") is not None:
|
|
2169
|
+
payload["project"] = target["project"]
|
|
2170
|
+
payload["project_kind"] = target.get("project_kind")
|
|
2171
|
+
if payload["exists"]:
|
|
2172
|
+
payload["bytes"] = os.path.getsize(target["path"])
|
|
2173
|
+
if content is not None:
|
|
2174
|
+
payload["content"] = content
|
|
2175
|
+
return payload
|
|
2176
|
+
|
|
2177
|
+
|
|
2178
|
+
def _list_memory_entries(base_dir, cwd):
|
|
2179
|
+
entries = []
|
|
2180
|
+
global_path = get_global_context_path(base_dir)
|
|
2181
|
+
if os.path.isfile(global_path):
|
|
2182
|
+
entries.append({"scope": "global", "path": global_path, "bytes": os.path.getsize(global_path)})
|
|
2183
|
+
entries.extend(list_named_project_contexts(base_dir))
|
|
2184
|
+
current_path = get_context_path(base_dir, cwd)
|
|
2185
|
+
if os.path.isfile(current_path):
|
|
2186
|
+
entries.append({"scope": "current", "path": current_path, "bytes": os.path.getsize(current_path)})
|
|
2187
|
+
return entries
|
|
2188
|
+
|
|
2189
|
+
|
|
2190
|
+
def handle_memory(rest, ctx):
|
|
2191
|
+
json_flag, scope, project, args = _parse_memory_args(rest)
|
|
2192
|
+
action = args[0]
|
|
2193
|
+
base_dir = ctx["service"]["base_dir"]
|
|
2194
|
+
cwd = ctx.get("cwd")
|
|
2195
|
+
|
|
2196
|
+
if action == "list":
|
|
2197
|
+
if len(args) != 1 or scope != "current":
|
|
2198
|
+
raise CdxError(MEMORY_USAGE)
|
|
2199
|
+
entries = _list_memory_entries(base_dir, cwd)
|
|
2200
|
+
if json_flag:
|
|
2201
|
+
_write_json(ctx, _json_success("memory.list", "Listed memory scopes", memories=entries))
|
|
2202
|
+
return 0
|
|
2203
|
+
if not entries:
|
|
2204
|
+
ctx["out"](f"{_dim('No cdx memory files found.', ctx['use_color'])}\n")
|
|
2205
|
+
return 0
|
|
2206
|
+
rows = [["SCOPE", "PROJECT", "SIZE", "PATH"]]
|
|
2207
|
+
for entry in entries:
|
|
2208
|
+
rows.append([
|
|
2209
|
+
entry["scope"],
|
|
2210
|
+
entry.get("project") or "-",
|
|
2211
|
+
_format_bytes(entry.get("bytes") or 0),
|
|
2212
|
+
entry["path"],
|
|
2213
|
+
])
|
|
2214
|
+
ctx["out"](_pad_table(rows) + "\n")
|
|
2215
|
+
return 0
|
|
2216
|
+
|
|
2217
|
+
target = _memory_target(base_dir, cwd, scope, project)
|
|
2218
|
+
path = target["path"]
|
|
2219
|
+
|
|
2220
|
+
if action == "show":
|
|
2221
|
+
if len(args) != 1:
|
|
2222
|
+
raise CdxError(MEMORY_USAGE)
|
|
2223
|
+
content = read_context_path(path)
|
|
2224
|
+
payload = _memory_payload(target, content=content)
|
|
2225
|
+
if json_flag:
|
|
2226
|
+
_write_json(ctx, _json_success("memory.show", "Loaded memory", memory=payload))
|
|
2227
|
+
return 0
|
|
2228
|
+
if content.strip():
|
|
2229
|
+
ctx["out"](content if content.endswith("\n") else f"{content}\n")
|
|
2230
|
+
else:
|
|
2231
|
+
ctx["out"](f"{_dim('No memory for this scope. Run: cdx memory append <text>', ctx['use_color'])}\n")
|
|
2232
|
+
return 0
|
|
2233
|
+
|
|
2234
|
+
if action == "path":
|
|
2235
|
+
if len(args) != 1:
|
|
2236
|
+
raise CdxError(MEMORY_USAGE)
|
|
2237
|
+
if json_flag:
|
|
2238
|
+
_write_json(ctx, _json_success("memory.path", "Resolved memory path", memory=_memory_payload(target)))
|
|
2239
|
+
return 0
|
|
2240
|
+
ctx["out"](f"{path}\n")
|
|
2241
|
+
return 0
|
|
2242
|
+
|
|
2243
|
+
if action == "init":
|
|
2244
|
+
if len(args) != 1:
|
|
2245
|
+
raise CdxError(MEMORY_USAGE)
|
|
2246
|
+
result = init_context_path(path)
|
|
2247
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2248
|
+
message = "Created memory" if result.get("created") else "Memory already exists"
|
|
2249
|
+
if json_flag:
|
|
2250
|
+
_write_json(ctx, _json_success("memory.init", message, memory=result))
|
|
2251
|
+
return 0
|
|
2252
|
+
ctx["out"](f"{_success(f'{message}: {path}', ctx['use_color'])}\n")
|
|
2253
|
+
return 0
|
|
2254
|
+
|
|
2255
|
+
if action == "edit":
|
|
2256
|
+
if len(args) != 1:
|
|
2257
|
+
raise CdxError(MEMORY_USAGE)
|
|
2258
|
+
result = edit_context_path(path, env=ctx.get("env"), spawn_sync=ctx.get("spawn_sync"))
|
|
2259
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2260
|
+
if json_flag:
|
|
2261
|
+
_write_json(ctx, _json_success("memory.edit", "Edited memory", memory=result))
|
|
2262
|
+
return 0
|
|
2263
|
+
ctx["out"](f"{_success(f'Edited memory: {path}', ctx['use_color'])}\n")
|
|
2264
|
+
return 0
|
|
2265
|
+
|
|
2266
|
+
if action == "clear":
|
|
2267
|
+
if len(args) != 1:
|
|
2268
|
+
raise CdxError(MEMORY_USAGE)
|
|
2269
|
+
result = clear_context_path(path)
|
|
2270
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2271
|
+
message = "Cleared memory" if result["removed"] else "No memory to clear"
|
|
2272
|
+
if json_flag:
|
|
2273
|
+
_write_json(ctx, _json_success("memory.clear", message, memory=result))
|
|
2274
|
+
return 0
|
|
2275
|
+
ctx["out"](f"{_success(message, ctx['use_color'])}\n")
|
|
2276
|
+
return 0
|
|
2277
|
+
|
|
2278
|
+
if action == "set":
|
|
2279
|
+
content_args = args[1:]
|
|
2280
|
+
if content_args:
|
|
2281
|
+
content = " ".join(content_args)
|
|
2282
|
+
else:
|
|
2283
|
+
stdin = ctx["options"].get("stdin_data")
|
|
2284
|
+
if stdin is None and not ctx.get("stdin_is_tty"):
|
|
2285
|
+
stdin = sys.stdin.read()
|
|
2286
|
+
if stdin is None:
|
|
2287
|
+
raise CdxError("Usage: cdx memory set <text> [--json]")
|
|
2288
|
+
content = stdin
|
|
2289
|
+
result = write_context_path(path, content)
|
|
2290
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2291
|
+
if json_flag:
|
|
2292
|
+
_write_json(ctx, _json_success("memory.set", "Saved memory", memory=result))
|
|
2293
|
+
return 0
|
|
2294
|
+
ctx["out"](f"{_success(f'Saved memory: {path}', ctx['use_color'])}\n")
|
|
2295
|
+
return 0
|
|
2296
|
+
|
|
2297
|
+
if action == "append":
|
|
2298
|
+
note = " ".join(args[1:])
|
|
2299
|
+
result = append_context_path(path, note)
|
|
2300
|
+
result = {**result, **{key: value for key, value in target.items() if key != "path"}}
|
|
2301
|
+
if json_flag:
|
|
2302
|
+
_write_json(ctx, _json_success("memory.append", "Appended memory", memory=result))
|
|
2303
|
+
return 0
|
|
2304
|
+
ctx["out"](f"{_success(f'Appended memory: {path}', ctx['use_color'])}\n")
|
|
2305
|
+
return 0
|
|
2306
|
+
|
|
2307
|
+
raise CdxError(MEMORY_USAGE)
|
|
2308
|
+
|
|
2309
|
+
|
|
2044
2310
|
def handle_status(rest, ctx):
|
|
2045
2311
|
parsed = _parse_flag_args(rest, {
|
|
2046
2312
|
"--json": {"key": "json", "type": "bool", "default": False},
|
|
@@ -2444,6 +2710,38 @@ def handle_logout(rest, ctx):
|
|
|
2444
2710
|
|
|
2445
2711
|
def handle_update(rest, ctx):
|
|
2446
2712
|
parsed = _parse_update_args(rest)
|
|
2713
|
+
if parsed["all"]:
|
|
2714
|
+
plan = collect_update_all_plan(ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"))
|
|
2715
|
+
if not parsed["yes"]:
|
|
2716
|
+
if parsed["json"]:
|
|
2717
|
+
_write_json(ctx, _json_success("update.all", "Collected workstation update plan", plan=plan, apply_required=bool(plan["steps"])))
|
|
2718
|
+
return 0
|
|
2719
|
+
ctx["out"](f"{_format_update_all(plan, ctx['use_color'])}\n")
|
|
2720
|
+
if not plan["steps"]:
|
|
2721
|
+
return 0
|
|
2722
|
+
if not ctx["stdin_is_tty"]:
|
|
2723
|
+
raise CdxError("cdx update all requires an interactive terminal or --yes.")
|
|
2724
|
+
if input("Apply this plan? [y/N] ").strip().lower() not in ("y", "yes"):
|
|
2725
|
+
ctx["out"](f"{_warn('Cancelled.', ctx['use_color'])}\n")
|
|
2726
|
+
return 0
|
|
2727
|
+
def progress(event):
|
|
2728
|
+
if parsed["json"]:
|
|
2729
|
+
return
|
|
2730
|
+
step_name = event["step"]["name"]
|
|
2731
|
+
if event["phase"] == "start":
|
|
2732
|
+
progress_label = _info(f"[{event['index']}/{event['total']}]", ctx["use_color"])
|
|
2733
|
+
ctx["out"](f"{progress_label} {step_name}...\n")
|
|
2734
|
+
else:
|
|
2735
|
+
ctx["out"](f" {_format_update_all_result(event['result'], ctx['use_color'])}\n")
|
|
2736
|
+
|
|
2737
|
+
results = apply_update_all_plan(plan, ctx["service"], env=ctx.get("env"), runner=ctx["options"].get("runUpdateAll"), progress=progress)
|
|
2738
|
+
failed = [row for row in results if row.get("returncode") != 0]
|
|
2739
|
+
message = "Applied workstation update plan" if not failed else "Workstation update plan completed with failures"
|
|
2740
|
+
if parsed["json"]:
|
|
2741
|
+
_write_json(ctx, _json_success("update.all", message, plan=plan, results=results, failed=len(failed)))
|
|
2742
|
+
else:
|
|
2743
|
+
ctx["out"](f"{_success(message, ctx['use_color']) if not failed else _warn(message, ctx['use_color'])}\n")
|
|
2744
|
+
return 1 if failed else 0
|
|
2447
2745
|
json_flag = parsed["json"]
|
|
2448
2746
|
current_version = str(ctx.get("version") or "").strip()
|
|
2449
2747
|
release_fetcher = ctx["options"].get("fetchLatestRelease") or fetch_latest_release
|
package/src/cli_helpers.py
CHANGED
|
@@ -250,11 +250,10 @@ def _collect_native_handoff_transcript_paths(session):
|
|
|
250
250
|
|
|
251
251
|
|
|
252
252
|
def _latest_handoff_transcript_path(session):
|
|
253
|
-
launch_path = _latest_launch_transcript_path(session)
|
|
254
|
-
if launch_path:
|
|
255
|
-
return launch_path
|
|
256
253
|
native_paths = _collect_native_handoff_transcript_paths(session)
|
|
257
|
-
|
|
254
|
+
if native_paths:
|
|
255
|
+
return native_paths[0]
|
|
256
|
+
return _latest_launch_transcript_path(session)
|
|
258
257
|
|
|
259
258
|
|
|
260
259
|
def _collect_handoff_text_fragments(value):
|
|
@@ -281,6 +280,10 @@ def _collect_handoff_text_fragments(value):
|
|
|
281
280
|
def _jsonl_record_to_handoff_text(record):
|
|
282
281
|
if not isinstance(record, dict):
|
|
283
282
|
return None
|
|
283
|
+
if record.get("type") == "response_item":
|
|
284
|
+
record = record.get("payload")
|
|
285
|
+
if not isinstance(record, dict):
|
|
286
|
+
return None
|
|
284
287
|
message = record.get("message") if isinstance(record.get("message"), dict) else {}
|
|
285
288
|
role = (
|
|
286
289
|
message.get("role")
|
|
@@ -289,6 +292,8 @@ def _jsonl_record_to_handoff_text(record):
|
|
|
289
292
|
or record.get("sender")
|
|
290
293
|
or "entry"
|
|
291
294
|
)
|
|
295
|
+
if role not in ("user", "assistant"):
|
|
296
|
+
return None
|
|
292
297
|
text_sources = []
|
|
293
298
|
for key in ("content", "text", "payload"):
|
|
294
299
|
if key in record:
|
package/src/context_store.py
CHANGED
|
@@ -3,6 +3,7 @@ import os
|
|
|
3
3
|
import shlex
|
|
4
4
|
import subprocess
|
|
5
5
|
from datetime import datetime
|
|
6
|
+
from urllib.parse import quote, unquote
|
|
6
7
|
|
|
7
8
|
from .errors import CdxError
|
|
8
9
|
from .fs_utils import atomic_write
|
|
@@ -42,12 +43,43 @@ def get_context_path(base_dir, cwd=None):
|
|
|
42
43
|
return os.path.join(_context_dir(base_dir, cwd), "context.md")
|
|
43
44
|
|
|
44
45
|
|
|
46
|
+
def get_global_context_path(base_dir):
|
|
47
|
+
return os.path.join(base_dir, "contexts", "global", "context.md")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_named_project_context_path(base_dir, project):
|
|
51
|
+
return os.path.join(base_dir, "contexts", "projects", quote(project, safe=""), "context.md")
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def list_named_project_contexts(base_dir):
|
|
55
|
+
root = os.path.join(base_dir, "contexts", "projects")
|
|
56
|
+
try:
|
|
57
|
+
names = sorted(os.listdir(root))
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
return []
|
|
60
|
+
rows = []
|
|
61
|
+
for name in names:
|
|
62
|
+
path = os.path.join(root, name, "context.md")
|
|
63
|
+
if not os.path.isfile(path):
|
|
64
|
+
continue
|
|
65
|
+
rows.append({
|
|
66
|
+
"scope": "project",
|
|
67
|
+
"project": unquote(name),
|
|
68
|
+
"path": path,
|
|
69
|
+
"bytes": os.path.getsize(path),
|
|
70
|
+
})
|
|
71
|
+
return rows
|
|
72
|
+
|
|
73
|
+
|
|
45
74
|
def get_context_meta_path(base_dir, cwd=None):
|
|
46
75
|
return os.path.join(_context_dir(base_dir, cwd), "meta.json")
|
|
47
76
|
|
|
48
77
|
|
|
49
78
|
def read_context(base_dir, cwd=None):
|
|
50
|
-
|
|
79
|
+
return read_context_path(get_context_path(base_dir, cwd))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def read_context_path(path):
|
|
51
83
|
try:
|
|
52
84
|
with open(path, encoding="utf-8") as handle:
|
|
53
85
|
return handle.read()
|
|
@@ -56,7 +88,10 @@ def read_context(base_dir, cwd=None):
|
|
|
56
88
|
|
|
57
89
|
|
|
58
90
|
def write_context(base_dir, content, cwd=None):
|
|
59
|
-
|
|
91
|
+
return write_context_path(get_context_path(base_dir, cwd), content)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def write_context_path(path, content):
|
|
60
95
|
_ensure_dir(os.path.dirname(path))
|
|
61
96
|
atomic_write(path, content.rstrip() + "\n", mode=0o644)
|
|
62
97
|
return {
|
|
@@ -66,20 +101,40 @@ def write_context(base_dir, content, cwd=None):
|
|
|
66
101
|
}
|
|
67
102
|
|
|
68
103
|
|
|
104
|
+
def append_context(base_dir, note, cwd=None):
|
|
105
|
+
return append_context_path(get_context_path(base_dir, cwd), note)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def append_context_path(path, note):
|
|
109
|
+
note = str(note or "").strip()
|
|
110
|
+
if not note:
|
|
111
|
+
raise CdxError("Memory append requires text.")
|
|
112
|
+
current = read_context_path(path).rstrip()
|
|
113
|
+
content = f"{current}\n{note}" if current else note
|
|
114
|
+
return write_context_path(path, content)
|
|
115
|
+
|
|
116
|
+
|
|
69
117
|
def init_context(base_dir, cwd=None):
|
|
70
|
-
|
|
118
|
+
return init_context_path(get_context_path(base_dir, cwd))
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def init_context_path(path):
|
|
122
|
+
current = read_context_path(path)
|
|
71
123
|
if current.strip():
|
|
72
124
|
return {
|
|
73
|
-
"path":
|
|
125
|
+
"path": path,
|
|
74
126
|
"created": False,
|
|
75
127
|
"bytes": len(current.encode("utf-8")),
|
|
76
128
|
}
|
|
77
|
-
result =
|
|
129
|
+
result = write_context_path(path, DEFAULT_CONTEXT_TEMPLATE)
|
|
78
130
|
return {**result, "created": True}
|
|
79
131
|
|
|
80
132
|
|
|
81
133
|
def clear_context(base_dir, cwd=None):
|
|
82
|
-
|
|
134
|
+
return clear_context_path(get_context_path(base_dir, cwd))
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def clear_context_path(path):
|
|
83
138
|
try:
|
|
84
139
|
os.remove(path)
|
|
85
140
|
removed = True
|
|
@@ -89,7 +144,11 @@ def clear_context(base_dir, cwd=None):
|
|
|
89
144
|
|
|
90
145
|
|
|
91
146
|
def edit_context(base_dir, cwd=None, editor=None, env=None, spawn_sync=None):
|
|
92
|
-
|
|
147
|
+
return edit_context_path(get_context_path(base_dir, cwd), editor=editor, env=env, spawn_sync=spawn_sync)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def edit_context_path(path, editor=None, env=None, spawn_sync=None):
|
|
151
|
+
result = init_context_path(path)
|
|
93
152
|
path = result["path"]
|
|
94
153
|
env = env or os.environ
|
|
95
154
|
editor = editor or env.get("VISUAL") or env.get("EDITOR")
|
package/src/health.py
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import os
|
|
3
|
+
import re
|
|
3
4
|
import shutil
|
|
5
|
+
import subprocess
|
|
4
6
|
import sys
|
|
5
7
|
import tempfile
|
|
6
8
|
from datetime import datetime, timezone
|
|
@@ -42,6 +44,8 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
42
44
|
path = shutil.which(command, path=env.get("PATH"))
|
|
43
45
|
status = "OK" if path else "WARN"
|
|
44
46
|
issues.append(_issue(status, f"{command}_cli", f"{command} CLI {'found' if path else 'not found'}", path))
|
|
47
|
+
if path:
|
|
48
|
+
issues.append(_provider_cli_version_issue(command, path, env, spawn_sync))
|
|
45
49
|
|
|
46
50
|
rtk_path = shutil.which("rtk", path=env.get("PATH"))
|
|
47
51
|
issues.append(_issue(
|
|
@@ -95,6 +99,66 @@ def collect_health_report(service, base_dir, env=None, spawn_sync=None):
|
|
|
95
99
|
return {"base_dir": base_dir, "issues": issues, "summary": summarize_health(issues)}
|
|
96
100
|
|
|
97
101
|
|
|
102
|
+
def _provider_cli_version_issue(command, path, env, spawn_sync=None):
|
|
103
|
+
result = _run_version_command(command, env, spawn_sync)
|
|
104
|
+
raw = (result.get("stdout") or result.get("stderr") or "").strip()
|
|
105
|
+
version = _extract_version(raw)
|
|
106
|
+
detail = {
|
|
107
|
+
"command": command,
|
|
108
|
+
"path": path,
|
|
109
|
+
"version": version,
|
|
110
|
+
"raw": raw,
|
|
111
|
+
"capabilities": _provider_capability_hints(command),
|
|
112
|
+
}
|
|
113
|
+
if version:
|
|
114
|
+
return _issue("OK", f"{command}_cli_version", f"{command} CLI version detected: {version}", detail)
|
|
115
|
+
return _issue("WARN", f"{command}_cli_version", f"{command} CLI version could not be detected", detail)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _run_version_command(command, env, spawn_sync=None):
|
|
119
|
+
try:
|
|
120
|
+
if spawn_sync:
|
|
121
|
+
result = spawn_sync(command, ["--version"], {"env": env, "timeout": 5})
|
|
122
|
+
if isinstance(result, dict):
|
|
123
|
+
return result
|
|
124
|
+
return {
|
|
125
|
+
"stdout": getattr(result, "stdout", "") or "",
|
|
126
|
+
"stderr": getattr(result, "stderr", "") or "",
|
|
127
|
+
}
|
|
128
|
+
completed = subprocess.run(
|
|
129
|
+
[command, "--version"],
|
|
130
|
+
env=env,
|
|
131
|
+
capture_output=True,
|
|
132
|
+
text=True,
|
|
133
|
+
timeout=5,
|
|
134
|
+
check=False,
|
|
135
|
+
)
|
|
136
|
+
return {"stdout": completed.stdout or "", "stderr": completed.stderr or ""}
|
|
137
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
138
|
+
return {"stdout": "", "stderr": ""}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _extract_version(text):
|
|
142
|
+
match = re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text or "")
|
|
143
|
+
return match.group(1) if match else None
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _provider_capability_hints(command):
|
|
147
|
+
if command == "codex":
|
|
148
|
+
return [
|
|
149
|
+
"version_inventory",
|
|
150
|
+
"provider_memory_import_surfaces_may_exist_in_recent_codex",
|
|
151
|
+
"provider_memory_is_not_modified_by_cdx_memory",
|
|
152
|
+
]
|
|
153
|
+
if command == "claude":
|
|
154
|
+
return [
|
|
155
|
+
"version_inventory",
|
|
156
|
+
"project_memory_and_stream_json_diagnostics_may_exist_in_recent_claude_code",
|
|
157
|
+
"provider_memory_is_not_modified_by_cdx_memory",
|
|
158
|
+
]
|
|
159
|
+
return ["version_inventory"]
|
|
160
|
+
|
|
161
|
+
|
|
98
162
|
def _codex_auth_issues(session, diag):
|
|
99
163
|
name = session["name"]
|
|
100
164
|
identity = diag.get("account_email") or diag.get("observed_account") or "unknown account"
|
package/src/session_service.py
CHANGED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Inventory and apply the safe, known updates for a CDX workstation."""
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import os
|
|
5
|
+
import re
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
|
|
9
|
+
from .config import PROVIDER_ANTIGRAVITY, PROVIDER_CLAUDE, PROVIDER_CODEX, PROVIDER_OLLAMA
|
|
10
|
+
|
|
11
|
+
_TOOLS = (
|
|
12
|
+
(PROVIDER_CODEX, "codex", None, ["codex", "update"]),
|
|
13
|
+
(PROVIDER_CLAUDE, "claude", "claude", ["brew", "upgrade", "--cask", "claude"]),
|
|
14
|
+
(PROVIDER_ANTIGRAVITY, "agy", "antigravity", ["brew", "upgrade", "--cask", "antigravity"]),
|
|
15
|
+
(PROVIDER_OLLAMA, "ollama", "ollama", ["brew", "upgrade", "ollama"]),
|
|
16
|
+
("rtk", "rtk", "rtk", ["brew", "upgrade", "rtk"]),
|
|
17
|
+
)
|
|
18
|
+
_PONYTAIL_URL = "https://github.com/DietrichGebert/ponytail.git"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _which(command, env):
|
|
22
|
+
path = env.get("PATH")
|
|
23
|
+
resolved = shutil.which(command, path=path)
|
|
24
|
+
if resolved or not path:
|
|
25
|
+
return resolved
|
|
26
|
+
for directory in path.split(os.pathsep):
|
|
27
|
+
candidate = os.path.join(directory, command)
|
|
28
|
+
if os.path.isfile(candidate):
|
|
29
|
+
return candidate
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _run(command, env, runner=None, timeout=15):
|
|
34
|
+
runner = runner or subprocess.run
|
|
35
|
+
try:
|
|
36
|
+
result = runner(command, env=env, capture_output=True, text=True, check=False, timeout=timeout)
|
|
37
|
+
except (OSError, subprocess.TimeoutExpired) as error:
|
|
38
|
+
return {"returncode": 1, "stdout": "", "stderr": str(error)}
|
|
39
|
+
if isinstance(result, dict):
|
|
40
|
+
return result
|
|
41
|
+
return {"returncode": result.returncode, "stdout": result.stdout or "", "stderr": result.stderr or ""}
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _version(command, env, runner):
|
|
45
|
+
result = _run([command, "--version"], env, runner)
|
|
46
|
+
text = (result["stdout"] or result["stderr"] or "").strip()
|
|
47
|
+
match = re.search(r"\b(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\b", text)
|
|
48
|
+
return match.group(1) if match else None
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _npm_latest(package, env, runner):
|
|
52
|
+
if not _which("npm", env):
|
|
53
|
+
return None
|
|
54
|
+
result = _run(["npm", "view", package, "version"], env, runner)
|
|
55
|
+
value = (result["stdout"] or "").strip()
|
|
56
|
+
return value if re.fullmatch(r"\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?", value) else None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _newer(latest, current):
|
|
60
|
+
def key(value):
|
|
61
|
+
return tuple(int(part) for part in str(value).split("-", 1)[0].split("+", 1)[0].split("."))
|
|
62
|
+
return bool(latest and current and key(latest) > key(current))
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _brew_outdated(env, runner):
|
|
66
|
+
if not _which("brew", env):
|
|
67
|
+
return {}
|
|
68
|
+
result = _run(["brew", "outdated", "--json=v2"], env, runner, timeout=30)
|
|
69
|
+
try:
|
|
70
|
+
payload = json.loads(result["stdout"])
|
|
71
|
+
except (TypeError, ValueError):
|
|
72
|
+
return {}
|
|
73
|
+
rows = {}
|
|
74
|
+
for group in ("formulae", "casks"):
|
|
75
|
+
for item in payload.get(group, []):
|
|
76
|
+
rows[item.get("name")] = item
|
|
77
|
+
return rows
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def _brew_cask_for_path(path):
|
|
81
|
+
match = re.search(r"/Caskroom/([^/]+)/", os.path.realpath(path or ""))
|
|
82
|
+
return match.group(1) if match else None
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _ponytail_revision(auth_home, env=None, runner=None):
|
|
86
|
+
try:
|
|
87
|
+
with open(os.path.join(auth_home, "config.toml"), encoding="utf-8") as handle:
|
|
88
|
+
text = handle.read()
|
|
89
|
+
except OSError:
|
|
90
|
+
text = ""
|
|
91
|
+
match = re.search(r'\[marketplaces\.ponytail\].*?last_revision\s*=\s*"([0-9a-f]+)"', text, re.S)
|
|
92
|
+
if match:
|
|
93
|
+
return match.group(1)
|
|
94
|
+
cache = os.path.join(auth_home, ".tmp", "marketplaces", "ponytail")
|
|
95
|
+
result = _run(["git", "-C", cache, "rev-parse", "HEAD"], env or os.environ, runner)
|
|
96
|
+
revision = (result["stdout"] or "").strip()
|
|
97
|
+
return revision if re.fullmatch(r"[0-9a-f]{40}", revision) else None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def collect_update_all_plan(service, env=None, runner=None):
|
|
101
|
+
env = dict(env or os.environ)
|
|
102
|
+
outdated = _brew_outdated(env, runner)
|
|
103
|
+
items, steps = [], []
|
|
104
|
+
for name, command, brew_name, update_command in _TOOLS:
|
|
105
|
+
path = _which(command, env)
|
|
106
|
+
item = {"name": name, "command": command, "installed": bool(path), "path": path, "version": _version(command, env, runner) if path else None}
|
|
107
|
+
cask_name = _brew_cask_for_path(path)
|
|
108
|
+
brew_target = cask_name or brew_name
|
|
109
|
+
brew_row = outdated.get(brew_target) if brew_target else None
|
|
110
|
+
if brew_row:
|
|
111
|
+
command_to_run = ["brew", "upgrade", "--cask", brew_target] if cask_name else update_command
|
|
112
|
+
item.update({"status": "update_available", "latest_version": brew_row.get("current_version"), "update_command": command_to_run, "package": brew_target})
|
|
113
|
+
steps.append({"name": name, "command": command_to_run, "env": {}})
|
|
114
|
+
elif path and name == PROVIDER_CODEX:
|
|
115
|
+
# Codex owns its installer; it is the only reliable updater across npm, Homebrew and standalone installs.
|
|
116
|
+
latest = _npm_latest("@openai/codex", env, runner)
|
|
117
|
+
item.update({"latest_version": latest, "status": "update_available" if _newer(latest, item["version"]) else "up_to_date" if latest else "check_with_native_updater", "update_command": update_command})
|
|
118
|
+
if item["status"] != "up_to_date":
|
|
119
|
+
steps.append({"name": name, "command": update_command, "env": {}})
|
|
120
|
+
else:
|
|
121
|
+
item["status"] = "up_to_date" if path else "not_installed"
|
|
122
|
+
items.append(item)
|
|
123
|
+
|
|
124
|
+
rtk_available = any(item["name"] == "rtk" and item["installed"] for item in items)
|
|
125
|
+
missing_rtk = [session["name"] for session in service["list_sessions"]() if not (session.get("launch") or {}).get("rtk")]
|
|
126
|
+
setup = {"rtk_available": rtk_available, "rtk_missing_sessions": missing_rtk, "ponytail": []}
|
|
127
|
+
if rtk_available and missing_rtk:
|
|
128
|
+
steps.append({"name": "enable RTK", "command": None, "sessions": missing_rtk})
|
|
129
|
+
|
|
130
|
+
codex_sessions = [session for session in service["list_sessions"]() if session.get("provider") == PROVIDER_CODEX]
|
|
131
|
+
ponytail_head = None
|
|
132
|
+
if any(_ponytail_revision(session.get("authHome") or "", env, runner) for session in codex_sessions):
|
|
133
|
+
remote = _run(["git", "ls-remote", _PONYTAIL_URL, "HEAD"], env, runner)
|
|
134
|
+
ponytail_head = (remote["stdout"] or "").split("\t", 1)[0].strip() or None
|
|
135
|
+
for session in codex_sessions:
|
|
136
|
+
revision = _ponytail_revision(session.get("authHome") or "", env, runner)
|
|
137
|
+
row = {"session": session["name"], "installed": bool(revision), "revision": revision}
|
|
138
|
+
if revision:
|
|
139
|
+
row["latest_revision"] = ponytail_head
|
|
140
|
+
row["status"] = "update_available" if ponytail_head and not ponytail_head.startswith(revision) else "up_to_date"
|
|
141
|
+
if row["status"] == "update_available":
|
|
142
|
+
steps.append({"name": f"update Ponytail ({session['name']})", "command": ["codex", "plugin", "marketplace", "upgrade", "ponytail", "--json"], "env": {"CODEX_HOME": session["authHome"]}})
|
|
143
|
+
else:
|
|
144
|
+
row["status"] = "not_installed"
|
|
145
|
+
marketplace_id = f"ponytail-marketplace:{session['name']}"
|
|
146
|
+
steps.extend([
|
|
147
|
+
{"id": marketplace_id, "name": f"add Ponytail marketplace ({session['name']})", "command": ["codex", "plugin", "marketplace", "add", _PONYTAIL_URL], "env": {"CODEX_HOME": session["authHome"]}},
|
|
148
|
+
{"name": f"install Ponytail ({session['name']})", "command": ["codex", "plugin", "add", "ponytail@ponytail", "--json"], "env": {"CODEX_HOME": session["authHome"]}, "requires": marketplace_id},
|
|
149
|
+
])
|
|
150
|
+
setup["ponytail"].append(row)
|
|
151
|
+
return {"items": items, "setup": setup, "steps": steps}
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def apply_update_all_plan(plan, service, env=None, runner=None, progress=None):
|
|
155
|
+
env = dict(env or os.environ)
|
|
156
|
+
results = []
|
|
157
|
+
completed = set()
|
|
158
|
+
for index, step in enumerate(plan["steps"], start=1):
|
|
159
|
+
if progress:
|
|
160
|
+
progress({"phase": "start", "index": index, "total": len(plan["steps"]), "step": step})
|
|
161
|
+
if step.get("requires") and step["requires"] not in completed:
|
|
162
|
+
result = {"name": step["name"], "returncode": None, "skipped": True, "blocked_by": step["requires"]}
|
|
163
|
+
results.append(result)
|
|
164
|
+
if progress:
|
|
165
|
+
progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
|
|
166
|
+
continue
|
|
167
|
+
if step["command"] is None:
|
|
168
|
+
for name in step["sessions"]:
|
|
169
|
+
service["set_launch_settings"](name, {"rtk": True})
|
|
170
|
+
result = {"name": step["name"], "returncode": 0, "sessions": step["sessions"]}
|
|
171
|
+
results.append(result)
|
|
172
|
+
if step.get("id"):
|
|
173
|
+
completed.add(step["id"])
|
|
174
|
+
if progress:
|
|
175
|
+
progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
|
|
176
|
+
continue
|
|
177
|
+
result = _run(step["command"], {**env, **step.get("env", {})}, runner, timeout=120)
|
|
178
|
+
result = {"name": step["name"], "command": step["command"], **result}
|
|
179
|
+
results.append(result)
|
|
180
|
+
if result["returncode"] == 0 and step.get("id"):
|
|
181
|
+
completed.add(step["id"])
|
|
182
|
+
if progress:
|
|
183
|
+
progress({"phase": "done", "index": index, "total": len(plan["steps"]), "step": step, "result": result})
|
|
184
|
+
return results
|