devops-bot-sdk 1.4.0__tar.gz → 1.4.1__tar.gz

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.
Files changed (36) hide show
  1. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/PKG-INFO +1 -1
  2. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
  3. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/SOURCES.txt +5 -0
  4. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/entry_points.txt +1 -0
  5. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/pyproject.toml +3 -2
  6. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/__init__.py +2 -2
  7. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/client.py +128 -1
  8. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/config.py +23 -1
  9. devops_bot_sdk-1.4.1/sdk/crucial.py +61 -0
  10. devops_bot_sdk-1.4.1/sdk/git_ops.py +124 -0
  11. devops_bot_sdk-1.4.1/sdk/graphify.py +172 -0
  12. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/handlers.py +518 -6
  13. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/local_exec.py +53 -5
  14. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/requests.py +10 -3
  15. devops_bot_sdk-1.4.1/sdk/run_auto.py +155 -0
  16. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/sse.py +19 -12
  17. devops_bot_sdk-1.4.1/sdk/updater.py +280 -0
  18. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/README.md +0 -0
  19. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
  20. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/requires.txt +0 -0
  21. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/top_level.txt +0 -0
  22. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/__init__.py +0 -0
  23. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/files.py +0 -0
  24. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/process.py +0 -0
  25. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/screenshot.py +0 -0
  26. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/exceptions.py +0 -0
  27. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/__init__.py +0 -0
  28. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/electron_bridge.py +0 -0
  29. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/__init__.py +0 -0
  30. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/envelope.py +0 -0
  31. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/responses.py +0 -0
  32. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/snapshots.py +0 -0
  33. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/py.typed +0 -0
  34. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/test.py +0 -0
  35. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/test_pipeline.py +0 -0
  36. {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: devops-bot-sdk
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
  Summary: DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app
5
5
  Author: noumanaziz2128
6
6
  License-Expression: LicenseRef-Proprietary
@@ -9,12 +9,17 @@ devops_bot_sdk.egg-info/top_level.txt
9
9
  sdk/__init__.py
10
10
  sdk/client.py
11
11
  sdk/config.py
12
+ sdk/crucial.py
12
13
  sdk/exceptions.py
14
+ sdk/git_ops.py
15
+ sdk/graphify.py
13
16
  sdk/local_exec.py
14
17
  sdk/py.typed
18
+ sdk/run_auto.py
15
19
  sdk/sse.py
16
20
  sdk/test.py
17
21
  sdk/test_pipeline.py
22
+ sdk/updater.py
18
23
  sdk/collectors/__init__.py
19
24
  sdk/collectors/files.py
20
25
  sdk/collectors/process.py
@@ -1,2 +1,3 @@
1
1
  [console_scripts]
2
2
  agentos = sdk.config:configure_cli
3
+ agentos-auto = sdk.run_auto:main
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "devops-bot-sdk"
7
- version = "1.4.0"
7
+ version = "1.4.1"
8
8
  description = "DevOps Bot Desktop SDK — thin client for the AgentOS Electron desktop app"
9
9
  readme = "README.md"
10
10
  license = "LicenseRef-Proprietary"
@@ -32,7 +32,8 @@ all = [
32
32
  ]
33
33
 
34
34
  [project.scripts]
35
- agentos = "sdk.config:configure_cli" # `agentos configure [--rotate]`
35
+ agentos = "sdk.config:configure_cli" # `agentos configure [--rotate]`
36
+ agentos-auto = "sdk.run_auto:main" # run orchestrate-auto LOCALLY (co_ token from config)
36
37
 
37
38
  [project.urls]
38
39
  Homepage = "https://agentos.io"
@@ -1,6 +1,6 @@
1
1
  """AgentOS Desktop SDK — thin HTTPS/SSE client for the Electron app.
2
2
 
3
- Version: 1.4.0
3
+ Version: 1.4.1
4
4
 
5
5
  Public surface:
6
6
  BackendClient.from_config() — create client from ~/.agentos/config.toml
@@ -30,7 +30,7 @@ Rules:
30
30
  - All data egress through submit_webhook only
31
31
  """
32
32
 
33
- __version__ = "1.4.0"
33
+ __version__ = "1.4.1"
34
34
  __author__ = "AgentOS"
35
35
 
36
36
  from sdk.client import BackendClient
@@ -36,7 +36,7 @@ from sdk.sse import _check_status, stream_with_reconnect
36
36
 
37
37
  logger = logging.getLogger(__name__)
38
38
 
39
- SDK_VERSION = "1.4.0"
39
+ SDK_VERSION = "1.4.1"
40
40
  _POLL_INTERVAL = 3.0
41
41
  _POLL_TIMEOUT = 600.0
42
42
  _ORCHESTRATE_TIMEOUT = 2700.0 # 45 min — covers approval wait + VPS execution time
@@ -578,6 +578,133 @@ class BackendClient:
578
578
  _check_status(resp.status_code, self._base_url)
579
579
  return resp.json()
580
580
 
581
+ async def list_todo_tasks_raw(self, status: str = "To Do", limit: int = 10000) -> list[dict]:
582
+ """GET /tasks-ml (ML backend) — RAW task records.
583
+
584
+ Unlike list_tasks (the AI backend's refined view), this returns the raw
585
+ records that include `agentMode`, `jiraIssueKey`, `projectKey`, and
586
+ `humanInstructions` — needed to drive the local auto loop (autonomy
587
+ switch, branch naming, folder routing). Best-effort: returns [] on error.
588
+ """
589
+ from sdk.config import BE_DEFAULT_URL
590
+
591
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/tasks-ml"
592
+ headers = {**self._headers, "accept": "*/*", "developer-name": "null", "product": ""}
593
+ try:
594
+ async with httpx.AsyncClient(timeout=30.0) as client:
595
+ resp = await client.get(url, params={"status": status, "limit": limit}, headers=headers)
596
+ if resp.status_code >= 400:
597
+ return []
598
+ return ((resp.json() or {}).get("data") or {}).get("records") or []
599
+ except Exception:
600
+ return []
601
+
602
+ async def approval_record(self, task_id: str) -> dict | None:
603
+ """GET /api/v1/mgmt/approve/{task_id} → the full decision record (or None).
604
+
605
+ Includes `decision` + `timestamp`. The timestamp lets the caller tell a
606
+ FRESH decision (for a new gate) from a stale one left by an earlier gate.
607
+ """
608
+ try:
609
+ async with httpx.AsyncClient(timeout=15.0) as client:
610
+ resp = await client.get(
611
+ self._url(f"/api/v1/mgmt/approve/{task_id}"), headers=self._headers,
612
+ )
613
+ if resp.status_code >= 400:
614
+ return None
615
+ return (resp.json() or {}).get("record")
616
+ except Exception:
617
+ return None
618
+
619
+ async def approval_status(self, task_id: str) -> str:
620
+ """GET /api/v1/mgmt/approve/{task_id} → 'approved' | 'rejected' | 'pending'.
621
+
622
+ Polled by the local loop to learn the human's WhatsApp decision (the
623
+ backend records it under approval_log:{task_id}). Returns 'pending' on
624
+ any error so the caller keeps polling rather than acting prematurely.
625
+ """
626
+ try:
627
+ async with httpx.AsyncClient(timeout=15.0) as client:
628
+ resp = await client.get(
629
+ self._url(f"/api/v1/mgmt/approve/{task_id}"), headers=self._headers,
630
+ )
631
+ if resp.status_code >= 400:
632
+ return "pending"
633
+ return str((resp.json() or {}).get("status") or "pending").lower()
634
+ except Exception:
635
+ return "pending"
636
+
637
+ async def github_token(self) -> str | None:
638
+ """GET /ml-api/github/token → the user's GitHub access token (gho_…).
639
+
640
+ Used by the LOCAL agent to authenticate git/gh for clone/push/PR. The
641
+ endpoint lives on the ML backend (devopsbot-be), authorised by the same
642
+ co_ token. Best-effort: returns None on any failure so a run without a
643
+ connected GitHub account still proceeds (clone/PR steps just won't auth).
644
+ """
645
+ from sdk.config import BE_DEFAULT_URL
646
+
647
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/ml-api/github/token"
648
+ headers = {
649
+ **self._headers,
650
+ "accept": "*/*",
651
+ "developer-name": "null",
652
+ "product": "",
653
+ }
654
+ try:
655
+ async with httpx.AsyncClient(timeout=15.0) as client:
656
+ resp = await client.get(url, headers=headers)
657
+ if resp.status_code >= 400:
658
+ return None
659
+ data = (resp.json() or {}).get("data") or {}
660
+ return data.get("accessToken") or None
661
+ except Exception:
662
+ return None
663
+
664
+ async def notify_whatsapp(
665
+ self,
666
+ task_id: str,
667
+ message: str,
668
+ *,
669
+ summary: str = "",
670
+ risk_level: str = "LOW",
671
+ approver_role: str = "TEAM_LEAD",
672
+ ) -> bool:
673
+ """Push a WhatsApp message to the user via POST /approvals-ml.
674
+
675
+ Used for alerts — a hard error during local execution, or the context
676
+ window being exceeded. `message` is what the user receives on WhatsApp
677
+ (the backend forwards it). Best-effort: returns False and never raises so
678
+ a notification failure can't break the run.
679
+ """
680
+ from sdk.config import BE_DEFAULT_URL
681
+
682
+ url = f"{BE_DEFAULT_URL.rstrip('/')}/approvals-ml"
683
+ headers = {
684
+ **self._headers,
685
+ "accept": "*/*",
686
+ "Content-Type": "application/json",
687
+ "developer-name": "null",
688
+ "product": "",
689
+ }
690
+ payload = {
691
+ "taskId": task_id,
692
+ "gateNumber": 1,
693
+ "riskLevel": risk_level,
694
+ "message": (message or "")[:300],
695
+ "costEstimateUsd": 0,
696
+ "approverRole": approver_role,
697
+ "approvalSummary": (summary or message or "")[:300],
698
+ "inputTokens": 0,
699
+ "outputTokens": 0,
700
+ }
701
+ try:
702
+ async with httpx.AsyncClient(timeout=15.0) as client:
703
+ resp = await client.post(url, json=payload, headers=headers)
704
+ return resp.status_code < 400
705
+ except Exception:
706
+ return False
707
+
581
708
  # ── Webhook (sole data egress) ─────────────────────────────────────
582
709
 
583
710
  async def submit_webhook(
@@ -29,9 +29,31 @@ BE_DEFAULT_URL = "https://devopsbot-be.apiswagger.co.uk"
29
29
 
30
30
  # ── Machine-derived Fernet key ────────────────────────────────────────
31
31
 
32
+ def _get_username() -> str:
33
+ """Return the current username without relying on a controlling terminal.
34
+
35
+ os.getlogin() raises FileNotFoundError in non-interactive shells (SSH
36
+ sessions without TTY, subprocesses, systemd services). Fall back through
37
+ environment variables and pwd before giving up.
38
+ """
39
+ for fn in (
40
+ lambda: os.environ.get("USER") or "",
41
+ lambda: os.environ.get("LOGNAME") or "",
42
+ lambda: __import__("pwd").getpwuid(os.getuid()).pw_name,
43
+ lambda: os.getlogin(),
44
+ ):
45
+ try:
46
+ name = fn()
47
+ if name:
48
+ return name
49
+ except Exception: # noqa: BLE001
50
+ continue
51
+ return "unknown"
52
+
53
+
32
54
  def _machine_key() -> bytes:
33
55
  from cryptography.fernet import Fernet
34
- seed = f"{platform.node()}:{platform.system()}:{os.getlogin()}:agentos-sdk"
56
+ seed = f"{platform.node()}:{platform.system()}:{_get_username()}:agentos-sdk"
35
57
  key_bytes = hashlib.sha256(seed.encode()).digest()
36
58
  return base64.urlsafe_b64encode(key_bytes)
37
59
 
@@ -0,0 +1,61 @@
1
+ """Mid-run crucial-decision gate.
2
+
3
+ When agentMode=true, the agent must PAUSE for human approval before a crucial /
4
+ irreversible action (DB schema changes/migrations, dropping data, destructive
5
+ shell, prod deploy, auth/billing changes, or anything the operator flagged in
6
+ humanInstructions).
7
+
8
+ Mechanism: the prompt instructs the agent to STOP and emit a one-line marker
9
+ before any such action. The SDK detects the marker, opens an approval gate, and
10
+ resumes the session on approval. (A Claude Code PreToolUse *blocking* hook is a
11
+ planned hardening on top of this — its block protocol needs verifying against
12
+ the installed claude version.)
13
+ """
14
+ from __future__ import annotations
15
+
16
+ MARKER = "AGENTOS_APPROVAL_REQUIRED:"
17
+
18
+ _BUILTIN_CRUCIAL = (
19
+ "Database schema changes or migrations (CREATE/ALTER/DROP TABLE, "
20
+ "prisma/alembic/knex/typeorm migrate, etc.)",
21
+ "Deleting or dropping data (DROP/TRUNCATE/DELETE, dropdb)",
22
+ "Destructive shell commands (rm -rf, git push --force, terraform destroy, "
23
+ "kubectl delete, dropping volumes)",
24
+ "Production deployment or release",
25
+ "Changes to authentication, secrets/credentials, billing or payment logic",
26
+ )
27
+
28
+
29
+ def crucial_prompt(human_instructions: str | None) -> str:
30
+ """Prompt block telling the agent when and how to pause for approval."""
31
+ items = "\n".join(f"- {x}" for x in _BUILTIN_CRUCIAL)
32
+
33
+ extra = ""
34
+ hi = human_instructions or ""
35
+ flagged = [
36
+ ln.strip() for ln in hi.splitlines()
37
+ if any(k in ln.lower() for k in ("crucial", "approval", "sensitive", "do not"))
38
+ ]
39
+ if flagged:
40
+ extra = "\nThe operator additionally marked these as crucial:\n" + "\n".join(
41
+ f"- {ln}" for ln in flagged[:10]
42
+ )
43
+
44
+ return (
45
+ "\n\n=== CRUCIAL-DECISION GATE (mandatory) ===\n"
46
+ "Before performing any CRUCIAL or IRREVERSIBLE action you MUST pause for "
47
+ "human approval. Crucial actions include:\n" + items + extra + "\n"
48
+ "When you reach such a step, DO NOT perform it. Output EXACTLY one line:\n"
49
+ f"{MARKER} <one-line description of the action needing approval>\n"
50
+ "then STOP and end your turn. Do not proceed until you are told it is approved."
51
+ )
52
+
53
+
54
+ def detect_marker(text: str | None) -> str | None:
55
+ """Return the description after the marker, or None if not present."""
56
+ if not text:
57
+ return None
58
+ for line in text.splitlines():
59
+ if MARKER in line:
60
+ return line.split(MARKER, 1)[1].strip() or "crucial action"
61
+ return None
@@ -0,0 +1,124 @@
1
+ """Local git operations — feature branch + PR via the user's GitHub token.
2
+
3
+ Runs `git`/`gh` as subprocesses on the user's machine, authenticated with the
4
+ token fetched from the backend (`/ml-api/github/token`). The SDK owns branching,
5
+ commit, push and PR for the PRIMARY project so it's deterministic; the agent is
6
+ told not to do git for that repo (it may still clone/PR other repos the ticket
7
+ names). Everything here is best-effort and never raises into the run.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import os
13
+ import re
14
+ import shutil
15
+ from pathlib import Path
16
+
17
+
18
+ def slugify(text: str | None, max_len: int = 40) -> str:
19
+ s = re.sub(r"[^a-z0-9]+", "-", (text or "").lower()).strip("-")
20
+ return s[:max_len].strip("-") or "task"
21
+
22
+
23
+ def _git_env(github_token: str | None) -> dict:
24
+ env = dict(os.environ)
25
+ if github_token:
26
+ env["GH_TOKEN"] = github_token
27
+ env["GITHUB_TOKEN"] = github_token
28
+ env["GIT_CONFIG_COUNT"] = "1"
29
+ env["GIT_CONFIG_KEY_0"] = (
30
+ f"url.https://x-access-token:{github_token}@github.com/.insteadOf"
31
+ )
32
+ env["GIT_CONFIG_VALUE_0"] = "https://github.com/"
33
+ env.setdefault("GIT_AUTHOR_NAME", "AgentOS")
34
+ env.setdefault("GIT_AUTHOR_EMAIL", "agentos@users.noreply.github.com")
35
+ env.setdefault("GIT_COMMITTER_NAME", "AgentOS")
36
+ env.setdefault("GIT_COMMITTER_EMAIL", "agentos@users.noreply.github.com")
37
+ return env
38
+
39
+
40
+ async def _run(cmd: list[str], cwd: str, env: dict, timeout: float = 120.0):
41
+ """Run a command; return (returncode, stdout, stderr). Never raises."""
42
+ try:
43
+ proc = await asyncio.create_subprocess_exec(
44
+ *cmd, cwd=cwd, env=env,
45
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE,
46
+ )
47
+ out, err = await asyncio.wait_for(proc.communicate(), timeout=timeout)
48
+ return (
49
+ proc.returncode,
50
+ out.decode("utf-8", "replace").strip(),
51
+ err.decode("utf-8", "replace").strip(),
52
+ )
53
+ except Exception as exc: # noqa: BLE001
54
+ return 1, "", str(exc)
55
+
56
+
57
+ async def _is_git_repo(path: str, env: dict) -> bool:
58
+ rc, out, _ = await _run(["git", "rev-parse", "--is-inside-work-tree"], path, env)
59
+ return rc == 0 and out == "true"
60
+
61
+
62
+ async def detect_default_branch(path: str, env: dict) -> str:
63
+ """Repo default branch (origin/HEAD), falling back to the current branch."""
64
+ rc, out, _ = await _run(
65
+ ["git", "symbolic-ref", "refs/remotes/origin/HEAD"], path, env,
66
+ )
67
+ if rc == 0 and "/" in out:
68
+ return out.rsplit("/", 1)[-1]
69
+ rc, out, _ = await _run(["git", "rev-parse", "--abbrev-ref", "HEAD"], path, env)
70
+ return out if (rc == 0 and out) else "main"
71
+
72
+
73
+ async def start_branch(
74
+ project_path: str, jira_key: str | None, summary: str | None, github_token: str | None,
75
+ ) -> dict | None:
76
+ """Create + check out `feature/<jira_key>-<slug>` off the default branch.
77
+
78
+ Returns {"branch", "base"} or None when the path isn't a git repo (e.g. a
79
+ fresh scratch dir) or the checkout failed.
80
+ """
81
+ path = str(Path(project_path).expanduser())
82
+ env = _git_env(github_token)
83
+ if not await _is_git_repo(path, env):
84
+ return None
85
+ base = await detect_default_branch(path, env)
86
+ key = slugify(jira_key or "task", max_len=24)
87
+ branch = f"feature/{key}-{slugify(summary)}"
88
+ await _run(["git", "fetch", "origin"], path, env, timeout=180.0)
89
+ rc, _, _ = await _run(["git", "checkout", "-B", branch], path, env)
90
+ if rc != 0:
91
+ return None
92
+ return {"branch": branch, "base": base}
93
+
94
+
95
+ async def finish_pr(
96
+ project_path: str, branch: str, base: str, title: str, body: str,
97
+ github_token: str | None,
98
+ ) -> dict:
99
+ """Stage all, commit, push the branch, open a PR. Best-effort.
100
+
101
+ Returns {"pushed": bool, "pr_url": str | None}.
102
+ """
103
+ path = str(Path(project_path).expanduser())
104
+ env = _git_env(github_token)
105
+
106
+ await _run(["git", "add", "-A"], path, env)
107
+ # commit — no-op (non-zero) when there's nothing staged; ignore that case.
108
+ await _run(["git", "commit", "-m", title], path, env)
109
+
110
+ rc, _, _ = await _run(
111
+ ["git", "push", "-u", "origin", branch, "--force-with-lease"],
112
+ path, env, timeout=300.0,
113
+ )
114
+ pushed = rc == 0
115
+ pr_url: str | None = None
116
+ if pushed and shutil.which("gh"):
117
+ rc, out, _ = await _run(
118
+ ["gh", "pr", "create", "--base", base, "--head", branch,
119
+ "--title", title, "--body", body],
120
+ path, env, timeout=120.0,
121
+ )
122
+ if rc == 0 and out:
123
+ pr_url = out.strip().splitlines()[-1]
124
+ return {"pushed": pushed, "pr_url": pr_url}
@@ -0,0 +1,172 @@
1
+ """Graphify integration — per-project knowledge-graph context management.
2
+
3
+ graphify (https://github.com/safishamsi/graphify) turns a project into a
4
+ queryable knowledge graph and plugs into Claude Code as a **skill + PreToolUse
5
+ hook**, so the agent queries the graph instead of loading whole files — keeping
6
+ large projects inside the context window.
7
+
8
+ Everything here is best-effort and never raises: if graphify is unavailable or a
9
+ step fails, the run continues on plain `claude`. Disable with
10
+ ``AGENTOS_NO_GRAPHIFY=1``.
11
+
12
+ Per-project flow (called once before each local `claude` run):
13
+ ensure_installed() → auto-install `graphifyy` via uv → pipx → pip if missing
14
+ ensure_skill() → `graphify install` once (registers /graphify + the hook)
15
+ build_graph(path) → `graphify extract <path> --update` (incremental)
16
+ gitignore_out(path) → keep graphify-out/ out of commits/PRs
17
+ """
18
+ from __future__ import annotations
19
+
20
+ import asyncio
21
+ import os
22
+ import shutil
23
+ import sys
24
+ from pathlib import Path
25
+
26
+ _SKILL_MARKER = Path.home() / ".agentos" / ".graphify_skill_installed"
27
+
28
+
29
+ def _disabled() -> bool:
30
+ return bool(os.getenv("AGENTOS_NO_GRAPHIFY"))
31
+
32
+
33
+ def is_available() -> bool:
34
+ """True when the `graphify` CLI is on PATH."""
35
+ return shutil.which("graphify") is not None
36
+
37
+
38
+ async def _run(cmd: list[str], cwd: str | None = None, timeout: float = 600.0) -> bool:
39
+ """Run a command quietly; True on exit 0. Never raises."""
40
+ proc = None
41
+ try:
42
+ proc = await asyncio.create_subprocess_exec(
43
+ *cmd,
44
+ cwd=cwd,
45
+ stdout=asyncio.subprocess.DEVNULL,
46
+ stderr=asyncio.subprocess.DEVNULL,
47
+ )
48
+ await asyncio.wait_for(proc.wait(), timeout=timeout)
49
+ return proc.returncode == 0
50
+ except Exception: # noqa: BLE001
51
+ if proc is not None:
52
+ try:
53
+ proc.kill()
54
+ except Exception: # noqa: BLE001
55
+ pass
56
+ return False
57
+
58
+
59
+ async def ensure_installed() -> bool:
60
+ """Install graphify (`graphifyy`) if missing — uv → pipx → pip. Returns availability."""
61
+ if _disabled():
62
+ return False
63
+ if is_available():
64
+ return True
65
+ candidates = [
66
+ ["uv", "tool", "install", "graphifyy"],
67
+ ["pipx", "install", "graphifyy"],
68
+ [sys.executable, "-m", "pip", "install", "graphifyy"],
69
+ ]
70
+ for cmd in candidates:
71
+ if shutil.which(cmd[0]) and await _run(cmd, timeout=300.0) and is_available():
72
+ return True
73
+ return is_available()
74
+
75
+
76
+ async def ensure_skill() -> None:
77
+ """Register the Claude Code skill + PreToolUse hook once (`graphify install`)."""
78
+ if _disabled() or not is_available() or _SKILL_MARKER.exists():
79
+ return
80
+ if await _run(["graphify", "install"], timeout=120.0):
81
+ try:
82
+ _SKILL_MARKER.parent.mkdir(parents=True, exist_ok=True)
83
+ _SKILL_MARKER.write_text("ok")
84
+ except Exception: # noqa: BLE001
85
+ pass
86
+
87
+
88
+ async def build_graph(project_path: str) -> bool:
89
+ """Incrementally (re)build the project graph. Tolerates CLI-flag differences."""
90
+ if _disabled() or not is_available():
91
+ return False
92
+ p = str(Path(project_path).expanduser())
93
+ # Default extraction is tree-sitter based (fast, local). Try the documented
94
+ # forms in order; the first that succeeds wins.
95
+ for cmd in (
96
+ ["graphify", "extract", p, "--update"],
97
+ ["graphify", "extract", p],
98
+ ["graphify", p, "--update"],
99
+ ):
100
+ if await _run(cmd, cwd=p, timeout=600.0):
101
+ return True
102
+ return False
103
+
104
+
105
+ # Volatile graphify artifacts to keep OUT of commits/PRs. graph.json and
106
+ # GRAPH_REPORT.md are intentionally NOT listed — they stay trackable.
107
+ _GITIGNORE_ENTRIES = (
108
+ "graphify-out/manifest.json",
109
+ "graphify-out/cost.json",
110
+ "graphify-out/cache",
111
+ )
112
+
113
+
114
+ def gitignore_out(project_path: str) -> None:
115
+ """Gitignore the volatile graphify artifacts (manifest/cost/cache).
116
+
117
+ Adds each entry only if absent (idempotent). graph.json / GRAPH_REPORT.md are
118
+ left trackable on purpose.
119
+ """
120
+ try:
121
+ gi = Path(project_path).expanduser() / ".gitignore"
122
+ existing = gi.read_text(encoding="utf-8") if gi.exists() else ""
123
+ missing = [e for e in _GITIGNORE_ENTRIES if e not in existing]
124
+ if not missing:
125
+ return
126
+ prefix = "" if (not existing or existing.endswith("\n")) else "\n"
127
+ with gi.open("a", encoding="utf-8") as f:
128
+ f.write(prefix + "\n".join(missing) + "\n")
129
+ except Exception: # noqa: BLE001
130
+ pass
131
+
132
+
133
+ # Debounced per-edit incremental updates (so rapid edits don't spawn a storm).
134
+ _last_update: dict[str, float] = {}
135
+ _UPDATE_DEBOUNCE_S = 15.0
136
+
137
+
138
+ def schedule_update(project_path: str) -> None:
139
+ """Fire-and-forget incremental graph refresh after a file edit (debounced).
140
+
141
+ Runs `graphify extract <path> --update` in the background so it never blocks
142
+ the agent; coalesces bursts to at most one update per _UPDATE_DEBOUNCE_S.
143
+ """
144
+ if _disabled() or not is_available():
145
+ return
146
+ import time as _t
147
+
148
+ now = _t.monotonic()
149
+ p = str(Path(project_path).expanduser())
150
+ if now - _last_update.get(p, 0.0) < _UPDATE_DEBOUNCE_S:
151
+ return
152
+ _last_update[p] = now
153
+ try:
154
+ asyncio.get_running_loop().create_task(build_graph(p))
155
+ except RuntimeError:
156
+ pass # no running loop — skip the background refresh
157
+
158
+
159
+ async def prepare(project_path: str) -> bool:
160
+ """Full per-project prep before a local claude run.
161
+
162
+ install → skill/hook → gitignore → incremental graph build.
163
+ Returns True when a graph was built (graph-routed context is active).
164
+ """
165
+ if _disabled():
166
+ return False
167
+ await ensure_installed()
168
+ if not is_available():
169
+ return False
170
+ await ensure_skill()
171
+ gitignore_out(project_path)
172
+ return await build_graph(project_path)