agentdir-cli 0.7.5__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.
agentdir/store.py ADDED
@@ -0,0 +1,270 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import platform
6
+ import re
7
+ import subprocess
8
+ from dataclasses import dataclass
9
+ from pathlib import Path
10
+
11
+ STORE_VERSION = "0.1"
12
+ CONFIG_DIR = ".agentdir"
13
+ INDEX_FILE = "agentdir.sqlite3"
14
+ SCOPE_CHOICES = ("project", "user", "global", "machine")
15
+
16
+ _ID_RE = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._@+-]{0,127}$")
17
+
18
+
19
+ class AgentDirError(Exception):
20
+ """Base exception for user-facing AgentDir failures."""
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class RootPaths:
25
+ root: Path
26
+ meta: Path
27
+ sessions: Path
28
+ actors: Path
29
+ queues: Path
30
+ artifacts: Path
31
+ archives: Path
32
+ indexes: Path
33
+ state: Path
34
+ hooks: Path
35
+ integrations: Path
36
+
37
+ @property
38
+ def index_path(self) -> Path:
39
+ return self.indexes / INDEX_FILE
40
+
41
+
42
+ def normalize_root(root: str | Path) -> Path:
43
+ return Path(root).expanduser().resolve()
44
+
45
+
46
+ def paths_for(root: str | Path) -> RootPaths:
47
+ root_path = normalize_root(root)
48
+ meta_path = root_path
49
+ legacy_meta = root_path / CONFIG_DIR
50
+ if not (root_path / "VERSION").exists() and (legacy_meta / "VERSION").exists():
51
+ meta_path = legacy_meta
52
+ return RootPaths(
53
+ root=root_path,
54
+ meta=meta_path,
55
+ sessions=root_path / "sessions",
56
+ actors=root_path / "actors",
57
+ queues=root_path / "queues",
58
+ artifacts=root_path / "artifacts",
59
+ archives=root_path / "archives",
60
+ indexes=root_path / "indexes",
61
+ state=root_path / "state",
62
+ hooks=root_path / "hooks",
63
+ integrations=root_path / "integrations",
64
+ )
65
+
66
+
67
+ def find_project_base(cwd: str | Path | None = None) -> Path:
68
+ start = Path(cwd or Path.cwd()).expanduser().resolve()
69
+ try:
70
+ result = subprocess.run(
71
+ ["git", "rev-parse", "--show-toplevel"],
72
+ cwd=start,
73
+ text=True,
74
+ capture_output=True,
75
+ check=False,
76
+ )
77
+ if result.returncode == 0 and result.stdout.strip():
78
+ return Path(result.stdout.strip()).resolve()
79
+ except OSError:
80
+ pass
81
+ return start
82
+
83
+
84
+ def machine_root() -> Path:
85
+ override = os.environ.get("AGENTDIR_MACHINE_ROOT")
86
+ if override:
87
+ return normalize_root(override)
88
+ legacy = _legacy_machine_root()
89
+ if legacy.exists():
90
+ return legacy
91
+ candidate = _platform_site_data_path()
92
+ if candidate is not None:
93
+ return candidate
94
+ return legacy
95
+
96
+
97
+ def user_root() -> Path:
98
+ override = os.environ.get("AGENTDIR_USER_ROOT")
99
+ if override:
100
+ return normalize_root(override)
101
+ legacy = Path.home().expanduser().resolve() / CONFIG_DIR
102
+ if legacy.exists():
103
+ return legacy
104
+ candidate = _platform_user_data_path()
105
+ if candidate is not None:
106
+ return candidate
107
+ return _fallback_user_data_path()
108
+
109
+
110
+ def _legacy_machine_root() -> Path:
111
+ if platform.system() == "Darwin":
112
+ return Path("/Library/Application Support/AgentDir").resolve()
113
+ return Path("/var/lib/agentdir").resolve()
114
+
115
+
116
+ def _platform_user_data_path() -> Path | None:
117
+ try:
118
+ from platformdirs import user_data_path
119
+ except ImportError:
120
+ return None
121
+ return Path(user_data_path("AgentDir", appauthor=False)).expanduser().resolve()
122
+
123
+
124
+ def _platform_site_data_path() -> Path | None:
125
+ try:
126
+ from platformdirs import site_data_path
127
+ except ImportError:
128
+ return None
129
+ return Path(site_data_path("AgentDir", appauthor=False)).expanduser().resolve()
130
+
131
+
132
+ def _fallback_user_data_path() -> Path:
133
+ if platform.system() == "Darwin":
134
+ return (Path.home().expanduser() / "Library" / "Application Support" / "AgentDir").resolve()
135
+ xdg = os.environ.get("XDG_DATA_HOME")
136
+ if xdg:
137
+ return (Path(xdg).expanduser() / "AgentDir").resolve()
138
+ return (Path.home().expanduser() / ".local" / "share" / "AgentDir").resolve()
139
+
140
+
141
+ def root_for_scope(scope: str | None = None, cwd: str | Path | None = None) -> Path:
142
+ selected = scope or os.environ.get("AGENTDIR_SCOPE") or "project"
143
+ if selected not in SCOPE_CHOICES:
144
+ raise AgentDirError(
145
+ f"Invalid scope {selected!r}; expected one of {', '.join(SCOPE_CHOICES)}"
146
+ )
147
+ if selected == "project":
148
+ return find_project_base(cwd) / CONFIG_DIR
149
+ if selected in {"user", "global"}:
150
+ return user_root()
151
+ return machine_root()
152
+
153
+
154
+ def resolve_root(
155
+ root: str | Path | None = None,
156
+ scope: str | None = None,
157
+ cwd: str | Path | None = None,
158
+ ) -> Path:
159
+ if root is not None:
160
+ return normalize_root(root)
161
+ if scope is None and os.environ.get("AGENTDIR_ROOT"):
162
+ return normalize_root(os.environ["AGENTDIR_ROOT"])
163
+ return root_for_scope(scope, cwd)
164
+
165
+
166
+ def validate_id(value: str, label: str = "id") -> str:
167
+ if not _ID_RE.match(value) or "/" in value or ":" in value or value.startswith("."):
168
+ raise AgentDirError(
169
+ f"Invalid {label} {value!r}; use filesystem-safe letters, numbers, dot, underscore, at, plus, or dash"
170
+ )
171
+ return value
172
+
173
+
174
+ def ensure_mailbox(path: Path) -> Path:
175
+ for name in ("tmp", "new", "cur"):
176
+ (path / name).mkdir(parents=True, exist_ok=True)
177
+ return path
178
+
179
+
180
+ def is_mailbox(path: Path) -> bool:
181
+ return all((path / name).is_dir() for name in ("tmp", "new", "cur"))
182
+
183
+
184
+ def init_root(root: str | Path) -> RootPaths:
185
+ paths = paths_for(root)
186
+ paths.root.mkdir(parents=True, exist_ok=True)
187
+ for directory in (
188
+ paths.meta,
189
+ paths.sessions,
190
+ paths.actors,
191
+ paths.queues,
192
+ paths.artifacts / "blobs" / "sha256",
193
+ paths.archives / "sessions",
194
+ paths.indexes,
195
+ paths.state,
196
+ paths.hooks,
197
+ paths.integrations,
198
+ ):
199
+ directory.mkdir(parents=True, exist_ok=True)
200
+
201
+ version_path = paths.meta / "VERSION"
202
+ if version_path.exists():
203
+ current = version_path.read_text(encoding="utf-8").strip()
204
+ if current != STORE_VERSION:
205
+ raise AgentDirError(
206
+ f"Unsupported AgentDir root version {current!r}; expected {STORE_VERSION}"
207
+ )
208
+ else:
209
+ version_path.write_text(f"{STORE_VERSION}\n", encoding="utf-8")
210
+
211
+ config_path = paths.meta / "config.json"
212
+ if not config_path.exists():
213
+ config = {"version": STORE_VERSION, "index": str(paths.index_path.relative_to(paths.root))}
214
+ config_path.write_text(json.dumps(config, indent=2) + "\n", encoding="utf-8")
215
+ return paths
216
+
217
+
218
+ def require_root(root: str | Path) -> RootPaths:
219
+ paths = paths_for(root)
220
+ if not (paths.meta / "VERSION").is_file():
221
+ raise AgentDirError(f"Not an AgentDir root: {paths.root}")
222
+ return paths
223
+
224
+
225
+ def session_mailbox(root: str | Path, session_id: str, create: bool = True) -> Path:
226
+ paths = require_root(root)
227
+ validate_id(session_id, "session id")
228
+ mailbox = paths.sessions / session_id / "Maildir"
229
+ if create:
230
+ ensure_mailbox(mailbox)
231
+ return mailbox
232
+
233
+
234
+ def actor_dir(root: str | Path, actor_id: str) -> Path:
235
+ paths = require_root(root)
236
+ validate_id(actor_id, "actor id")
237
+ return paths.actors / actor_id
238
+
239
+
240
+ def actor_mailbox(root: str | Path, actor_id: str, box: str, create: bool = True) -> Path:
241
+ if box not in {"inbox", "outbox"}:
242
+ raise AgentDirError(f"Unknown actor mailbox {box!r}")
243
+ mailbox = actor_dir(root, actor_id) / box / "Maildir"
244
+ if create:
245
+ ensure_mailbox(mailbox)
246
+ return mailbox
247
+
248
+
249
+ def queue_mailbox(root: str | Path, queue_id: str, create: bool = True) -> Path:
250
+ paths = require_root(root)
251
+ validate_id(queue_id, "queue id")
252
+ mailbox = paths.queues / queue_id / "Maildir"
253
+ if create:
254
+ ensure_mailbox(mailbox)
255
+ return mailbox
256
+
257
+
258
+ def discover_mailboxes(root: str | Path) -> list[tuple[str, Path]]:
259
+ paths = require_root(root)
260
+ discovered: list[tuple[str, Path]] = []
261
+ for session in sorted(paths.sessions.glob("*/Maildir")):
262
+ if is_mailbox(session):
263
+ discovered.append(("session", session))
264
+ for actor_box in sorted(paths.actors.glob("*/*/Maildir")):
265
+ if is_mailbox(actor_box):
266
+ discovered.append(("actor", actor_box))
267
+ for queue in sorted(paths.queues.glob("*/Maildir")):
268
+ if is_mailbox(queue):
269
+ discovered.append(("queue", queue))
270
+ return discovered
agentdir/upgrade.py ADDED
@@ -0,0 +1,252 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import os
5
+ import shutil
6
+ import subprocess
7
+ import sys
8
+ import tempfile
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import Any
12
+
13
+ from . import __version__
14
+ from .store import AgentDirError
15
+
16
+ DEFAULT_REPO = "jstxn/agentdir"
17
+
18
+
19
+ @dataclass(frozen=True)
20
+ class UpgradeOptions:
21
+ repo: str = DEFAULT_REPO
22
+ version: str | None = None
23
+ adopt: bool = True
24
+ install_skill: str = "user"
25
+ hooks: bool = True
26
+ dry_run: bool = False
27
+
28
+
29
+ def upgrade_agentdir(options: UpgradeOptions) -> dict[str, Any]:
30
+ target_version = options.version or _latest_release_tag(options.repo, dry_run=options.dry_run)
31
+ cwd = Path.cwd()
32
+ repo_root = _git_root(cwd) if options.adopt else None
33
+ before = _command_version(_agentdir_bin())
34
+ result: dict[str, Any] = {
35
+ "current_process_version": __version__,
36
+ "installed_version_before": before,
37
+ "target_version": target_version,
38
+ "repo": options.repo,
39
+ "dry_run": options.dry_run,
40
+ "install": {"planned": True, "ok": None},
41
+ "adopt": {
42
+ "planned": bool(repo_root),
43
+ "ok": None,
44
+ "root": str(repo_root) if repo_root else None,
45
+ "install_skill": options.install_skill,
46
+ "hooks": options.hooks,
47
+ },
48
+ "doctor": {"planned": bool(repo_root), "ok": None},
49
+ }
50
+ if options.dry_run:
51
+ result["install"]["command"] = _install_description(options.repo, target_version)
52
+ if repo_root:
53
+ result["adopt"]["command"] = _adopt_description(options.install_skill, options.hooks)
54
+ return result
55
+
56
+ install_output = _run_installer(options.repo, target_version)
57
+ result["install"].update({"ok": install_output.returncode == 0, "stderr": install_output.stderr})
58
+ if install_output.returncode != 0:
59
+ result["install"]["stdout"] = install_output.stdout
60
+ return result
61
+
62
+ agentdir_bin = _agentdir_bin()
63
+ after = _command_version(agentdir_bin)
64
+ result["installed_version_after"] = after
65
+ result["install"]["version_ok"] = _version_matches(after, target_version)
66
+ if repo_root:
67
+ adopt_output = _run_adopt(agentdir_bin, repo_root, options.install_skill, options.hooks)
68
+ result["adopt"].update(
69
+ {
70
+ "ok": adopt_output.returncode == 0,
71
+ "stdout": adopt_output.stdout,
72
+ "stderr": adopt_output.stderr,
73
+ }
74
+ )
75
+ doctor_output = subprocess.run(
76
+ [agentdir_bin, "doctor", "--json"],
77
+ cwd=repo_root,
78
+ text=True,
79
+ capture_output=True,
80
+ check=False,
81
+ )
82
+ doctor_payload = _json_or_text(doctor_output.stdout)
83
+ result["doctor"].update(
84
+ {
85
+ "ok": doctor_output.returncode == 0,
86
+ "result": doctor_payload,
87
+ "stderr": doctor_output.stderr,
88
+ }
89
+ )
90
+ return result
91
+
92
+
93
+ def format_upgrade_result(result: dict[str, Any]) -> str:
94
+ lines = [
95
+ f"current_process_version={result['current_process_version']}",
96
+ f"installed_version_before={result.get('installed_version_before') or 'unknown'}",
97
+ f"target_version={result['target_version']}",
98
+ ]
99
+ if result["dry_run"]:
100
+ lines.append(f"install={result['install']['command']}")
101
+ if result["adopt"]["planned"]:
102
+ lines.append(f"adopt={result['adopt']['command']}")
103
+ else:
104
+ lines.append("adopt=skipped outside git repository")
105
+ return "\n".join(lines)
106
+
107
+ lines.append(f"install_ok={str(bool(result['install']['ok'])).lower()}")
108
+ if not result["install"]["ok"]:
109
+ lines.append("upgrade failed during reinstall")
110
+ return "\n".join(lines)
111
+ lines.append(f"installed_version_after={result.get('installed_version_after') or 'unknown'}")
112
+ lines.append(f"install_version_ok={str(bool(result['install'].get('version_ok'))).lower()}")
113
+ if result["adopt"]["planned"]:
114
+ lines.append(f"adopt_ok={str(bool(result['adopt']['ok'])).lower()}")
115
+ lines.append(f"adopt_root={result['adopt']['root']}")
116
+ else:
117
+ lines.append("adopt=skipped outside git repository")
118
+ if result["doctor"]["planned"]:
119
+ lines.append(f"doctor_ok={str(bool(result['doctor']['ok'])).lower()}")
120
+ doctor_result = result["doctor"].get("result")
121
+ if isinstance(doctor_result, dict):
122
+ for error in doctor_result.get("errors", []):
123
+ lines.append(f"doctor_error={error}")
124
+ for warning in doctor_result.get("warnings", []):
125
+ lines.append(f"doctor_warning={warning}")
126
+ return "\n".join(lines)
127
+
128
+
129
+ def upgrade_exit_code(result: dict[str, Any]) -> int:
130
+ if result["dry_run"]:
131
+ return 0
132
+ if not result["install"]["ok"]:
133
+ return 1
134
+ if not result["install"].get("version_ok"):
135
+ return 1
136
+ if result["adopt"]["planned"] and not result["adopt"]["ok"]:
137
+ return 1
138
+ if result["doctor"]["planned"] and not result["doctor"]["ok"]:
139
+ return 1
140
+ return 0
141
+
142
+
143
+ def _latest_release_tag(repo: str, *, dry_run: bool) -> str:
144
+ if dry_run:
145
+ return "latest"
146
+ result = subprocess.run(
147
+ ["gh", "api", f"repos/{repo}/releases/latest", "--jq", ".tag_name"],
148
+ text=True,
149
+ capture_output=True,
150
+ check=False,
151
+ )
152
+ if result.returncode != 0 or not result.stdout.strip():
153
+ raise AgentDirError(result.stderr.strip() or f"could not resolve latest release for {repo}")
154
+ return result.stdout.strip()
155
+
156
+
157
+ def _run_installer(repo: str, version: str) -> subprocess.CompletedProcess[str]:
158
+ with tempfile.TemporaryDirectory(prefix="agentdir-upgrade.") as tmp:
159
+ install_script = Path(tmp) / "install-agentdir.sh"
160
+ fetch = subprocess.run(
161
+ [
162
+ "gh",
163
+ "api",
164
+ "-H",
165
+ "Accept: application/vnd.github.raw",
166
+ f"repos/{repo}/contents/scripts/install.sh?ref={version}",
167
+ ],
168
+ text=True,
169
+ capture_output=True,
170
+ check=False,
171
+ )
172
+ if fetch.returncode != 0:
173
+ return fetch
174
+ install_script.write_text(fetch.stdout, encoding="utf-8")
175
+ install_script.chmod(0o700)
176
+ env = os.environ.copy()
177
+ env["AGENTDIR_REPO"] = repo
178
+ env["AGENTDIR_VERSION"] = version
179
+ env["AGENTDIR_WHEEL"] = ""
180
+ return subprocess.run(
181
+ ["bash", str(install_script)],
182
+ text=True,
183
+ capture_output=True,
184
+ check=False,
185
+ env=env,
186
+ )
187
+
188
+
189
+ def _run_adopt(
190
+ agentdir_bin: str,
191
+ repo_root: Path,
192
+ install_skill: str,
193
+ hooks: bool,
194
+ ) -> subprocess.CompletedProcess[str]:
195
+ command = [agentdir_bin, "adopt", "--install-skill", install_skill]
196
+ if not hooks:
197
+ command.append("--no-hooks")
198
+ return subprocess.run(command, cwd=repo_root, text=True, capture_output=True, check=False)
199
+
200
+
201
+ def _git_root(cwd: Path) -> Path | None:
202
+ result = subprocess.run(
203
+ ["git", "rev-parse", "--show-toplevel"],
204
+ cwd=cwd,
205
+ text=True,
206
+ capture_output=True,
207
+ check=False,
208
+ )
209
+ if result.returncode != 0 or not result.stdout.strip():
210
+ return None
211
+ return Path(result.stdout.strip()).resolve()
212
+
213
+
214
+ def _agentdir_bin() -> str:
215
+ invoked = Path(sys.argv[0])
216
+ if invoked.name == "agentdir" and invoked.exists():
217
+ return str(invoked.resolve())
218
+ return shutil.which("agentdir") or str(Path.home() / ".local" / "bin" / "agentdir")
219
+
220
+
221
+ def _command_version(command: str) -> str | None:
222
+ try:
223
+ result = subprocess.run([command, "--version"], text=True, capture_output=True, check=False)
224
+ except OSError:
225
+ return None
226
+ if result.returncode != 0:
227
+ return None
228
+ return result.stdout.strip()
229
+
230
+
231
+ def _version_matches(version_output: str | None, target_version: str) -> bool:
232
+ if not version_output:
233
+ return False
234
+ return target_version.lstrip("v") in version_output
235
+
236
+
237
+ def _json_or_text(text: str) -> Any:
238
+ try:
239
+ return json.loads(text)
240
+ except json.JSONDecodeError:
241
+ return text
242
+
243
+
244
+ def _install_description(repo: str, version: str) -> str:
245
+ return f"install {repo}@{version}"
246
+
247
+
248
+ def _adopt_description(install_skill: str, hooks: bool) -> str:
249
+ command = f"agentdir adopt --install-skill {install_skill}"
250
+ if not hooks:
251
+ command += " --no-hooks"
252
+ return command