agentsquid 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- agent/__init__.py +0 -0
- agent/config.py +137 -0
- agent/context_sync.py +163 -0
- agent/creds.py +130 -0
- agent/flow.py +806 -0
- agent/git_changes.py +399 -0
- agent/harnesses.py +344 -0
- agent/history.py +16 -0
- agent/journal.py +323 -0
- agent/memory.py +247 -0
- agent/providers.py +275 -0
- agent/resolve.py +199 -0
- agent/runners.py +1841 -0
- agent/server.py +2879 -0
- agent/stats_db.py +3577 -0
- agent/topic_queue.py +666 -0
- agent/topics.py +14 -0
- agent/worktree.py +717 -0
- agentsquid-0.1.0.dist-info/METADATA +234 -0
- agentsquid-0.1.0.dist-info/RECORD +47 -0
- agentsquid-0.1.0.dist-info/WHEEL +4 -0
- agentsquid-0.1.0.dist-info/entry_points.txt +2 -0
- agentsquid-0.1.0.dist-info/licenses/LICENSE +21 -0
- config/squid.yaml.example +206 -0
- context/README.md +89 -0
- context/roles/review/AGENTS.md +52 -0
- context/roles/review/claude/CLAUDE.md +3 -0
- context/topics/default/memory.md +4 -0
- ui/app.js +14381 -0
- ui/favicon.png +0 -0
- ui/flow-lang.js +727 -0
- ui/flow-playground.html +306 -0
- ui/icons/icon-192.png +0 -0
- ui/icons/icon-512.png +0 -0
- ui/icons/maskable-192.png +0 -0
- ui/icons/maskable-512.png +0 -0
- ui/index.html +755 -0
- ui/manifest.webmanifest +39 -0
- ui/squid.jpg +0 -0
- ui/squid.png +0 -0
- ui/squid_white.png +0 -0
- ui/style.css +5071 -0
- ui/sw.js +66 -0
- ui/vendor/atom-one-dark.min.css +1 -0
- ui/vendor/highlight.min.js +1213 -0
- ui/vendor/marked.min.js +69 -0
- ui/vendor/qrcode.min.js +1 -0
agent/__init__.py
ADDED
|
File without changes
|
agent/config.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import getpass
|
|
2
|
+
import hashlib
|
|
3
|
+
import os
|
|
4
|
+
import shutil
|
|
5
|
+
import tempfile
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Optional
|
|
8
|
+
import yaml
|
|
9
|
+
|
|
10
|
+
_USER_CONFIG = Path.home() / ".squid" / "squid.yaml"
|
|
11
|
+
_EXAMPLE_CONFIG = Path(__file__).parent.parent / "config" / "squid.yaml.example"
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _bootstrap_config() -> None:
|
|
15
|
+
"""Create ~/.squid/squid.yaml from the bundled example on first run.
|
|
16
|
+
|
|
17
|
+
bin/install.sh and bin/start.sh do this same substitution for a source
|
|
18
|
+
checkout, but a pip/pipx install never runs either script, so this has
|
|
19
|
+
to also happen here — otherwise a fresh pipx install has no config file
|
|
20
|
+
and _load_config() below crashes on the very first launch.
|
|
21
|
+
"""
|
|
22
|
+
if _USER_CONFIG.exists() or not _EXAMPLE_CONFIG.exists():
|
|
23
|
+
return
|
|
24
|
+
_USER_CONFIG.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
text = _EXAMPLE_CONFIG.read_text(encoding="utf-8")
|
|
26
|
+
text = text.replace("/tmp/squid", f"/tmp/{getpass.getuser()}/squid")
|
|
27
|
+
_USER_CONFIG.write_text(text, encoding="utf-8")
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _load_config() -> dict:
|
|
31
|
+
_bootstrap_config()
|
|
32
|
+
return yaml.safe_load(_USER_CONFIG.read_text())
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def config_text() -> str:
|
|
36
|
+
return _USER_CONFIG.read_text(encoding="utf-8")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def config_revision(content: Optional[str] = None) -> str:
|
|
40
|
+
raw = config_text() if content is None else content
|
|
41
|
+
return hashlib.sha256(raw.encode("utf-8")).hexdigest()[:16]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def write_config_text(content: str, expected_revision: Optional[str] = None) -> str:
|
|
45
|
+
"""Atomically replace squid.yaml, retaining the previous version as .bak."""
|
|
46
|
+
if expected_revision is not None and config_revision() != expected_revision:
|
|
47
|
+
raise RuntimeError("configuration changed since it was loaded")
|
|
48
|
+
|
|
49
|
+
mode = _USER_CONFIG.stat().st_mode & 0o777
|
|
50
|
+
backup = _USER_CONFIG.with_suffix(_USER_CONFIG.suffix + ".bak")
|
|
51
|
+
shutil.copy2(_USER_CONFIG, backup)
|
|
52
|
+
fd, tmp_name = tempfile.mkstemp(prefix=".squid.yaml.", dir=_USER_CONFIG.parent)
|
|
53
|
+
try:
|
|
54
|
+
with os.fdopen(fd, "w", encoding="utf-8") as handle:
|
|
55
|
+
handle.write(content)
|
|
56
|
+
handle.flush()
|
|
57
|
+
os.fsync(handle.fileno())
|
|
58
|
+
os.chmod(tmp_name, mode)
|
|
59
|
+
os.replace(tmp_name, _USER_CONFIG)
|
|
60
|
+
finally:
|
|
61
|
+
if os.path.exists(tmp_name):
|
|
62
|
+
os.unlink(tmp_name)
|
|
63
|
+
return config_revision(content)
|
|
64
|
+
|
|
65
|
+
_cfg = _load_config()
|
|
66
|
+
|
|
67
|
+
# CLI executable names — override via env if installed elsewhere
|
|
68
|
+
CLAUDE_CLI = "claude"
|
|
69
|
+
CODEX_CLI = "codex"
|
|
70
|
+
CURSOR_CLI = "cursor-agent"
|
|
71
|
+
OPENCODE_CLI = "opencode"
|
|
72
|
+
PI_CLI = "pi"
|
|
73
|
+
|
|
74
|
+
# Experimental runners exist for these CLIs but they are not configurable harnesses.
|
|
75
|
+
COPILOT_CLI = "copilot"
|
|
76
|
+
AGY_CLI = "agy"
|
|
77
|
+
|
|
78
|
+
# Squid Echo: a no-op harness (agent/runners.py's run_echo) that echoes the
|
|
79
|
+
# prompt straight back with no subprocess and no network call — for fast,
|
|
80
|
+
# free previews of how a Squid Flow route actually dispatches/executes
|
|
81
|
+
# (joins, round-trips, broadcasts, ...) without a real coding-agent CLI in
|
|
82
|
+
# the loop. Opt-in only (absent/false by default), so it never shows up for
|
|
83
|
+
# a user who hasn't asked for it; harnesses.py and providers.py both gate on
|
|
84
|
+
# this flag. Settable either way — `test_harness_enabled: true` in
|
|
85
|
+
# squid.yaml (picked up on every restart, since /restart re-execs and
|
|
86
|
+
# re-reads the file) or the SQUID_TEST_HARNESS env var (for CI/one-offs).
|
|
87
|
+
TEST_HARNESS_ENABLED: bool = bool(_cfg.get("test_harness_enabled")) or bool(os.environ.get("SQUID_TEST_HARNESS"))
|
|
88
|
+
|
|
89
|
+
# How long to wait for a CLI to produce its first byte before giving up
|
|
90
|
+
FIRST_BYTE_TIMEOUT: int = _cfg["agent"]["first_byte_timeout"]
|
|
91
|
+
|
|
92
|
+
# Hard cap on total response time per request
|
|
93
|
+
RESPONSE_TIMEOUT: int = _cfg["agent"]["response_timeout"]
|
|
94
|
+
|
|
95
|
+
def find_cli(name: str) -> Optional[str]:
|
|
96
|
+
return shutil.which(name)
|
|
97
|
+
|
|
98
|
+
CLAUDE_PATH = find_cli(CLAUDE_CLI)
|
|
99
|
+
CODEX_PATH = find_cli(CODEX_CLI)
|
|
100
|
+
COPILOT_PATH = find_cli(COPILOT_CLI)
|
|
101
|
+
CURSOR_PATH = find_cli(CURSOR_CLI)
|
|
102
|
+
OPENCODE_PATH = find_cli(OPENCODE_CLI)
|
|
103
|
+
PI_PATH = find_cli(PI_CLI)
|
|
104
|
+
AGY_PATH = find_cli(AGY_CLI)
|
|
105
|
+
|
|
106
|
+
# OpenCode free provider — no API key required
|
|
107
|
+
OPENCODE_DEFAULT_MODEL = "opencode/deepseek-v4-flash-free"
|
|
108
|
+
|
|
109
|
+
# Per-user tmp dir for context sync — avoids cross-user permission conflicts
|
|
110
|
+
SQUID_HOME = f"/tmp/{os.getlogin()}/squid"
|
|
111
|
+
|
|
112
|
+
# Per-turn Git worktree isolation (see ADR-0025). On by default; set
|
|
113
|
+
# `worktree.enabled: false` in squid.yaml to use direct working-tree writes.
|
|
114
|
+
_worktree_cfg = _cfg.get("worktree", {})
|
|
115
|
+
WORKTREE_ISOLATION_ENABLED: bool = bool(_worktree_cfg.get("enabled", True))
|
|
116
|
+
WORKTREE_TRACK_DIRTY_CHANGES: bool = bool(_worktree_cfg.get("track_dirty_changes", False))
|
|
117
|
+
|
|
118
|
+
# Dependency/cache directory names to symlink from a code root into each fresh
|
|
119
|
+
# per-turn worktree. auto_link_ignored_dirs only links ignored dirs whose
|
|
120
|
+
# basename appears in this allowlist.
|
|
121
|
+
WORKTREE_AUTO_LINK_IGNORED_DIRS: bool = bool(_worktree_cfg.get("auto_link_ignored_dirs", True))
|
|
122
|
+
DEPENDENCY_DIRS: list[str] = _worktree_cfg.get("dependency_dirs", [
|
|
123
|
+
"node_modules", ".venv", "venv", "env", ".tox", "__pypackages__",
|
|
124
|
+
"vendor", "target", ".bundle", "Pods", ".cargo", ".stack-work", "elm-stuff",
|
|
125
|
+
])
|
|
126
|
+
|
|
127
|
+
# Proxy environment to inject into every CLI subprocess, or None if disabled.
|
|
128
|
+
_proxy_cfg = _cfg.get("proxy", {})
|
|
129
|
+
PROXY_ENV: Optional[dict] = None
|
|
130
|
+
if _proxy_cfg.get("enabled"):
|
|
131
|
+
_proxy_url = _proxy_cfg.get("url", "http://127.0.0.1:8080")
|
|
132
|
+
_cert = os.path.expanduser(_proxy_cfg.get("ssl_cert_file", "~/.mitmproxy/mitmproxy-ca-cert.pem"))
|
|
133
|
+
PROXY_ENV = {
|
|
134
|
+
"HTTP_PROXY": _proxy_url,
|
|
135
|
+
"HTTPS_PROXY": _proxy_url,
|
|
136
|
+
"SSL_CERT_FILE": _cert,
|
|
137
|
+
}
|
agent/context_sync.py
ADDED
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
"""
|
|
2
|
+
context_sync.py — keep /tmp/<user>/squid in sync with squid/context/.
|
|
3
|
+
|
|
4
|
+
The tmp dir cannot be a symlink to context/ because Claude Code resolves the
|
|
5
|
+
real path, which would load CLAUDE.md and MCP config from the wrong scope.
|
|
6
|
+
Instead we maintain it as a real directory and rsync into it.
|
|
7
|
+
|
|
8
|
+
Strategy (Option 4):
|
|
9
|
+
- sync_now() — blocking full sync; call once at daemon startup
|
|
10
|
+
- maybe_sync() — async stat-check; call before each prompt; only rsyncs
|
|
11
|
+
when context/ has changed since the last sync
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
import os
|
|
18
|
+
import shutil
|
|
19
|
+
import subprocess
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
from .config import SQUID_HOME
|
|
23
|
+
|
|
24
|
+
log = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
CONTEXT_DIR = str(Path.home() / ".squid" / "context")
|
|
27
|
+
# Bundled default context (topics/roles seed content), shipped alongside
|
|
28
|
+
# ui/ and config/ in the wheel (see pyproject.toml's force-include). Sibling
|
|
29
|
+
# to this package the same way UI_DIR is in agent/server.py.
|
|
30
|
+
_BUNDLED_CONTEXT = Path(__file__).parent.parent / "context"
|
|
31
|
+
_last_sync_mtime: float = 0.0
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _seed_context_if_empty() -> None:
|
|
35
|
+
"""Copy the bundled default context/ into ~/.squid/context on first run.
|
|
36
|
+
|
|
37
|
+
bin/install.sh and bin/start.sh do this same "only if empty" copy for a
|
|
38
|
+
source checkout, but a pip/pipx install never runs either script, so
|
|
39
|
+
this has to also happen here — otherwise a fresh pipx install has no
|
|
40
|
+
default topics/roles content and nothing seeds it.
|
|
41
|
+
"""
|
|
42
|
+
context_dir = Path(CONTEXT_DIR)
|
|
43
|
+
if context_dir.exists() and any(context_dir.iterdir()):
|
|
44
|
+
return
|
|
45
|
+
if not _BUNDLED_CONTEXT.exists():
|
|
46
|
+
return
|
|
47
|
+
context_dir.mkdir(parents=True, exist_ok=True)
|
|
48
|
+
shutil.copytree(_BUNDLED_CONTEXT, context_dir, dirs_exist_ok=True)
|
|
49
|
+
_CODEX_MANAGED_BEGIN = "# BEGIN SQUID MANAGED MCP"
|
|
50
|
+
_CODEX_MANAGED_END = "# END SQUID MANAGED MCP"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _rmdir_empty_parents(path: Path, stop: Path) -> None:
|
|
54
|
+
parent = path.parent
|
|
55
|
+
while parent != stop and parent.is_relative_to(stop):
|
|
56
|
+
try:
|
|
57
|
+
parent.rmdir()
|
|
58
|
+
except OSError:
|
|
59
|
+
return
|
|
60
|
+
parent = parent.parent
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _remove_json_server(path: Path, section: str, empty_shape: dict, context_dir: Path) -> None:
|
|
64
|
+
if not path.exists():
|
|
65
|
+
return
|
|
66
|
+
try:
|
|
67
|
+
config = json.loads(path.read_text(encoding="utf-8"))
|
|
68
|
+
except json.JSONDecodeError:
|
|
69
|
+
return
|
|
70
|
+
if not isinstance(config, dict):
|
|
71
|
+
return
|
|
72
|
+
servers = config.get(section)
|
|
73
|
+
if not isinstance(servers, dict) or "squid" not in servers:
|
|
74
|
+
return
|
|
75
|
+
servers.pop("squid", None)
|
|
76
|
+
if config == empty_shape:
|
|
77
|
+
path.unlink()
|
|
78
|
+
_rmdir_empty_parents(path, context_dir)
|
|
79
|
+
return
|
|
80
|
+
path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _remove_codex_managed_block(path: Path, context_dir: Path) -> None:
|
|
84
|
+
if not path.exists():
|
|
85
|
+
return
|
|
86
|
+
text = path.read_text(encoding="utf-8")
|
|
87
|
+
start = text.find(_CODEX_MANAGED_BEGIN)
|
|
88
|
+
end = text.find(_CODEX_MANAGED_END)
|
|
89
|
+
if start < 0 or end < start:
|
|
90
|
+
return
|
|
91
|
+
end += len(_CODEX_MANAGED_END)
|
|
92
|
+
updated = (text[:start].rstrip() + "\n" + text[end:].lstrip()).strip()
|
|
93
|
+
if not updated:
|
|
94
|
+
path.unlink()
|
|
95
|
+
_rmdir_empty_parents(path, context_dir)
|
|
96
|
+
return
|
|
97
|
+
path.write_text(updated + "\n", encoding="utf-8")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def _remove_legacy_mcp_context_files(context_dir: Path) -> None:
|
|
101
|
+
_remove_json_server(context_dir / ".mcp.json", "mcpServers", {"mcpServers": {}}, context_dir)
|
|
102
|
+
_remove_json_server(context_dir / ".cursor" / "mcp.json", "mcpServers", {"mcpServers": {}}, context_dir)
|
|
103
|
+
_remove_json_server(
|
|
104
|
+
context_dir / "opencode.json",
|
|
105
|
+
"mcp",
|
|
106
|
+
{"$schema": "https://opencode.ai/config.json", "mcp": {}},
|
|
107
|
+
context_dir,
|
|
108
|
+
)
|
|
109
|
+
_remove_codex_managed_block(context_dir / ".codex" / "config.toml", context_dir)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _tree_mtime(path: str) -> float:
|
|
113
|
+
"""Max mtime across all entries under path (handles nested .claude/ dirs)."""
|
|
114
|
+
result = 0.0
|
|
115
|
+
for root, dirs, files in os.walk(path):
|
|
116
|
+
for name in dirs + files:
|
|
117
|
+
try:
|
|
118
|
+
result = max(result, os.stat(os.path.join(root, name)).st_mtime)
|
|
119
|
+
except OSError:
|
|
120
|
+
pass
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _rsync() -> bool:
|
|
125
|
+
result = subprocess.run(
|
|
126
|
+
["rsync", "-a", "--delete", f"{CONTEXT_DIR}/", SQUID_HOME],
|
|
127
|
+
capture_output=True,
|
|
128
|
+
)
|
|
129
|
+
if result.returncode != 0:
|
|
130
|
+
log.warning("context sync failed: %s", result.stderr.decode().strip())
|
|
131
|
+
return False
|
|
132
|
+
return True
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def sync_now() -> None:
|
|
136
|
+
"""Full blocking sync at daemon startup. Creates SQUID_HOME if needed."""
|
|
137
|
+
global _last_sync_mtime
|
|
138
|
+
os.makedirs(SQUID_HOME, exist_ok=True)
|
|
139
|
+
_seed_context_if_empty()
|
|
140
|
+
_remove_legacy_mcp_context_files(Path(CONTEXT_DIR))
|
|
141
|
+
if _rsync():
|
|
142
|
+
_last_sync_mtime = _tree_mtime(CONTEXT_DIR)
|
|
143
|
+
log.info("context synced → %s", SQUID_HOME)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
async def maybe_sync() -> None:
|
|
147
|
+
"""Async stat-check before each prompt. No-op when nothing changed."""
|
|
148
|
+
global _last_sync_mtime
|
|
149
|
+
loop = asyncio.get_event_loop()
|
|
150
|
+
current = await loop.run_in_executor(None, _tree_mtime, CONTEXT_DIR)
|
|
151
|
+
if current <= _last_sync_mtime:
|
|
152
|
+
return
|
|
153
|
+
log.info("context changed, resyncing → %s", SQUID_HOME)
|
|
154
|
+
proc = await asyncio.create_subprocess_exec(
|
|
155
|
+
"rsync", "-a", "--delete", f"{CONTEXT_DIR}/", SQUID_HOME,
|
|
156
|
+
stdout=asyncio.subprocess.DEVNULL,
|
|
157
|
+
stderr=asyncio.subprocess.PIPE,
|
|
158
|
+
)
|
|
159
|
+
_, stderr = await proc.communicate()
|
|
160
|
+
if proc.returncode != 0:
|
|
161
|
+
log.warning("context sync failed: %s", stderr.decode().strip())
|
|
162
|
+
else:
|
|
163
|
+
_last_sync_mtime = current
|
agent/creds.py
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""
|
|
2
|
+
creds.py — Store and retrieve service credentials.
|
|
3
|
+
Saved to ~/.squid/squid-creds.json alongside ~/.squid/squid.db and
|
|
4
|
+
~/.squid/squid.yaml.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import json
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
_CREDS_PATH = Path.home() / ".squid" / "squid-creds.json"
|
|
12
|
+
_CODEX_AUTH_PATH = Path.home() / ".codex" / "auth.json"
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def load() -> dict:
|
|
16
|
+
try:
|
|
17
|
+
return json.loads(_CREDS_PATH.read_text())
|
|
18
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
19
|
+
return {}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _save(data: dict) -> None:
|
|
23
|
+
existing = load()
|
|
24
|
+
existing.update(data)
|
|
25
|
+
_CREDS_PATH.write_text(json.dumps(existing, indent=2))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def save(org_id: str, session_key: str) -> None:
|
|
29
|
+
_save({"claude_org_id": org_id, "claude_session_key": session_key})
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def save_codex(token: str) -> None:
|
|
33
|
+
_save({"codex_token": token})
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def get_org_id() -> Optional[str]:
|
|
37
|
+
d = load()
|
|
38
|
+
return d.get("claude_org_id") or d.get("org_id")
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_session_key() -> Optional[str]:
|
|
42
|
+
d = load()
|
|
43
|
+
return d.get("claude_session_key") or d.get("session_key")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def get_codex_token() -> Optional[str]:
|
|
47
|
+
return load().get("codex_token")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def get_codex_cli_auth() -> dict:
|
|
51
|
+
try:
|
|
52
|
+
data = json.loads(_CODEX_AUTH_PATH.read_text())
|
|
53
|
+
except (FileNotFoundError, json.JSONDecodeError):
|
|
54
|
+
return {}
|
|
55
|
+
tokens = data.get("tokens")
|
|
56
|
+
return tokens if isinstance(tokens, dict) else {}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _extract_cookies(domain: str) -> dict:
|
|
60
|
+
"""Read all cookies for a domain from Chrome then Safari."""
|
|
61
|
+
import browser_cookie3
|
|
62
|
+
errors = []
|
|
63
|
+
for loader, name in [(browser_cookie3.chrome, "Chrome"), (browser_cookie3.safari, "Safari")]:
|
|
64
|
+
try:
|
|
65
|
+
jar = loader(domain_name=domain)
|
|
66
|
+
cookies = {c.name: c.value for c in jar}
|
|
67
|
+
if cookies:
|
|
68
|
+
return cookies
|
|
69
|
+
except PermissionError:
|
|
70
|
+
errors.append(
|
|
71
|
+
f"{name}: permission denied — grant Full Disk Access to Terminal in "
|
|
72
|
+
f"System Settings → Privacy & Security → Full Disk Access"
|
|
73
|
+
)
|
|
74
|
+
except Exception as e:
|
|
75
|
+
errors.append(f"{name}: {e}")
|
|
76
|
+
raise RuntimeError(" | ".join(errors) if errors else
|
|
77
|
+
f"No cookies found for {domain} in Chrome or Safari. Make sure you are logged in.")
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def read_chrome_claude_creds() -> dict:
|
|
81
|
+
"""Read sessionKey and lastActiveOrg from Chrome or Safari cookie store (macOS only)."""
|
|
82
|
+
cookies = _extract_cookies("claude.ai")
|
|
83
|
+
result = {k: v for k, v in cookies.items() if k in ("sessionKey", "lastActiveOrg")}
|
|
84
|
+
if not result.get("sessionKey"):
|
|
85
|
+
raise RuntimeError("sessionKey not found in claude.ai cookies. Make sure you are logged in.")
|
|
86
|
+
return result
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def save_max_budget(gauge: str, amount: float) -> None:
|
|
90
|
+
data = load()
|
|
91
|
+
budgets = data.get("max_budgets") or {}
|
|
92
|
+
budgets[gauge] = amount
|
|
93
|
+
data["max_budgets"] = budgets
|
|
94
|
+
if gauge == "deepseek":
|
|
95
|
+
data.pop("deepseek_max_budget", None) # superseded legacy flat key
|
|
96
|
+
_CREDS_PATH.write_text(json.dumps(data, indent=2))
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def get_max_budget(gauge: str) -> Optional[float]:
|
|
100
|
+
data = load()
|
|
101
|
+
budgets = data.get("max_budgets") or {}
|
|
102
|
+
if gauge in budgets:
|
|
103
|
+
return budgets[gauge]
|
|
104
|
+
if gauge == "deepseek":
|
|
105
|
+
return data.get("deepseek_max_budget") # legacy flat key
|
|
106
|
+
return None
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def clear_max_budget(gauge: str) -> None:
|
|
110
|
+
data = load()
|
|
111
|
+
budgets = data.get("max_budgets") or {}
|
|
112
|
+
budgets.pop(gauge, None)
|
|
113
|
+
data["max_budgets"] = budgets
|
|
114
|
+
if gauge == "deepseek":
|
|
115
|
+
data.pop("deepseek_max_budget", None)
|
|
116
|
+
_CREDS_PATH.write_text(json.dumps(data, indent=2))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def get_cursor_token() -> Optional[str]:
|
|
120
|
+
"""Read Cursor access token from macOS Keychain (where cursor-agent stores it)."""
|
|
121
|
+
try:
|
|
122
|
+
import subprocess
|
|
123
|
+
result = subprocess.run(
|
|
124
|
+
["security", "find-generic-password", "-s", "cursor-access-token", "-w"],
|
|
125
|
+
capture_output=True, text=True, timeout=5,
|
|
126
|
+
)
|
|
127
|
+
token = result.stdout.strip()
|
|
128
|
+
return token or None
|
|
129
|
+
except Exception:
|
|
130
|
+
return None
|