Initial release: airndb
This commit is contained in:
441
scripts/airndb_capture.py
Normal file
441
scripts/airndb_capture.py
Normal file
@@ -0,0 +1,441 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Run safe bounded tcpdump/WinDump capture helpers for AirNDB."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import platform
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Sequence
|
||||
|
||||
|
||||
DEFAULT_COUNT = 200
|
||||
DEFAULT_TIMEOUT = 30
|
||||
MAX_COUNT = 500000
|
||||
WINDUMP_DOWNLOAD_PAGE = "https://www.winpcap.org/windump/install/"
|
||||
WINDUMP_DOWNLOAD_URL = "https://www.winpcap.org/windump/install/bin/windump_3_9_5/WinDump.exe"
|
||||
WINDUMP_SHA1 = "d59bc54721951dec855cbb4bbc000f9a71ea4d95"
|
||||
|
||||
|
||||
def now_stamp() -> str:
|
||||
return datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def is_windows() -> bool:
|
||||
return platform.system().lower().startswith("win")
|
||||
|
||||
|
||||
def parse_env_file(path: Path) -> Dict[str, str]:
|
||||
if not path.exists():
|
||||
return {}
|
||||
values: Dict[str, str] = {}
|
||||
for raw_line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = raw_line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, value = line.split("=", 1)
|
||||
values[key.strip()] = value.strip().strip('"')
|
||||
return values
|
||||
|
||||
|
||||
def project_tool_env(project_root: Optional[Path]) -> Dict[str, str]:
|
||||
if not project_root:
|
||||
return {}
|
||||
return parse_env_file(project_root / "AirPlan" / "state" / "airndb" / "tool.env")
|
||||
|
||||
|
||||
def tool_dir(project_root: Optional[Path] = None) -> Path:
|
||||
configured = os.environ.get("AIRNDB_TOOL_DIR", "").strip()
|
||||
if configured:
|
||||
return Path(configured).expanduser().resolve()
|
||||
if project_root:
|
||||
return (project_root / "AirPlan" / "state" / "airndb" / "tools").resolve()
|
||||
return Path.home() / ".airndb" / "tools"
|
||||
|
||||
|
||||
def detect_tool(explicit: str = "", project_root: Optional[Path] = None) -> Optional[str]:
|
||||
candidates: List[str] = []
|
||||
if explicit:
|
||||
candidates.append(explicit)
|
||||
project_env = project_tool_env(project_root)
|
||||
project_tool = project_env.get("AIRNDB_TCPDUMP", "")
|
||||
if project_tool:
|
||||
candidates.append(project_tool)
|
||||
env_tool = os.environ.get("AIRNDB_TCPDUMP", "")
|
||||
if env_tool:
|
||||
candidates.append(env_tool)
|
||||
bundled = tool_dir(project_root) / "WinDump.exe"
|
||||
if bundled.exists():
|
||||
candidates.append(str(bundled))
|
||||
candidates.extend(["windump", "WinDump.exe", "tcpdump"] if is_windows() else ["tcpdump", "windump", "WinDump.exe"])
|
||||
for candidate in candidates:
|
||||
if Path(candidate).exists():
|
||||
return str(Path(candidate).resolve())
|
||||
found = shutil.which(candidate)
|
||||
if found:
|
||||
return found
|
||||
return None
|
||||
|
||||
|
||||
def sha1_file(path: Path) -> str:
|
||||
digest = hashlib.sha1()
|
||||
with path.open("rb") as handle:
|
||||
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
||||
digest.update(chunk)
|
||||
return digest.hexdigest()
|
||||
|
||||
|
||||
def write_project_tool_env(project_root: Path, tool_path: Path) -> Path:
|
||||
airndb_dir = project_root / "AirPlan" / "state" / "airndb"
|
||||
airndb_dir.mkdir(parents=True, exist_ok=True)
|
||||
env_path = airndb_dir / "tool.env"
|
||||
env_path.write_text(f"AIRNDB_TCPDUMP={tool_path}\n", encoding="utf-8", newline="\n")
|
||||
gitignore = airndb_dir / ".gitignore"
|
||||
existing = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
||||
if "tool.env" not in existing.splitlines():
|
||||
gitignore.write_text((existing.rstrip() + "\ntool.env\n").lstrip(), encoding="utf-8", newline="\n")
|
||||
return env_path
|
||||
|
||||
|
||||
def download_windump(target: Path, timeout: int = 60) -> Dict[str, Any]:
|
||||
target.parent.mkdir(parents=True, exist_ok=True)
|
||||
temp = target.with_suffix(".download")
|
||||
try:
|
||||
with urllib.request.urlopen(WINDUMP_DOWNLOAD_URL, timeout=timeout) as response:
|
||||
data = response.read()
|
||||
temp.write_bytes(data)
|
||||
actual_sha1 = sha1_file(temp)
|
||||
if actual_sha1.lower() != WINDUMP_SHA1.lower():
|
||||
temp.unlink(missing_ok=True)
|
||||
return {
|
||||
"status": "failed",
|
||||
"reason": "sha1_mismatch",
|
||||
"expectedSha1": WINDUMP_SHA1,
|
||||
"actualSha1": actual_sha1,
|
||||
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
|
||||
"downloadUrl": WINDUMP_DOWNLOAD_URL,
|
||||
}
|
||||
if target.exists():
|
||||
target.unlink()
|
||||
temp.replace(target)
|
||||
return {
|
||||
"status": "downloaded",
|
||||
"path": str(target),
|
||||
"sha1": actual_sha1,
|
||||
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
|
||||
"downloadUrl": WINDUMP_DOWNLOAD_URL,
|
||||
}
|
||||
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
||||
temp.unlink(missing_ok=True)
|
||||
return {
|
||||
"status": "failed",
|
||||
"reason": type(exc).__name__,
|
||||
"message": str(exc),
|
||||
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
|
||||
"downloadUrl": WINDUMP_DOWNLOAD_URL,
|
||||
}
|
||||
|
||||
|
||||
def ensure_capture_tool(project_root: Path, explicit: str = "", auto_install: bool = False) -> Dict[str, Any]:
|
||||
existing = detect_tool(explicit, project_root)
|
||||
if existing:
|
||||
env_path = write_project_tool_env(project_root, Path(existing)) if Path(existing).exists() else None
|
||||
return {
|
||||
"status": "ok",
|
||||
"tool": existing,
|
||||
"source": "existing",
|
||||
"projectEnv": str(env_path) if env_path else "",
|
||||
}
|
||||
if not is_windows():
|
||||
return {
|
||||
"status": "missing",
|
||||
"tool": "",
|
||||
"reason": "tcpdump_or_windump_not_found",
|
||||
"hint": "Install tcpdump or pass --tool <path>.",
|
||||
}
|
||||
if not auto_install:
|
||||
return {
|
||||
"status": "missing",
|
||||
"tool": "",
|
||||
"reason": "tcpdump_or_windump_not_found",
|
||||
"hint": "Run /airndb enter or pass --tool <path>.",
|
||||
"downloadPage": WINDUMP_DOWNLOAD_PAGE,
|
||||
}
|
||||
target = tool_dir(project_root) / "WinDump.exe"
|
||||
download = download_windump(target)
|
||||
if download.get("status") != "downloaded":
|
||||
return {
|
||||
"status": "missing",
|
||||
"tool": "",
|
||||
"reason": "windump_download_failed",
|
||||
"download": download,
|
||||
"hint": "Install tcpdump/WinDump manually or set AIRNDB_TCPDUMP.",
|
||||
}
|
||||
env_path = write_project_tool_env(project_root, target)
|
||||
return {
|
||||
"status": "ok",
|
||||
"tool": str(target),
|
||||
"source": "downloaded",
|
||||
"projectEnv": str(env_path),
|
||||
"download": download,
|
||||
"driverHint": "WinDump still requires a packet capture driver such as Npcap or WinPcap; install one manually if interface listing fails.",
|
||||
}
|
||||
|
||||
|
||||
def split_filter(filter_expr: str) -> List[str]:
|
||||
if not filter_expr.strip():
|
||||
return []
|
||||
try:
|
||||
return shlex.split(filter_expr, posix=not is_windows())
|
||||
except ValueError:
|
||||
return filter_expr.split()
|
||||
|
||||
|
||||
def display_command(command: Sequence[str]) -> str:
|
||||
if is_windows():
|
||||
return subprocess.list2cmdline(list(command))
|
||||
return shlex.join(list(command))
|
||||
|
||||
|
||||
def tail(text: str, limit: int = 8000) -> str:
|
||||
if len(text) <= limit:
|
||||
return text
|
||||
return text[-limit:]
|
||||
|
||||
|
||||
def bounded_count(value: int) -> int:
|
||||
if value < 1:
|
||||
raise SystemExit("--count must be >= 1")
|
||||
if value > MAX_COUNT:
|
||||
raise SystemExit(f"--count must be <= {MAX_COUNT}")
|
||||
return value
|
||||
|
||||
|
||||
def capture_dir(project_root: Path, output_dir: str = "") -> Path:
|
||||
path = Path(output_dir).expanduser().resolve() if output_dir else project_root / "AirPlan" / "docs" / "network" / "airndb-captures"
|
||||
path.mkdir(parents=True, exist_ok=True)
|
||||
return path
|
||||
|
||||
|
||||
def base_capture_command(tool: str, iface: str, output: Path, count: int, filter_expr: str) -> List[str]:
|
||||
if not iface:
|
||||
raise SystemExit("--iface is required for capture or command")
|
||||
command = [tool, "-i", iface, "-nn", "-s", "0", "-w", str(output), "-c", str(bounded_count(count))]
|
||||
command.extend(split_filter(filter_expr))
|
||||
return command
|
||||
|
||||
|
||||
def read_command(tool: str, read_file: Path, filter_expr: str) -> List[str]:
|
||||
if not read_file.exists():
|
||||
raise SystemExit(f"read file not found: {read_file}")
|
||||
command = [tool, "-nn", "-tttt", "-r", str(read_file)]
|
||||
command.extend(split_filter(filter_expr))
|
||||
return command
|
||||
|
||||
|
||||
def run_command(command: Sequence[str], timeout: int) -> Dict[str, Any]:
|
||||
started = now_iso()
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
list(command),
|
||||
check=False,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
encoding="utf-8",
|
||||
errors="replace",
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"startedAt": started,
|
||||
"finishedAt": now_iso(),
|
||||
"timedOut": False,
|
||||
"returnCode": completed.returncode,
|
||||
"stdout": tail(completed.stdout),
|
||||
"stderr": tail(completed.stderr),
|
||||
}
|
||||
except subprocess.TimeoutExpired as exc:
|
||||
return {
|
||||
"startedAt": started,
|
||||
"finishedAt": now_iso(),
|
||||
"timedOut": True,
|
||||
"returnCode": None,
|
||||
"stdout": tail((exc.stdout or "") if isinstance(exc.stdout, str) else ""),
|
||||
"stderr": tail((exc.stderr or "") if isinstance(exc.stderr, str) else ""),
|
||||
}
|
||||
|
||||
|
||||
def write_report(output_dir: Path, action: str, payload: Dict[str, Any]) -> Path:
|
||||
report_path = output_dir / f"{now_stamp()}-airndb-{action}.json"
|
||||
report_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
||||
return report_path
|
||||
|
||||
|
||||
def append_log(project_root: Path, payload: Dict[str, Any]) -> None:
|
||||
log_path = project_root / "AirPlan" / "docs" / "network" / "airndb-log.md"
|
||||
log_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
lines = [
|
||||
"",
|
||||
f"## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB {payload['action']}",
|
||||
"",
|
||||
f"- Tool: `{payload.get('tool', 'missing')}`",
|
||||
f"- Interface: `{payload.get('interface', '')}`",
|
||||
f"- Filter: `{payload.get('filter', '')}`",
|
||||
f"- Command: `{payload.get('command', '')}`",
|
||||
f"- Artifacts: `{payload.get('artifact', '')}`",
|
||||
f"- Report: `{payload.get('report', '')}`",
|
||||
f"- Result: `{payload.get('result', '')}`",
|
||||
"- AirDbg handoff: TODO",
|
||||
"- Residual risk: pcap files may contain sensitive packet contents; review before sharing.",
|
||||
]
|
||||
with log_path.open("a", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write("\n".join(lines) + "\n")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="AirNDB tcpdump/WinDump helper.")
|
||||
parser.add_argument("--project", default=".")
|
||||
parser.add_argument("--action", choices=["interfaces", "command", "capture", "read"], default="interfaces")
|
||||
parser.add_argument("--tool", default="", help="Explicit tcpdump/WinDump path.")
|
||||
parser.add_argument("--iface", default="", help="Interface name or WinDump interface index.")
|
||||
parser.add_argument("--filter", default="", help="BPF filter expression.")
|
||||
parser.add_argument("--count", type=int, default=DEFAULT_COUNT)
|
||||
parser.add_argument("--timeout", type=int, default=DEFAULT_TIMEOUT)
|
||||
parser.add_argument("--output-dir", default="")
|
||||
parser.add_argument("--output", default="")
|
||||
parser.add_argument("--read-file", default="")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
output_dir = capture_dir(project_root, args.output_dir)
|
||||
tool = detect_tool(args.tool, project_root)
|
||||
if not tool:
|
||||
print("airndb_status=blocked")
|
||||
print("reason=tcpdump_or_windump_not_found")
|
||||
print("hint=Install tcpdump on Unix-like systems or WinDump/Npcap on Windows, or pass --tool <path>.")
|
||||
raise SystemExit(2)
|
||||
|
||||
if args.action == "interfaces":
|
||||
command = [tool, "-D"]
|
||||
result = run_command(command, max(5, args.timeout))
|
||||
payload = {
|
||||
"action": "interfaces",
|
||||
"tool": tool,
|
||||
"command": display_command(command),
|
||||
"result": result,
|
||||
}
|
||||
report = write_report(output_dir, "interfaces", payload)
|
||||
print("airndb_status=ok" if result["returnCode"] == 0 else "airndb_status=failed")
|
||||
print(f"tool={tool}")
|
||||
print(f"command={display_command(command)}")
|
||||
print(f"report={report}")
|
||||
print(result.get("stdout", ""))
|
||||
print(result.get("stderr", ""))
|
||||
append_log(project_root, {
|
||||
"action": "interfaces",
|
||||
"tool": tool,
|
||||
"interface": "",
|
||||
"filter": "",
|
||||
"command": display_command(command),
|
||||
"artifact": "",
|
||||
"report": str(report),
|
||||
"result": "ok" if result["returnCode"] == 0 else "failed",
|
||||
})
|
||||
return
|
||||
|
||||
if args.action == "command":
|
||||
output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap"
|
||||
command = base_capture_command(tool, args.iface, output, args.count, args.filter)
|
||||
print("airndb_status=ok")
|
||||
print(f"capture_command={display_command(command)}")
|
||||
print(f"output={output}")
|
||||
return
|
||||
|
||||
if args.action == "capture":
|
||||
output = Path(args.output).expanduser().resolve() if args.output else output_dir / f"{now_stamp()}-airndb.pcap"
|
||||
command = base_capture_command(tool, args.iface, output, args.count, args.filter)
|
||||
result = run_command(command, max(1, args.timeout))
|
||||
payload = {
|
||||
"action": "capture",
|
||||
"tool": tool,
|
||||
"interface": args.iface,
|
||||
"filter": args.filter,
|
||||
"count": bounded_count(args.count),
|
||||
"timeoutSeconds": args.timeout,
|
||||
"pcap": str(output),
|
||||
"command": display_command(command),
|
||||
"result": result,
|
||||
}
|
||||
report = write_report(output_dir, "capture", payload)
|
||||
status = "timeout" if result["timedOut"] else ("ok" if result["returnCode"] == 0 else "failed")
|
||||
print(f"airndb_status={status}")
|
||||
print(f"pcap={output}")
|
||||
print(f"report={report}")
|
||||
print(f"command={display_command(command)}")
|
||||
append_log(project_root, {
|
||||
"action": "capture",
|
||||
"tool": tool,
|
||||
"interface": args.iface,
|
||||
"filter": args.filter,
|
||||
"command": display_command(command),
|
||||
"artifact": str(output),
|
||||
"report": str(report),
|
||||
"result": status,
|
||||
})
|
||||
return
|
||||
|
||||
if args.action == "read":
|
||||
if not args.read_file:
|
||||
raise SystemExit("--read-file is required for read")
|
||||
read_file = Path(args.read_file).expanduser()
|
||||
if not read_file.is_absolute():
|
||||
read_file = project_root / read_file
|
||||
read_file = read_file.resolve()
|
||||
command = read_command(tool, read_file, args.filter)
|
||||
result = run_command(command, max(1, args.timeout))
|
||||
summary_path = output_dir / f"{now_stamp()}-airndb-read.txt"
|
||||
summary_path.write_text((result.get("stdout") or "") + (result.get("stderr") or ""), encoding="utf-8", newline="\n")
|
||||
payload = {
|
||||
"action": "read",
|
||||
"tool": tool,
|
||||
"readFile": str(read_file),
|
||||
"filter": args.filter,
|
||||
"summary": str(summary_path),
|
||||
"command": display_command(command),
|
||||
"result": result,
|
||||
}
|
||||
report = write_report(output_dir, "read", payload)
|
||||
status = "ok" if result["returnCode"] == 0 else "failed"
|
||||
print(f"airndb_status={status}")
|
||||
print(f"summary={summary_path}")
|
||||
print(f"report={report}")
|
||||
print(f"command={display_command(command)}")
|
||||
append_log(project_root, {
|
||||
"action": "read",
|
||||
"tool": tool,
|
||||
"interface": "",
|
||||
"filter": args.filter,
|
||||
"command": display_command(command),
|
||||
"artifact": str(summary_path),
|
||||
"report": str(report),
|
||||
"result": status,
|
||||
})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
271
scripts/airndb_mode.py
Normal file
271
scripts/airndb_mode.py
Normal file
@@ -0,0 +1,271 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Bootstrap AirNDB network debugging context files."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Dict, Tuple
|
||||
|
||||
from airndb_capture import ensure_capture_tool
|
||||
|
||||
MARKER_BEGIN = "<!-- AIRNDB:BEGIN -->"
|
||||
MARKER_END = "<!-- AIRNDB:END -->"
|
||||
|
||||
|
||||
def now_iso() -> str:
|
||||
return datetime.now(timezone.utc).isoformat()
|
||||
|
||||
|
||||
def airndb_agents_block() -> str:
|
||||
return f"""{MARKER_BEGIN}
|
||||
## AirNDB Network Debug Workflow
|
||||
|
||||
1. Use AirNDB for `/airndb` sessions that need tcpdump/WinDump packet capture, pcap reading, BPF filters, or network evidence.
|
||||
2. On first entry, detect tcpdump/WinDump; on Windows, if none is available, download official `WinDump.exe` from https://www.winpcap.org/windump/install/, verify SHA1, and write `AirPlan/state/airndb/tool.env`.
|
||||
3. Only capture authorized traffic; prefer short bounded captures with `-nn`, `-s 0`, `-c <count>`, and narrow BPF filters.
|
||||
4. Before capture, load:
|
||||
- `AirPlan/AGENTS.md`
|
||||
- `AirPlan/docs/architecture/adr/`
|
||||
- `AirPlan/docs/architecture/c4/module.md`
|
||||
- `AirPlan/docs/network/airndb-log.md`
|
||||
5. Store pcap, text summaries, and JSON reports under `AirPlan/docs/network/airndb-captures/`.
|
||||
6. Record exact command, interface, filter, capture window, artifact paths, key observations, and residual risk in `AirPlan/docs/network/airndb-log.md`.
|
||||
7. AirDbg may call AirNDB when debugging needs DNS/TCP/UDP/TLS/HTTP, ports, proxy, firewall, packet-loss, retransmit, reset, or pcap evidence.
|
||||
8. Update ADR/C4 when network boundaries, capture tooling, observability, ports, protocols, DNS, proxy, TLS, or runtime topology become durable architecture context.
|
||||
{MARKER_END}
|
||||
"""
|
||||
|
||||
|
||||
def c4_module_template() -> str:
|
||||
return """# C4 Module
|
||||
|
||||
## System Context
|
||||
- TODO: Describe the system, users, and important external systems.
|
||||
|
||||
## Containers
|
||||
- TODO: Describe runtime/deployable units and network boundaries.
|
||||
|
||||
## Modules
|
||||
|
||||
| Module | Responsibility | Network Interfaces | Dependencies | Data Ownership | Network Debug Notes |
|
||||
| --- | --- | --- | --- | --- | --- |
|
||||
| TODO | TODO | TODO | TODO | TODO | TODO |
|
||||
|
||||
## Network / Observability Boundaries
|
||||
- tcpdump/WinDump capture points, interfaces, container/WSL/VM/host boundaries: TODO
|
||||
- Ports, protocols, DNS, proxy, TLS, firewall, NAT, or gateway notes: TODO
|
||||
|
||||
## Change Log
|
||||
- TODO: Record network-boundary or observability changes discovered by AirNDB.
|
||||
"""
|
||||
|
||||
|
||||
def adr_template() -> str:
|
||||
return """# ADR-0001: AirNDB Packet Capture Governance
|
||||
|
||||
- Status: Accepted
|
||||
- Date: TODO
|
||||
|
||||
## Context
|
||||
Network debugging needs durable, AI-readable packet capture context and bounded evidence collection.
|
||||
|
||||
## Decision
|
||||
Use AirNDB to build safe tcpdump/WinDump commands, capture or read pcap artifacts, and maintain `AirPlan/docs/network/airndb-log.md`, C4 module docs, and ADR records when network boundaries or diagnostics change.
|
||||
|
||||
## Consequences
|
||||
- Packet evidence can be reused by future AirDbg or AirNDB sessions.
|
||||
- Captures must stay bounded and authorized.
|
||||
- pcap artifacts may contain sensitive data and need careful handling.
|
||||
|
||||
## Alternatives
|
||||
- Chat-only packet notes: rejected because commands, filters, and pcap paths are easy to lose.
|
||||
"""
|
||||
|
||||
|
||||
def network_log_template() -> str:
|
||||
return """# AirNDB Network Debug Log
|
||||
|
||||
Append entries for AirNDB packet capture sessions.
|
||||
|
||||
## Entry Template
|
||||
|
||||
### YYYY-MM-DD: short network issue title
|
||||
|
||||
- Scope / authorization: TODO
|
||||
- Symptom: TODO
|
||||
- Interface: TODO
|
||||
- Filter: TODO
|
||||
- Capture window: TODO
|
||||
- Command: TODO
|
||||
- Artifacts: TODO
|
||||
- Key observations: TODO
|
||||
- AirDbg handoff: TODO
|
||||
- ADR/C4 updates: TODO
|
||||
- Residual risk: TODO
|
||||
"""
|
||||
|
||||
|
||||
def captures_gitignore_template() -> str:
|
||||
return """*.pcap
|
||||
*.pcapng
|
||||
*.cap
|
||||
*.txt
|
||||
*.json
|
||||
!.gitignore
|
||||
"""
|
||||
|
||||
|
||||
def airndb_gitignore_template() -> str:
|
||||
return """tool.env
|
||||
"""
|
||||
|
||||
|
||||
def write_if_missing(path: Path, content: str) -> bool:
|
||||
if path.exists():
|
||||
return False
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(content, encoding="utf-8", newline="\n")
|
||||
return True
|
||||
|
||||
|
||||
def upsert_agents_md(path: Path) -> str:
|
||||
block = airndb_agents_block().rstrip() + "\n"
|
||||
if path.exists():
|
||||
original = path.read_text(encoding="utf-8")
|
||||
existed = True
|
||||
else:
|
||||
original = "# AGENTS.md\n\n"
|
||||
existed = False
|
||||
|
||||
begin = original.find(MARKER_BEGIN)
|
||||
end = original.find(MARKER_END)
|
||||
if begin >= 0 and end > begin:
|
||||
end += len(MARKER_END)
|
||||
updated = original[:begin].rstrip() + "\n\n" + block + original[end:].lstrip()
|
||||
status = "updated"
|
||||
else:
|
||||
updated = original.rstrip() + "\n\n" + block
|
||||
status = "updated" if existed else "created"
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(updated, encoding="utf-8", newline="\n")
|
||||
return status
|
||||
|
||||
|
||||
def artifact_map(project_root: Path) -> Dict[str, Path]:
|
||||
return {
|
||||
"AGENTS.md": project_root / "AirPlan" / "AGENTS.md",
|
||||
"c4_module": project_root / "AirPlan" / "docs" / "architecture" / "c4" / "module.md",
|
||||
"adr_dir": project_root / "AirPlan" / "docs" / "architecture" / "adr",
|
||||
"adr_0001": project_root / "AirPlan" / "docs" / "architecture" / "adr" / "ADR-0001-airndb-packet-capture-governance.md",
|
||||
"network_log": project_root / "AirPlan" / "docs" / "network" / "airndb-log.md",
|
||||
"captures_dir": project_root / "AirPlan" / "docs" / "network" / "airndb-captures",
|
||||
"captures_gitignore": project_root / "AirPlan" / "docs" / "network" / "airndb-captures" / ".gitignore",
|
||||
"tool_env": project_root / "AirPlan" / "state" / "airndb" / "tool.env",
|
||||
"airndb_gitignore": project_root / "AirPlan" / "state" / "airndb" / ".gitignore",
|
||||
"state": project_root / "AirPlan" / "state" / "airndb" / "state.json",
|
||||
}
|
||||
|
||||
|
||||
def write_state(path: Path, enabled: bool, project_root: Path) -> None:
|
||||
artifacts = artifact_map(project_root)
|
||||
health = {
|
||||
name: artifacts[name].exists()
|
||||
for name in [
|
||||
"AGENTS.md",
|
||||
"c4_module",
|
||||
"adr_dir",
|
||||
"adr_0001",
|
||||
"network_log",
|
||||
"captures_dir",
|
||||
"captures_gitignore",
|
||||
"tool_env",
|
||||
"airndb_gitignore",
|
||||
]
|
||||
}
|
||||
payload = {
|
||||
"enabled": enabled,
|
||||
"updatedAt": now_iso(),
|
||||
"projectRoot": str(project_root),
|
||||
"artifactHealth": health,
|
||||
"captureTool": ensure_capture_tool(project_root, auto_install=False),
|
||||
}
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def enter_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
results: Dict[str, str] = {}
|
||||
results["AGENTS.md"] = upsert_agents_md(artifacts["AGENTS.md"])
|
||||
results["c4_module"] = "created" if write_if_missing(artifacts["c4_module"], c4_module_template()) else "exists"
|
||||
artifacts["adr_dir"].mkdir(parents=True, exist_ok=True)
|
||||
results["adr_dir"] = "exists"
|
||||
results["adr_0001"] = "created" if write_if_missing(artifacts["adr_0001"], adr_template()) else "exists"
|
||||
results["network_log"] = "created" if write_if_missing(artifacts["network_log"], network_log_template()) else "exists"
|
||||
artifacts["captures_dir"].mkdir(parents=True, exist_ok=True)
|
||||
results["captures_dir"] = "exists"
|
||||
results["captures_gitignore"] = "created" if write_if_missing(artifacts["captures_gitignore"], captures_gitignore_template()) else "exists"
|
||||
results["airndb_gitignore"] = "created" if write_if_missing(artifacts["airndb_gitignore"], airndb_gitignore_template()) else "exists"
|
||||
tool_status = ensure_capture_tool(project_root, auto_install=True)
|
||||
results["capture_tool"] = tool_status.get("status", "unknown")
|
||||
results["capture_tool_path"] = tool_status.get("tool", "")
|
||||
results["capture_tool_source"] = tool_status.get("source", "")
|
||||
results["capture_tool_env"] = tool_status.get("projectEnv", "")
|
||||
if tool_status.get("driverHint"):
|
||||
results["capture_driver_hint"] = tool_status["driverHint"]
|
||||
write_state(artifacts["state"], True, project_root)
|
||||
return "enabled", results
|
||||
|
||||
|
||||
def exit_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
write_state(artifacts["state"], False, project_root)
|
||||
return "disabled", {}
|
||||
|
||||
|
||||
def status_mode(project_root: Path) -> Tuple[str, Dict[str, str]]:
|
||||
artifacts = artifact_map(project_root)
|
||||
state_file = artifacts["state"]
|
||||
enabled = False
|
||||
if state_file.exists():
|
||||
try:
|
||||
payload = json.loads(state_file.read_text(encoding="utf-8"))
|
||||
enabled = bool(payload.get("enabled"))
|
||||
except json.JSONDecodeError:
|
||||
enabled = False
|
||||
results = {
|
||||
name: ("ok" if path.exists() else "missing")
|
||||
for name, path in artifacts.items()
|
||||
if name != "state"
|
||||
}
|
||||
return ("enabled" if enabled else "disabled"), results
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Manage AirNDB network debug artifacts.")
|
||||
parser.add_argument("--mode", choices=["enter", "setup", "exit", "status"], default="enter")
|
||||
parser.add_argument("--project", default=".")
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
project_root = Path(args.project).expanduser().resolve()
|
||||
if args.mode in {"enter", "setup"}:
|
||||
mode_state, result = enter_mode(project_root)
|
||||
elif args.mode == "exit":
|
||||
mode_state, result = exit_mode(project_root)
|
||||
else:
|
||||
mode_state, result = status_mode(project_root)
|
||||
print(f"airndb_mode={mode_state}")
|
||||
print(f"project_root={project_root}")
|
||||
for key, value in result.items():
|
||||
print(f"{key}={value}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
136
scripts/airndb_remote_device.py
Normal file
136
scripts/airndb_remote_device.py
Normal file
@@ -0,0 +1,136 @@
|
||||
#!/usr/bin/env python3
|
||||
"""AirNDB remote packet capture helper over SSH."""
|
||||
from __future__ import annotations
|
||||
import argparse, base64, json, os, shlex, shutil, subprocess
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
MAX_COUNT = 500000
|
||||
|
||||
def stamp(): return datetime.now().strftime('%Y%m%d-%H%M%S')
|
||||
def iso(): return datetime.now(timezone.utc).isoformat()
|
||||
def q(s): return shlex.quote(str(s))
|
||||
def split(s): return shlex.split(s, posix=True) if s.strip() else []
|
||||
|
||||
def read_env(path):
|
||||
vals = {}
|
||||
if path.exists():
|
||||
for raw in path.read_text(encoding='utf-8-sig').splitlines():
|
||||
line = raw.strip()
|
||||
if line and not line.startswith('#') and '=' in line:
|
||||
k, v = line.split('=', 1); vals[k.strip()] = v.strip().strip('"').strip("'")
|
||||
for k in ['AIRNDB_REMOTE_SSH_TARGET','AIRNDB_REMOTE_SSH_PORT','AIRNDB_REMOTE_SSH_OPTIONS','AIRNDB_REMOTE_WORKDIR','AIRNDB_REMOTE_TCPDUMP','AIRNDB_REMOTE_CAPTURE_PREFIX']:
|
||||
if os.environ.get(k): vals[k] = os.environ[k]
|
||||
vals.setdefault('AIRNDB_REMOTE_SSH_PORT','22'); vals.setdefault('AIRNDB_REMOTE_TCPDUMP','auto'); vals.setdefault('AIRNDB_REMOTE_CAPTURE_PREFIX','sudo -n')
|
||||
return vals
|
||||
|
||||
def write_env(path, updates):
|
||||
path.parent.mkdir(parents=True, exist_ok=True); old = path.read_text(encoding='utf-8') if path.exists() else ''; keys = set(updates); lines = []
|
||||
for raw in old.splitlines():
|
||||
key = raw.split('=',1)[0].strip() if '=' in raw and not raw.strip().startswith('#') else None
|
||||
if key not in keys: lines.append(raw)
|
||||
if lines and lines[-1].strip(): lines.append('')
|
||||
for k, v in updates.items():
|
||||
if v: lines.append(f'{k}={v}')
|
||||
path.write_text('\n'.join(lines).rstrip()+'\n', encoding='utf-8', newline='\n')
|
||||
|
||||
def ensure_files(project):
|
||||
d = project/'AirPlan'/'state'/'airndb'; d.mkdir(parents=True, exist_ok=True); ex = d/'remote-device.env.example'
|
||||
if not ex.exists():
|
||||
ex.write_text('# AirNDB remote packet-capture device configuration\nAIRNDB_REMOTE_SSH_TARGET=user@host\nAIRNDB_REMOTE_SSH_PORT=22\nAIRNDB_REMOTE_SSH_OPTIONS=\nAIRNDB_REMOTE_WORKDIR=\nAIRNDB_REMOTE_TCPDUMP=auto\nAIRNDB_REMOTE_CAPTURE_PREFIX=sudo -n\n', encoding='utf-8', newline='\n')
|
||||
gi = d/'.gitignore'; old = gi.read_text(encoding='utf-8') if gi.exists() else ''
|
||||
for item in ['remote-device.env','tool.env']:
|
||||
if item not in old.splitlines(): old = (old.rstrip()+f'\n{item}\n').lstrip()
|
||||
gi.write_text(old, encoding='utf-8', newline='\n')
|
||||
|
||||
def ssh_args(env, cmd):
|
||||
target = env.get('AIRNDB_REMOTE_SSH_TARGET','')
|
||||
if not target: raise SystemExit('AIRNDB_REMOTE_SSH_TARGET is required')
|
||||
args = [shutil.which('ssh') or 'ssh']; port = env.get('AIRNDB_REMOTE_SSH_PORT','22')
|
||||
if port: args += ['-p', port]
|
||||
args += split(env.get('AIRNDB_REMOTE_SSH_OPTIONS','')); return args + [target, cmd]
|
||||
|
||||
def run(env, cmd, timeout=60):
|
||||
return subprocess.run(ssh_args(env, cmd), capture_output=True, text=True, encoding='utf-8', errors='replace', timeout=timeout)
|
||||
|
||||
def remote_home(env):
|
||||
r = run(env, 'printf %s "$HOME"', 15); return r.stdout.strip() if r.returncode == 0 else ''
|
||||
|
||||
def workdir(env):
|
||||
if env.get('AIRNDB_REMOTE_WORKDIR'): return env['AIRNDB_REMOTE_WORKDIR']
|
||||
home = remote_home(env); return (home.rstrip('/')+'/.airndb') if home else '.airndb'
|
||||
|
||||
def detect_tool(env):
|
||||
tool = env.get('AIRNDB_REMOTE_TCPDUMP','auto').strip()
|
||||
if tool and tool != 'auto': return tool
|
||||
r = run(env, 'for t in tcpdump dumpcap windump WinDump.exe; do command -v "$t" >/dev/null 2>&1 && { command -v "$t"; exit 0; }; done', 15)
|
||||
return r.stdout.strip().splitlines()[-1] if r.returncode == 0 and r.stdout.strip() else ''
|
||||
|
||||
def install_tool(env):
|
||||
script = """set -e
|
||||
if command -v tcpdump >/dev/null 2>&1; then exit 0; fi
|
||||
if [ "$(id -u 2>/dev/null || echo 1)" = "0" ]; then SUDO=""; else SUDO="sudo -n"; fi
|
||||
if command -v apt-get >/dev/null 2>&1; then $SUDO apt-get update && $SUDO apt-get install -y tcpdump;
|
||||
elif command -v dnf >/dev/null 2>&1; then $SUDO dnf install -y tcpdump;
|
||||
elif command -v yum >/dev/null 2>&1; then $SUDO yum install -y tcpdump;
|
||||
elif command -v apk >/dev/null 2>&1; then $SUDO apk add tcpdump;
|
||||
elif command -v pacman >/dev/null 2>&1; then $SUDO pacman -Sy --noconfirm tcpdump;
|
||||
else exit 42; fi"""
|
||||
return run(env, script, 300)
|
||||
|
||||
def setup(project, env, auto=True):
|
||||
if not env.get('AIRNDB_REMOTE_SSH_TARGET'):
|
||||
return {'status':'blocked','reason':'missing_remote_target','example':str(project/'AirPlan'/'state'/'airndb'/'remote-device.env.example')}
|
||||
if not shutil.which('ssh'): return {'status':'blocked','reason':'ssh_not_found'}
|
||||
probe = run(env, 'printf ok', 20)
|
||||
if probe.returncode: return {'status':'blocked','reason':'ssh_probe_failed','stderr':probe.stderr.strip()}
|
||||
wd = workdir(env); mk = run(env, f'mkdir -p {q(wd)}', 20)
|
||||
if mk.returncode: return {'status':'blocked','reason':'remote_workdir_failed','stderr':mk.stderr.strip()}
|
||||
tool = detect_tool(env); install = 'skipped'
|
||||
if not tool and auto:
|
||||
inst = install_tool(env); install = 'ok' if inst.returncode == 0 else f'failed:{inst.returncode}'; tool = detect_tool(env)
|
||||
upd = {'AIRNDB_REMOTE_WORKDIR':wd}
|
||||
if tool: upd['AIRNDB_REMOTE_TCPDUMP'] = tool
|
||||
write_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env', upd)
|
||||
return {'status':'ok' if tool else 'blocked','target':env.get('AIRNDB_REMOTE_SSH_TARGET',''),'workdir':wd,'tool':tool,'capturePrefix':env.get('AIRNDB_REMOTE_CAPTURE_PREFIX',''),'autoConfigure':install,'hint':'' if tool else 'Install tcpdump/dumpcap or set AIRNDB_REMOTE_TCPDUMP.'}
|
||||
|
||||
def bounded(n):
|
||||
if n < 1 or n > MAX_COUNT: raise SystemExit(f'--count must be 1..{MAX_COUNT}')
|
||||
return n
|
||||
|
||||
def filt(expr):
|
||||
if not expr.strip(): return []
|
||||
try: return split(expr)
|
||||
except ValueError: return expr.split()
|
||||
|
||||
def cap_cmd(env, tool, iface, out, count, filter_expr):
|
||||
if not iface: raise SystemExit('--iface is required')
|
||||
parts = split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-i',iface,'-nn','-s','0','-w',out,'-c',str(bounded(count))] + filt(filter_expr)
|
||||
return shlex.join(parts)
|
||||
|
||||
def outdir(project, configured):
|
||||
p = Path(configured).expanduser().resolve() if configured else project/'AirPlan'/'docs'/'network'/'airndb-captures'; p.mkdir(parents=True, exist_ok=True); return p
|
||||
|
||||
def emit(prefix, data):
|
||||
print(f'{prefix}_status={data.get("status","unknown")}')
|
||||
for k, v in data.items():
|
||||
if k != 'status': print(f'{k}={json.dumps(v, ensure_ascii=False) if isinstance(v,(dict,list)) else v}')
|
||||
|
||||
def log(project, data):
|
||||
lp = project/'AirPlan'/'docs'/'network'/'airndb-log.md'; lp.parent.mkdir(parents=True, exist_ok=True)
|
||||
lp.open('a', encoding='utf-8', newline='\n').write(f"\n## {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}: AirNDB remote {data.get('action','capture')}\n\n- Remote target: `{data.get('target','')}`\n- Tool: `{data.get('tool','')}`\n- Interface: `{data.get('interface','')}`\n- Filter: `{data.get('filter','')}`\n- Command: `{data.get('command','')}`\n- Artifacts: `{data.get('artifact','')}`\n- Report: `{data.get('report','')}`\n- Result: `{data.get('status','')}`\n- AirDbg handoff: TODO\n- Residual risk: remote pcap files may contain sensitive packet contents.\n")
|
||||
|
||||
def main():
|
||||
ap = argparse.ArgumentParser(); ap.add_argument('--project', default='.'); ap.add_argument('--action', choices=['setup','status','interfaces','command','capture'], default='setup'); ap.add_argument('--iface', default=''); ap.add_argument('--filter', default=''); ap.add_argument('--count', type=int, default=200); ap.add_argument('--timeout', type=int, default=30); ap.add_argument('--output-dir', default=''); ap.add_argument('--output', default=''); ap.add_argument('--no-auto-configure', action='store_true'); a = ap.parse_args()
|
||||
project = Path(a.project).expanduser().resolve(); ensure_files(project); env = read_env(project/'AirPlan'/'state'/'airndb'/'remote-device.env')
|
||||
info = setup(project, env, auto=(a.action!='status' and not a.no_auto_configure))
|
||||
if a.action in ['setup','status'] or info.get('status') != 'ok': emit('airndb_remote', info); raise SystemExit(0 if info.get('status')=='ok' else 2)
|
||||
od = outdir(project, a.output_dir); tool = info['tool']
|
||||
if a.action == 'interfaces':
|
||||
icmd = shlex.join(split(env.get('AIRNDB_REMOTE_CAPTURE_PREFIX','')) + [tool,'-D']); r = run(env, icmd, max(5,a.timeout)); data = {'action':'interfaces','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'command':shlex.join(ssh_args(env, icmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip()}; report = od/(stamp()+'-airndb-remote-interfaces.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report']=str(report); log(project,data); emit('airndb_remote',data); print(r.stdout); print(r.stderr); raise SystemExit(0 if data['status']=='ok' else 1)
|
||||
remote = a.output or info['workdir'].rstrip('/') + '/' + stamp() + '-airndb-remote.pcap'; cmd = cap_cmd(env, tool, a.iface, remote, a.count, a.filter)
|
||||
if a.action == 'command': emit('airndb_remote', {'status':'ok','target':info['target'],'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd))}); return
|
||||
r = run(env, cmd, max(1,a.timeout)); data = {'action':'capture','status':'ok' if r.returncode==0 else 'failed','target':info['target'],'tool':tool,'interface':a.iface,'filter':a.filter,'count':bounded(a.count),'timeoutSeconds':a.timeout,'remotePcap':remote,'command':shlex.join(ssh_args(env, cmd)),'stdout':r.stdout.strip(),'stderr':r.stderr.strip(),'capturedAt':iso()}
|
||||
if r.returncode == 0:
|
||||
b64 = run(env, f'base64 < {q(remote)}', a.timeout); local = od/(stamp()+'-airndb-remote.pcap'); local.write_bytes(base64.b64decode(''.join(b64.stdout.split()))); data['artifact'] = str(local)
|
||||
report = od/(stamp()+'-airndb-remote-capture.json'); report.write_text(json.dumps(data, ensure_ascii=False, indent=2)+'\n', encoding='utf-8'); data['report'] = str(report); log(project, data); emit('airndb_remote', data); raise SystemExit(0 if data['status']=='ok' else 1)
|
||||
if __name__ == '__main__': main()
|
||||
82
scripts/install_airndb_plugin.py
Normal file
82
scripts/install_airndb_plugin.py
Normal file
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Install the AirNDB plugin into the current user's home-local plugin directory."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict
|
||||
|
||||
PLUGIN_NAME = "airndb"
|
||||
|
||||
|
||||
def copy_plugin(source: Path, target: Path) -> None:
|
||||
if source.resolve() == target.resolve():
|
||||
return
|
||||
if target.exists():
|
||||
shutil.rmtree(target)
|
||||
ignore = shutil.ignore_patterns("__pycache__", "*.pyc", ".DS_Store", ".git")
|
||||
shutil.copytree(source, target, ignore=ignore)
|
||||
|
||||
|
||||
def marketplace_payload() -> Dict[str, Any]:
|
||||
return {
|
||||
"name": "local-airarc",
|
||||
"interface": {"displayName": "Local AirArc Plugins"},
|
||||
"plugins": [],
|
||||
}
|
||||
|
||||
|
||||
def update_marketplace(path: Path) -> None:
|
||||
if path.exists():
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
else:
|
||||
payload = marketplace_payload()
|
||||
payload.setdefault("name", "local-airarc")
|
||||
payload.setdefault("interface", {}).setdefault("displayName", "Local AirArc Plugins")
|
||||
plugins = payload.setdefault("plugins", [])
|
||||
entry = {
|
||||
"name": PLUGIN_NAME,
|
||||
"source": {
|
||||
"source": "local",
|
||||
"path": f"./plugins/{PLUGIN_NAME}",
|
||||
},
|
||||
"policy": {
|
||||
"installation": "INSTALLED_BY_DEFAULT",
|
||||
"authentication": "ON_INSTALL",
|
||||
},
|
||||
"category": "Productivity",
|
||||
}
|
||||
for index, existing in enumerate(plugins):
|
||||
if existing.get("name") == PLUGIN_NAME:
|
||||
plugins[index] = entry
|
||||
break
|
||||
else:
|
||||
plugins.append(entry)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Install AirNDB as a home-local Codex plugin.")
|
||||
parser.add_argument("--source", default=str(Path(__file__).resolve().parents[1]))
|
||||
parser.add_argument("--home", default=str(Path.home()))
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def main() -> None:
|
||||
args = parse_args()
|
||||
source = Path(args.source).expanduser().resolve()
|
||||
home = Path(args.home).expanduser().resolve()
|
||||
target = home / "plugins" / PLUGIN_NAME
|
||||
marketplace = home / ".agents" / "plugins" / "marketplace.json"
|
||||
copy_plugin(source, target)
|
||||
update_marketplace(marketplace)
|
||||
print(f"installed_plugin={target}")
|
||||
print(f"marketplace={marketplace}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user