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 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