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.
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/PKG-INFO +1 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/SOURCES.txt +5 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/entry_points.txt +1 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/pyproject.toml +3 -2
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/__init__.py +2 -2
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/client.py +128 -1
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/config.py +23 -1
- devops_bot_sdk-1.4.1/sdk/crucial.py +61 -0
- devops_bot_sdk-1.4.1/sdk/git_ops.py +124 -0
- devops_bot_sdk-1.4.1/sdk/graphify.py +172 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/handlers.py +518 -6
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/local_exec.py +53 -5
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/requests.py +10 -3
- devops_bot_sdk-1.4.1/sdk/run_auto.py +155 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/sse.py +19 -12
- devops_bot_sdk-1.4.1/sdk/updater.py +280 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/README.md +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/dependency_links.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/requires.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/devops_bot_sdk.egg-info/top_level.txt +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/files.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/process.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/collectors/screenshot.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/exceptions.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/ipc/electron_bridge.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/__init__.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/envelope.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/responses.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/models/snapshots.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/py.typed +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/test.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/sdk/test_pipeline.py +0 -0
- {devops_bot_sdk-1.4.0 → devops_bot_sdk-1.4.1}/setup.cfg +0 -0
|
@@ -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
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "devops-bot-sdk"
|
|
7
|
-
version = "1.4.
|
|
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"
|
|
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.
|
|
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.
|
|
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.
|
|
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()}:{
|
|
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)
|