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/federation.py ADDED
@@ -0,0 +1,530 @@
1
+ from __future__ import annotations
2
+
3
+ import hashlib
4
+ import json
5
+ import subprocess
6
+ from datetime import UTC, datetime
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from .index import rebuild_index
11
+ from .memory import DEFAULT_MIN_SCORE, RETRIEVAL_HYBRID, search_memory
12
+ from .rendering import rich_root_diagnostics
13
+ from .store import AgentDirError, paths_for, require_root, validate_id
14
+
15
+ ROOT_REGISTRY_FILE = "registered-roots.json"
16
+ VISIBILITY_CHOICES = ("private", "team", "machine")
17
+
18
+
19
+ def register_root(
20
+ controller_root: str | Path,
21
+ root: str | Path,
22
+ *,
23
+ name: str | None = None,
24
+ visibility: str = "private",
25
+ ) -> dict[str, Any]:
26
+ if visibility not in VISIBILITY_CHOICES:
27
+ raise AgentDirError(
28
+ f"Invalid visibility {visibility!r}; expected one of {', '.join(VISIBILITY_CHOICES)}"
29
+ )
30
+ controller = require_root(controller_root)
31
+ source_root = resolve_registered_root(root)
32
+ root_id = root_id_for_path(source_root)
33
+ registry = _read_registry(controller.root)
34
+ entry = {
35
+ "root_id": root_id,
36
+ "name": name or default_root_name(source_root),
37
+ "root_path": str(source_root),
38
+ "visibility": visibility,
39
+ "registered_at": now_iso(),
40
+ }
41
+ roots = [item for item in registry["roots"] if item["root_id"] != root_id]
42
+ roots.append(entry)
43
+ registry["roots"] = sorted(roots, key=lambda item: (item["name"], item["root_id"]))
44
+ _write_registry(controller.root, registry)
45
+ return entry
46
+
47
+
48
+ def list_registered_roots(controller_root: str | Path, *, group: str | None = None) -> list[dict[str, Any]]:
49
+ controller = require_root(controller_root)
50
+ registry = _read_registry(controller.root)
51
+ roots = registry["roots"]
52
+ if group:
53
+ wanted = set(_root_ids_for_group(registry, group))
54
+ roots = [root for root in roots if root["root_id"] in wanted]
55
+ return [{**root, "available": Path(root["root_path"]).joinpath("VERSION").is_file()} for root in roots]
56
+
57
+
58
+ def remove_registered_root(controller_root: str | Path, identifier: str) -> dict[str, Any]:
59
+ controller = require_root(controller_root)
60
+ registry = _read_registry(controller.root)
61
+ remaining: list[dict[str, Any]] = []
62
+ removed: dict[str, Any] | None = None
63
+ for root in registry["roots"]:
64
+ if identifier in {root["root_id"], root["name"], root["root_path"]}:
65
+ removed = root
66
+ else:
67
+ remaining.append(root)
68
+ if removed is None:
69
+ raise AgentDirError(f"Unknown registered root: {identifier}")
70
+ registry["roots"] = remaining
71
+ _write_registry(controller.root, registry)
72
+ return removed
73
+
74
+
75
+ def rebuild_registered_roots(
76
+ controller_root: str | Path,
77
+ *,
78
+ group: str | None = None,
79
+ stale_only: bool = False,
80
+ ) -> list[dict[str, Any]]:
81
+ results: list[dict[str, Any]] = []
82
+ stale_ids = {
83
+ item["root_id"]
84
+ for item in doctor_registered_roots(controller_root, group=group)
85
+ if item.get("stale")
86
+ } if stale_only else set()
87
+ for root in list_registered_roots(controller_root, group=group):
88
+ item = {**root}
89
+ if stale_only and root["root_id"] not in stale_ids:
90
+ item["ok"] = True
91
+ item["skipped"] = True
92
+ item["reason"] = "fresh"
93
+ results.append(item)
94
+ continue
95
+ if not root["available"]:
96
+ item["ok"] = False
97
+ item["error"] = "missing root"
98
+ results.append(item)
99
+ continue
100
+ try:
101
+ result = rebuild_index(root["root_path"])
102
+ except AgentDirError as exc:
103
+ item["ok"] = False
104
+ item["error"] = str(exc)
105
+ else:
106
+ item["ok"] = True
107
+ item["indexed"] = result.indexed
108
+ item["malformed"] = result.malformed
109
+ item["duplicates"] = result.duplicates
110
+ results.append(item)
111
+ return results
112
+
113
+
114
+ def search_federated_memory(
115
+ controller_root: str | Path,
116
+ query: str,
117
+ *,
118
+ session_id: str | None = None,
119
+ event_type: str | None = None,
120
+ actor: str | None = None,
121
+ task_id: str | None = None,
122
+ tool: str | None = None,
123
+ git_head: str | None = None,
124
+ workspace: str | None = None,
125
+ since: str | None = None,
126
+ until: str | None = None,
127
+ limit: int = 10,
128
+ min_score: float = DEFAULT_MIN_SCORE,
129
+ retrieval_mode: str = RETRIEVAL_HYBRID,
130
+ rebuild: bool = True,
131
+ group: str | None = None,
132
+ ) -> list[dict[str, Any]]:
133
+ roots = list_registered_roots(controller_root, group=group)
134
+ if not roots:
135
+ if group:
136
+ raise AgentDirError(f"No registered roots in group {group!r}")
137
+ raise AgentDirError("No registered roots; run agentdir roots register <root>")
138
+
139
+ per_root_limit = max(limit, 5)
140
+ hits: list[dict[str, Any]] = []
141
+ for root in roots:
142
+ root_path = Path(root["root_path"])
143
+ if not root["available"]:
144
+ continue
145
+ if rebuild:
146
+ rebuild_index(root_path)
147
+ for row in search_memory(
148
+ root_path,
149
+ query,
150
+ session_id=session_id,
151
+ event_type=event_type,
152
+ actor=actor,
153
+ task_id=task_id,
154
+ tool=tool,
155
+ git_head=git_head,
156
+ workspace=workspace,
157
+ since=since,
158
+ until=until,
159
+ limit=per_root_limit,
160
+ min_score=min_score,
161
+ retrieval_mode=retrieval_mode,
162
+ ):
163
+ hits.append(_federated_row(root, row))
164
+
165
+ hits.sort(
166
+ key=lambda row: (
167
+ -float(row.get("memory_score") or 0),
168
+ row.get("date_utc") or row.get("indexed_at") or "",
169
+ row.get("source_id") or "",
170
+ )
171
+ )
172
+ return hits[:limit]
173
+
174
+
175
+ def suggest_roots(controller_root: str | Path, *, near: str | Path | None = None) -> list[dict[str, Any]]:
176
+ controller = require_root(controller_root)
177
+ registry = _read_registry(controller.root)
178
+ registered_ids = {root["root_id"] for root in registry["roots"]}
179
+ start = Path(near).expanduser().resolve() if near else Path.cwd().resolve()
180
+ candidates: set[Path] = set()
181
+ for candidate in _nearby_root_candidates(start):
182
+ resolved = _candidate_agentdir_root(candidate)
183
+ if resolved:
184
+ candidates.add(resolved)
185
+ suggestions: list[dict[str, Any]] = []
186
+ for root in sorted(candidates, key=lambda path: str(path)):
187
+ root_id = root_id_for_path(root)
188
+ project_path = root.parent if root.name == ".agentdir" else root
189
+ suggestions.append(
190
+ {
191
+ "root_id": root_id,
192
+ "name": default_root_name(root),
193
+ "root_path": str(root),
194
+ "project_path": str(project_path),
195
+ "registered": root_id in registered_ids,
196
+ "available": True,
197
+ "git_remote": _git_remote(project_path),
198
+ "last_indexed_at": _index_mtime(root),
199
+ }
200
+ )
201
+ return suggestions
202
+
203
+
204
+ def doctor_registered_roots(controller_root: str | Path, *, group: str | None = None) -> list[dict[str, Any]]:
205
+ diagnostics: list[dict[str, Any]] = []
206
+ for root in list_registered_roots(controller_root, group=group):
207
+ item = {**root, "ok": True, "errors": [], "warnings": []}
208
+ root_path = Path(root["root_path"])
209
+ if not root["available"]:
210
+ item["ok"] = False
211
+ item["errors"].append("missing root")
212
+ item["index_exists"] = False
213
+ item["stale"] = True
214
+ diagnostics.append(item)
215
+ continue
216
+ paths = paths_for(root_path)
217
+ index_path = paths.index_path
218
+ latest_record_mtime = _latest_record_mtime(paths.root)
219
+ index_mtime = index_path.stat().st_mtime if index_path.is_file() else None
220
+ stale = index_mtime is None or (
221
+ latest_record_mtime is not None and latest_record_mtime > index_mtime
222
+ )
223
+ item.update(
224
+ {
225
+ "index_exists": index_path.is_file(),
226
+ "index_path": str(index_path),
227
+ "index_mtime": index_mtime,
228
+ "latest_record_mtime": latest_record_mtime,
229
+ "stale": stale,
230
+ }
231
+ )
232
+ if not index_path.is_file():
233
+ item["warnings"].append("index missing")
234
+ elif stale:
235
+ item["warnings"].append("index stale")
236
+ diagnostics.append(item)
237
+ return diagnostics
238
+
239
+
240
+ def create_root_group(controller_root: str | Path, name: str, root_ids: list[str]) -> dict[str, Any]:
241
+ controller = require_root(controller_root)
242
+ validate_id(name, "root group")
243
+ registry = _read_registry(controller.root)
244
+ normalized = _validate_registered_root_ids(registry, root_ids)
245
+ if any(group["name"] == name for group in registry["groups"]):
246
+ raise AgentDirError(f"Root group already exists: {name}")
247
+ now = now_iso()
248
+ group = {"name": name, "root_ids": normalized, "created_at": now, "updated_at": now}
249
+ registry["groups"].append(group)
250
+ registry["groups"] = sorted(registry["groups"], key=lambda item: item["name"])
251
+ _write_registry(controller.root, registry)
252
+ return group
253
+
254
+
255
+ def list_root_groups(controller_root: str | Path) -> list[dict[str, Any]]:
256
+ controller = require_root(controller_root)
257
+ registry = _read_registry(controller.root)
258
+ roots_by_id = {root["root_id"]: root for root in registry["roots"]}
259
+ groups: list[dict[str, Any]] = []
260
+ for group in registry["groups"]:
261
+ groups.append(
262
+ {
263
+ **group,
264
+ "roots": [
265
+ roots_by_id[root_id]
266
+ for root_id in group["root_ids"]
267
+ if root_id in roots_by_id
268
+ ],
269
+ "missing_root_ids": [
270
+ root_id for root_id in group["root_ids"] if root_id not in roots_by_id
271
+ ],
272
+ }
273
+ )
274
+ return groups
275
+
276
+
277
+ def add_root_to_group(controller_root: str | Path, name: str, root_id: str) -> dict[str, Any]:
278
+ controller = require_root(controller_root)
279
+ registry = _read_registry(controller.root)
280
+ _validate_registered_root_ids(registry, [root_id])
281
+ group = _group_entry(registry, name)
282
+ if root_id not in group["root_ids"]:
283
+ group["root_ids"].append(root_id)
284
+ group["root_ids"].sort()
285
+ group["updated_at"] = now_iso()
286
+ _write_registry(controller.root, registry)
287
+ return group
288
+
289
+
290
+ def remove_root_from_group(controller_root: str | Path, name: str, root_id: str) -> dict[str, Any]:
291
+ controller = require_root(controller_root)
292
+ registry = _read_registry(controller.root)
293
+ group = _group_entry(registry, name)
294
+ group["root_ids"] = [candidate for candidate in group["root_ids"] if candidate != root_id]
295
+ group["updated_at"] = now_iso()
296
+ _write_registry(controller.root, registry)
297
+ return group
298
+
299
+
300
+ def format_registered_roots(roots: list[dict[str, Any]]) -> str:
301
+ if not roots:
302
+ return "No registered roots.\n"
303
+ lines: list[str] = []
304
+ for root in roots:
305
+ status = "available" if root["available"] else "missing"
306
+ lines.append(
307
+ f"{root['root_id']} {root['name']} visibility={root['visibility']} "
308
+ f"status={status} {root['root_path']}"
309
+ )
310
+ return "\n".join(lines) + "\n"
311
+
312
+
313
+ def format_root_suggestions(suggestions: list[dict[str, Any]]) -> str:
314
+ if not suggestions:
315
+ return "No AgentDir roots found.\n"
316
+ lines: list[str] = []
317
+ for suggestion in suggestions:
318
+ state = "registered" if suggestion["registered"] else "unregistered"
319
+ remote = f" remote={suggestion['git_remote']}" if suggestion.get("git_remote") else ""
320
+ lines.append(
321
+ f"{suggestion['root_id']} {suggestion['name']} {state} "
322
+ f"{suggestion['root_path']}{remote}"
323
+ )
324
+ return "\n".join(lines) + "\n"
325
+
326
+
327
+ def format_root_diagnostics(rows: list[dict[str, Any]]) -> str:
328
+ rendered = rich_root_diagnostics(rows)
329
+ if rendered is not None:
330
+ return rendered
331
+ if not rows:
332
+ return "No registered roots.\n"
333
+ lines: list[str] = []
334
+ for row in rows:
335
+ status = "ok" if row["ok"] else "error"
336
+ stale = "stale" if row.get("stale") else "fresh"
337
+ details = "; ".join([*row.get("errors", []), *row.get("warnings", [])])
338
+ suffix = f" {details}" if details else ""
339
+ lines.append(f"{row['root_id']} {row['name']} {status} {stale} {row['root_path']}{suffix}")
340
+ return "\n".join(lines) + "\n"
341
+
342
+
343
+ def format_root_groups(groups: list[dict[str, Any]]) -> str:
344
+ if not groups:
345
+ return "No root groups.\n"
346
+ lines: list[str] = []
347
+ for group in groups:
348
+ lines.append(f"{group['name']} roots={len(group['root_ids'])} {','.join(group['root_ids'])}")
349
+ return "\n".join(lines) + "\n"
350
+
351
+
352
+ def format_federated_hits(rows: list[dict[str, Any]]) -> str:
353
+ lines: list[str] = []
354
+ for row in rows:
355
+ body = (row.get("body_text") or "").strip().replace("\n", "\\n")
356
+ if len(body) > 220:
357
+ body = body[:217] + "..."
358
+ lines.append(
359
+ f"{row.get('memory_score'):.3f} "
360
+ f"root={row.get('source_root_name')} "
361
+ f"{row.get('event_type') or 'unknown'} "
362
+ f"{row.get('subject') or ''} "
363
+ f"session={row.get('session_id') or ''} "
364
+ f"{body} "
365
+ f"{row.get('source_file_path') or ''}"
366
+ )
367
+ return "\n".join(lines)
368
+
369
+
370
+ def resolve_registered_root(root: str | Path) -> Path:
371
+ candidate = Path(root).expanduser().resolve()
372
+ if (candidate / "VERSION").is_file():
373
+ require_root(candidate)
374
+ return candidate
375
+ nested = candidate / ".agentdir"
376
+ if (nested / "VERSION").is_file():
377
+ require_root(nested)
378
+ return nested
379
+ raise AgentDirError(f"Not an AgentDir root or project with .agentdir: {candidate}")
380
+
381
+
382
+ def root_id_for_path(root: Path) -> str:
383
+ digest = hashlib.sha256(str(root).encode("utf-8")).hexdigest()
384
+ return f"root-{digest[:12]}"
385
+
386
+
387
+ def default_root_name(root: Path) -> str:
388
+ if root.name == ".agentdir" and root.parent.name:
389
+ return root.parent.name
390
+ return root.name
391
+
392
+
393
+ def now_iso() -> str:
394
+ return datetime.now(UTC).isoformat()
395
+
396
+
397
+ def _registry_path(root: str | Path) -> Path:
398
+ return paths_for(root).state / ROOT_REGISTRY_FILE
399
+
400
+
401
+ def _read_registry(root: str | Path) -> dict[str, Any]:
402
+ path = _registry_path(root)
403
+ if not path.exists():
404
+ return {"version": 1, "roots": [], "groups": []}
405
+ data = json.loads(path.read_text(encoding="utf-8"))
406
+ if data.get("version") != 1:
407
+ raise AgentDirError(f"Unsupported root registry version: {data.get('version')}")
408
+ data.setdefault("roots", [])
409
+ data.setdefault("groups", [])
410
+ return data
411
+
412
+
413
+ def _write_registry(root: str | Path, registry: dict[str, Any]) -> None:
414
+ path = _registry_path(root)
415
+ path.parent.mkdir(parents=True, exist_ok=True)
416
+ path.write_text(json.dumps(registry, indent=2, sort_keys=True) + "\n", encoding="utf-8")
417
+
418
+
419
+ def _federated_row(root: dict[str, Any], row: dict[str, Any]) -> dict[str, Any]:
420
+ original_source_id = str(row.get("source_id") or "")
421
+ federated_source_id = f"{root['root_id']}:{original_source_id}"
422
+ body_text = _excerpt(row.get("body_text") or "", 500)
423
+ return {
424
+ **row,
425
+ "body_text": body_text,
426
+ "body_text_truncated": body_text != (row.get("body_text") or ""),
427
+ "source_root_id": root["root_id"],
428
+ "source_root_name": root["name"],
429
+ "source_root_path": root["root_path"],
430
+ "source_root_visibility": root["visibility"],
431
+ "source_id_original": original_source_id,
432
+ "source_file_path": row.get("file_path"),
433
+ "source_id": federated_source_id,
434
+ }
435
+
436
+
437
+ def _excerpt(text: str, limit: int) -> str:
438
+ collapsed = " ".join(text.strip().split())
439
+ if len(collapsed) <= limit:
440
+ return collapsed
441
+ return collapsed[: limit - 3] + "..."
442
+
443
+
444
+ def _root_ids_for_group(registry: dict[str, Any], name: str) -> list[str]:
445
+ return list(_group_entry(registry, name)["root_ids"])
446
+
447
+
448
+ def _group_entry(registry: dict[str, Any], name: str) -> dict[str, Any]:
449
+ for group in registry["groups"]:
450
+ if group["name"] == name:
451
+ return group
452
+ raise AgentDirError(f"Unknown root group: {name}")
453
+
454
+
455
+ def _validate_registered_root_ids(registry: dict[str, Any], root_ids: list[str]) -> list[str]:
456
+ registered = {root["root_id"] for root in registry["roots"]}
457
+ normalized: list[str] = []
458
+ for root_id in root_ids:
459
+ validate_id(root_id, "root id")
460
+ if root_id not in registered:
461
+ raise AgentDirError(f"Unknown registered root: {root_id}")
462
+ if root_id not in normalized:
463
+ normalized.append(root_id)
464
+ return normalized
465
+
466
+
467
+ def _candidate_agentdir_root(candidate: Path) -> Path | None:
468
+ if (candidate / "VERSION").is_file():
469
+ return candidate.resolve()
470
+ nested = candidate / ".agentdir"
471
+ if (nested / "VERSION").is_file():
472
+ return nested.resolve()
473
+ return None
474
+
475
+
476
+ def _nearby_root_candidates(start: Path) -> list[Path]:
477
+ candidates = [start]
478
+ if start.parent != start:
479
+ candidates.append(start.parent)
480
+ try:
481
+ candidates.extend(path for path in start.parent.iterdir() if path.is_dir())
482
+ except OSError:
483
+ pass
484
+ try:
485
+ candidates.extend(path for path in start.iterdir() if path.is_dir())
486
+ except OSError:
487
+ pass
488
+ seen: set[Path] = set()
489
+ unique: list[Path] = []
490
+ for candidate in candidates:
491
+ resolved = candidate.resolve()
492
+ if resolved in seen:
493
+ continue
494
+ seen.add(resolved)
495
+ unique.append(resolved)
496
+ return unique
497
+
498
+
499
+ def _git_remote(project_path: Path) -> str | None:
500
+ try:
501
+ result = subprocess.run(
502
+ ["git", "remote", "get-url", "origin"],
503
+ cwd=project_path,
504
+ text=True,
505
+ capture_output=True,
506
+ check=False,
507
+ )
508
+ except OSError:
509
+ return None
510
+ if result.returncode != 0:
511
+ return None
512
+ return result.stdout.strip() or None
513
+
514
+
515
+ def _index_mtime(root: Path) -> float | None:
516
+ path = paths_for(root).index_path
517
+ return path.stat().st_mtime if path.is_file() else None
518
+
519
+
520
+ def _latest_record_mtime(root: Path) -> float | None:
521
+ latest: float | None = None
522
+ for container in ("sessions", "actors", "queues"):
523
+ base = root / container
524
+ if not base.is_dir():
525
+ continue
526
+ for path in base.rglob("*"):
527
+ if path.is_file():
528
+ mtime = path.stat().st_mtime
529
+ latest = mtime if latest is None else max(latest, mtime)
530
+ return latest
agentdir/git.py ADDED
@@ -0,0 +1,45 @@
1
+ from __future__ import annotations
2
+
3
+ import subprocess
4
+ from pathlib import Path
5
+
6
+
7
+ def git_output(args: list[str], cwd: str | Path | None = None) -> str | None:
8
+ try:
9
+ result = subprocess.run(
10
+ ["git", *args],
11
+ cwd=Path(cwd).expanduser().resolve() if cwd else None,
12
+ text=True,
13
+ capture_output=True,
14
+ check=False,
15
+ )
16
+ except OSError:
17
+ return None
18
+ if result.returncode != 0:
19
+ return None
20
+ return result.stdout.strip()
21
+
22
+
23
+ def git_root(cwd: str | Path | None = None) -> Path | None:
24
+ output = git_output(["rev-parse", "--show-toplevel"], cwd)
25
+ return Path(output).resolve() if output else None
26
+
27
+
28
+ def git_head(cwd: str | Path | None = None, *, short: bool = False) -> str | None:
29
+ args = ["rev-parse", "--short", "HEAD"] if short else ["rev-parse", "HEAD"]
30
+ return git_output(args, cwd)
31
+
32
+
33
+ def git_branch(cwd: str | Path | None = None) -> str | None:
34
+ return git_output(["branch", "--show-current"], cwd)
35
+
36
+
37
+ def git_status_short(cwd: str | Path | None = None) -> str:
38
+ return git_output(["status", "--short"], cwd) or ""
39
+
40
+
41
+ def workspace_name(cwd: str | Path | None = None) -> str:
42
+ root = git_root(cwd)
43
+ if root:
44
+ return root.name
45
+ return Path(cwd or Path.cwd()).resolve().name
agentdir/gitignore.py ADDED
@@ -0,0 +1,106 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ from .git import git_output, git_root
8
+
9
+ GITIGNORE_CHOICES = ("ask", "project", "user", "none")
10
+ AGENTDIR_IGNORE_PATTERN = ".agentdir/"
11
+ _AGENTDIR_PATTERN_VARIANTS = {".agentdir", ".agentdir/", "/.agentdir", "/.agentdir/"}
12
+
13
+
14
+ def gitignore_plan(
15
+ *,
16
+ target: str,
17
+ cwd: str | Path | None = None,
18
+ ) -> dict[str, Any]:
19
+ if target == "ask":
20
+ return {
21
+ "target": "ask",
22
+ "action": "prompt",
23
+ "path": None,
24
+ "pattern": AGENTDIR_IGNORE_PATTERN,
25
+ "would_write": None,
26
+ }
27
+ if target == "none":
28
+ return _skip("none", "selected_none")
29
+
30
+ path = _ignore_path(target, cwd)
31
+ if path is None:
32
+ return _skip(target, "not_in_git_repository")
33
+ exists = path.is_file()
34
+ return {
35
+ "target": target,
36
+ "action": "exists" if _has_agentdir_pattern(path) else "update" if exists else "create",
37
+ "path": str(path),
38
+ "pattern": AGENTDIR_IGNORE_PATTERN,
39
+ "exists": exists,
40
+ "would_write": not _has_agentdir_pattern(path),
41
+ }
42
+
43
+
44
+ def ensure_agentdir_ignored(
45
+ *,
46
+ target: str,
47
+ cwd: str | Path | None = None,
48
+ ) -> dict[str, Any]:
49
+ plan = gitignore_plan(target=target, cwd=cwd)
50
+ if not plan.get("would_write"):
51
+ return {**plan, "changed": False}
52
+
53
+ path = Path(str(plan["path"]))
54
+ path.parent.mkdir(parents=True, exist_ok=True)
55
+ _append_pattern(path)
56
+ return {**plan, "changed": True}
57
+
58
+
59
+ def user_gitignore_path() -> Path:
60
+ configured = git_output(["config", "--global", "--get", "core.excludesFile"])
61
+ if configured:
62
+ return Path(configured).expanduser().resolve()
63
+ xdg_config = os.environ.get("XDG_CONFIG_HOME")
64
+ base = Path(xdg_config).expanduser() if xdg_config else Path.home() / ".config"
65
+ return (base / "git" / "ignore").resolve()
66
+
67
+
68
+ def _ignore_path(target: str, cwd: str | Path | None) -> Path | None:
69
+ if target == "project":
70
+ root = git_root(cwd)
71
+ return (root / ".gitignore") if root else None
72
+ if target == "user":
73
+ return user_gitignore_path()
74
+ return None
75
+
76
+
77
+ def _has_agentdir_pattern(path: Path) -> bool:
78
+ if not path.is_file():
79
+ return False
80
+ for line in path.read_text(encoding="utf-8").splitlines():
81
+ candidate = line.strip()
82
+ if not candidate or candidate.startswith("#"):
83
+ continue
84
+ if candidate in _AGENTDIR_PATTERN_VARIANTS:
85
+ return True
86
+ return False
87
+
88
+
89
+ def _append_pattern(path: Path) -> None:
90
+ if path.exists():
91
+ text = path.read_text(encoding="utf-8")
92
+ separator = "" if text.endswith(("\n", "\r")) or not text else "\n"
93
+ path.write_text(f"{text}{separator}{AGENTDIR_IGNORE_PATTERN}\n", encoding="utf-8")
94
+ return
95
+ path.write_text(f"{AGENTDIR_IGNORE_PATTERN}\n", encoding="utf-8")
96
+
97
+
98
+ def _skip(target: str, reason: str) -> dict[str, Any]:
99
+ return {
100
+ "target": target,
101
+ "action": "skip",
102
+ "path": None,
103
+ "pattern": AGENTDIR_IGNORE_PATTERN,
104
+ "reason": reason,
105
+ "would_write": False,
106
+ }