aiops-wrap 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
aiops_wrap/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ """aiops-wrap: instrument any scripted agent with zero code changes."""
2
+
3
+ from __future__ import annotations
4
+
5
+ __version__ = "0.1.0"
6
+ __all__ = ["__version__"]
aiops_wrap/__main__.py ADDED
@@ -0,0 +1,4 @@
1
+ from aiops_wrap.cli import main
2
+
3
+ if __name__ == "__main__":
4
+ raise SystemExit(main())
aiops_wrap/cli.py ADDED
@@ -0,0 +1,127 @@
1
+ """`aiops` command-line entry point: `aiops join` and `aiops wrap -- <command>`."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import sys
7
+ from collections.abc import Sequence
8
+
9
+ from aiops_wrap import __version__
10
+ from aiops_wrap.config import DEFAULT_BASE_URL, load_settings, save_global_setting
11
+ from aiops_wrap.join import JoinError, join
12
+ from aiops_wrap.wrap import run_wrapped
13
+
14
+
15
+ def _build_parser() -> argparse.ArgumentParser:
16
+ parser = argparse.ArgumentParser(
17
+ prog="aiops",
18
+ description="Instrument any scripted agent with zero code changes.",
19
+ )
20
+ parser.add_argument("--version", action="version", version=f"aiops-wrap {__version__}")
21
+ subparsers = parser.add_subparsers(dest="command", required=True)
22
+
23
+ join_parser = subparsers.add_parser(
24
+ "join", help="Self-register this agent with AiOps Enabler and store credentials."
25
+ )
26
+ join_parser.add_argument(
27
+ "--email", required=True, help="Operator email (never shown publicly)."
28
+ )
29
+ join_parser.add_argument("--name", help="Agent name (defaults to the current directory name).")
30
+ join_parser.add_argument(
31
+ "--category",
32
+ default="other",
33
+ choices=["incident-response", "alert-triage", "remediation", "observability", "other"],
34
+ )
35
+ join_parser.add_argument("--description")
36
+ join_parser.add_argument("--repo-url")
37
+ join_parser.add_argument("--base-url", default=DEFAULT_BASE_URL)
38
+
39
+ wrap_parser = subparsers.add_parser("wrap", help="Run a command and report it as a task event.")
40
+ wrap_parser.add_argument(
41
+ "wrapped_command",
42
+ nargs=argparse.REMAINDER,
43
+ help="The command to run, e.g. `aiops wrap -- python my_agent.py`.",
44
+ )
45
+ wrap_parser.add_argument(
46
+ "-q", "--quiet", action="store_true", help="Suppress reporting warnings."
47
+ )
48
+
49
+ configure_parser = subparsers.add_parser(
50
+ "configure", help="Set a persistent, non-secret config value in ~/.aiops/config.json."
51
+ )
52
+ configure_parser.add_argument(
53
+ "key", choices=["category", "heartbeat_interval_seconds", "enabled", "base_url"]
54
+ )
55
+ configure_parser.add_argument("value")
56
+
57
+ return parser
58
+
59
+
60
+ def _run_join(args: argparse.Namespace) -> int:
61
+ import os
62
+
63
+ name = args.name or os.path.basename(os.getcwd())
64
+ try:
65
+ result = join(
66
+ email=args.email,
67
+ name=name,
68
+ category=args.category,
69
+ description=args.description,
70
+ repo_url=args.repo_url,
71
+ base_url=args.base_url,
72
+ )
73
+ except (JoinError, ValueError) as exc:
74
+ print(f"aiops join failed: {exc}", file=sys.stderr)
75
+ return 1
76
+
77
+ print(f"Joined as '{result.agent_name}' (slug: {result.agent_slug}).")
78
+ print(f"Credentials saved to ~/.aiops/credentials.json (key id: {result.key_id}).")
79
+ print(result.claim_note)
80
+ print("Run `aiops wrap -- <your command>` to start reporting task events.")
81
+ return 0
82
+
83
+
84
+ def _run_wrap(args: argparse.Namespace) -> int:
85
+ command = list(args.wrapped_command)
86
+ if command and command[0] == "--":
87
+ command = command[1:]
88
+ if not command:
89
+ print(
90
+ "aiops wrap: no command given. Usage: aiops wrap -- <command> [args...]",
91
+ file=sys.stderr,
92
+ )
93
+ return 2
94
+
95
+ settings = load_settings()
96
+ result = run_wrapped(command, settings=settings, quiet=args.quiet)
97
+ return result.exit_code
98
+
99
+
100
+ def _run_configure(args: argparse.Namespace) -> int:
101
+ value: object = args.value
102
+ if args.key == "heartbeat_interval_seconds":
103
+ value = int(args.value)
104
+ elif args.key == "enabled":
105
+ value = args.value.strip().lower() not in ("0", "false", "no", "off")
106
+ save_global_setting(args.key, value)
107
+ print(f"Set {args.key} = {value!r} in ~/.aiops/config.json")
108
+ return 0
109
+
110
+
111
+ def main(argv: Sequence[str] | None = None) -> int:
112
+ parser = _build_parser()
113
+ args = parser.parse_args(argv)
114
+
115
+ if args.command == "join":
116
+ return _run_join(args)
117
+ if args.command == "wrap":
118
+ return _run_wrap(args)
119
+ if args.command == "configure":
120
+ return _run_configure(args)
121
+
122
+ parser.print_help()
123
+ return 2
124
+
125
+
126
+ if __name__ == "__main__":
127
+ raise SystemExit(main())
aiops_wrap/config.py ADDED
@@ -0,0 +1,195 @@
1
+ """Config + credential storage for aiops-wrap.
2
+
3
+ Two concerns are deliberately kept in separate files so a project-local
4
+ override file can safely be committed to a repo without ever risking a
5
+ leaked secret:
6
+
7
+ - ``~/.aiops/credentials.json`` — the agent key pair from `aiops join`.
8
+ Never read from a project-local file, never settable via a project-local
9
+ override; only the global per-user file or the ``AIOPS_KEY_ID`` /
10
+ ``AIOPS_SECRET`` environment variables (for CI use, e.g. GitHub Actions
11
+ secrets).
12
+ - Settings (base URL, category, heartbeat interval, the opt-in
13
+ ``enabled`` flag) resolve from, in increasing priority: built-in
14
+ defaults -> ``~/.aiops/config.json`` -> ``.aiops.json`` in the current
15
+ directory (or nearest ancestor) -> ``AIOPS_*`` environment variables.
16
+
17
+ Everything is JSON — no extra parsing dependency, and both files are
18
+ meant to be hand-editable.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import contextlib
24
+ import json
25
+ import os
26
+ import stat
27
+ from dataclasses import dataclass
28
+ from pathlib import Path
29
+ from typing import Any
30
+
31
+ DEFAULT_BASE_URL = "https://api.aiopsenabler.com"
32
+ DEFAULT_CATEGORY = "other"
33
+ DEFAULT_HEARTBEAT_INTERVAL_SECONDS = 1800 # 30 minutes, per skill.md guidance
34
+
35
+ _PROJECT_CONFIG_FILENAME = ".aiops.json"
36
+
37
+
38
+ def aiops_home() -> Path:
39
+ """The per-user aiops-wrap directory, ``~/.aiops`` — overridable via
40
+ ``AIOPS_HOME`` for tests and sandboxed/CI environments."""
41
+ override = os.environ.get("AIOPS_HOME")
42
+ if override:
43
+ return Path(override)
44
+ return Path.home() / ".aiops"
45
+
46
+
47
+ def credentials_path() -> Path:
48
+ return aiops_home() / "credentials.json"
49
+
50
+
51
+ def global_config_path() -> Path:
52
+ return aiops_home() / "config.json"
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Credentials:
57
+ key_id: str
58
+ secret: str
59
+ base_url: str
60
+ agent_slug: str | None = None
61
+ agent_name: str | None = None
62
+
63
+
64
+ @dataclass(frozen=True)
65
+ class Settings:
66
+ base_url: str = DEFAULT_BASE_URL
67
+ category: str = DEFAULT_CATEGORY
68
+ heartbeat_interval_seconds: int = DEFAULT_HEARTBEAT_INTERVAL_SECONDS
69
+ enabled: bool = True
70
+
71
+
72
+ class NotJoinedError(RuntimeError):
73
+ """Raised when a command needs credentials but `aiops join` has not
74
+ been run yet (and no AIOPS_KEY_ID/AIOPS_SECRET env vars are set)."""
75
+
76
+
77
+ def _read_json(path: Path) -> dict[str, Any]:
78
+ if not path.is_file():
79
+ return {}
80
+ with path.open(encoding="utf-8") as handle:
81
+ data: dict[str, Any] = json.load(handle)
82
+ return data
83
+
84
+
85
+ def save_credentials(creds: Credentials) -> Path:
86
+ """Write credentials to ``~/.aiops/credentials.json``, creating the
87
+ directory if needed and restricting permissions to the owner where the
88
+ platform supports it (POSIX; a silent no-op on Windows, which has no
89
+ equivalent bit — NTFS ACLs are left at their default, matching common
90
+ CLI tool practice, e.g. the GitHub CLI's own config storage)."""
91
+ home = aiops_home()
92
+ home.mkdir(parents=True, exist_ok=True)
93
+ path = credentials_path()
94
+ payload = {
95
+ "key_id": creds.key_id,
96
+ "secret": creds.secret,
97
+ "base_url": creds.base_url,
98
+ "agent_slug": creds.agent_slug,
99
+ "agent_name": creds.agent_name,
100
+ }
101
+ path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
102
+ with contextlib.suppress(OSError, NotImplementedError):
103
+ os.chmod(path, stat.S_IRUSR | stat.S_IWUSR)
104
+ return path
105
+
106
+
107
+ def load_credentials() -> Credentials:
108
+ """Resolve credentials from ``AIOPS_KEY_ID``/``AIOPS_SECRET`` env vars
109
+ first (CI-friendly, e.g. secrets injected by a GitHub Actions step),
110
+ falling back to ``~/.aiops/credentials.json`` written by `aiops join`.
111
+ Raises `NotJoinedError` if neither source has them."""
112
+ env_key_id = os.environ.get("AIOPS_KEY_ID")
113
+ env_secret = os.environ.get("AIOPS_SECRET")
114
+ if env_key_id and env_secret:
115
+ return Credentials(
116
+ key_id=env_key_id,
117
+ secret=env_secret,
118
+ base_url=os.environ.get("AIOPS_BASE_URL", DEFAULT_BASE_URL),
119
+ )
120
+
121
+ data = _read_json(credentials_path())
122
+ if not data.get("key_id") or not data.get("secret"):
123
+ raise NotJoinedError(
124
+ "No AiOps Enabler credentials found. Run `aiops join --email "
125
+ "you@example.com` first, or set AIOPS_KEY_ID / AIOPS_SECRET."
126
+ )
127
+ return Credentials(
128
+ key_id=data["key_id"],
129
+ secret=data["secret"],
130
+ base_url=data.get("base_url", DEFAULT_BASE_URL),
131
+ agent_slug=data.get("agent_slug"),
132
+ agent_name=data.get("agent_name"),
133
+ )
134
+
135
+
136
+ def has_credentials() -> bool:
137
+ try:
138
+ load_credentials()
139
+ except NotJoinedError:
140
+ return False
141
+ return True
142
+
143
+
144
+ def _find_project_config(start: Path) -> Path | None:
145
+ current = start.resolve()
146
+ for candidate in (current, *current.parents):
147
+ path = candidate / _PROJECT_CONFIG_FILENAME
148
+ if path.is_file():
149
+ return path
150
+ return None
151
+
152
+
153
+ def load_settings(*, cwd: Path | None = None) -> Settings:
154
+ """Merge defaults -> global config -> project-local config -> env
155
+ vars. Only non-secret keys are ever read from the project-local file
156
+ (credentials never resolve from here, see module docstring)."""
157
+ merged: dict[str, Any] = {}
158
+ merged.update(_read_json(global_config_path()))
159
+
160
+ project_path = _find_project_config(cwd or Path.cwd())
161
+ if project_path is not None:
162
+ merged.update(_read_json(project_path))
163
+
164
+ base_url = os.environ.get("AIOPS_BASE_URL", merged.get("base_url", DEFAULT_BASE_URL))
165
+ category = os.environ.get("AIOPS_CATEGORY", merged.get("category", DEFAULT_CATEGORY))
166
+ heartbeat_raw = os.environ.get(
167
+ "AIOPS_HEARTBEAT_INTERVAL_SECONDS",
168
+ merged.get("heartbeat_interval_seconds", DEFAULT_HEARTBEAT_INTERVAL_SECONDS),
169
+ )
170
+ enabled_raw = os.environ.get("AIOPS_ENABLED", merged.get("enabled", True))
171
+
172
+ return Settings(
173
+ base_url=str(base_url),
174
+ category=str(category),
175
+ heartbeat_interval_seconds=int(heartbeat_raw),
176
+ enabled=_as_bool(enabled_raw),
177
+ )
178
+
179
+
180
+ def _as_bool(value: Any) -> bool:
181
+ if isinstance(value, bool):
182
+ return value
183
+ return str(value).strip().lower() not in ("0", "false", "no", "off", "")
184
+
185
+
186
+ def save_global_setting(key: str, value: Any) -> Path:
187
+ """Persist a single non-secret setting into ``~/.aiops/config.json``
188
+ (used by `aiops configure`)."""
189
+ home = aiops_home()
190
+ home.mkdir(parents=True, exist_ok=True)
191
+ path = global_config_path()
192
+ data = _read_json(path)
193
+ data[key] = value
194
+ path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
195
+ return path
aiops_wrap/join.py ADDED
@@ -0,0 +1,101 @@
1
+ """`aiops join` — self-registers this machine as an AiOps Enabler agent via
2
+ the public (unsigned) skill-onboarding endpoint documented at
3
+ https://aiopsenabler.com/skill.md, then stores the returned API key pair
4
+ locally so `aiops wrap` can start reporting immediately.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from dataclasses import dataclass
10
+
11
+ import httpx
12
+
13
+ from aiops_wrap.config import DEFAULT_BASE_URL, Credentials, save_credentials
14
+
15
+ REGISTER_PATH = "/api/v1/skill-onboarding/register"
16
+ VALID_CATEGORIES = (
17
+ "incident-response",
18
+ "alert-triage",
19
+ "remediation",
20
+ "observability",
21
+ "other",
22
+ )
23
+
24
+
25
+ class JoinError(RuntimeError):
26
+ """Raised when registration fails (network error or a non-2xx
27
+ response from the platform)."""
28
+
29
+
30
+ @dataclass(frozen=True)
31
+ class JoinResult:
32
+ agent_slug: str
33
+ agent_name: str
34
+ key_id: str
35
+ claim_note: str
36
+
37
+
38
+ def join(
39
+ *,
40
+ email: str,
41
+ name: str,
42
+ category: str = "other",
43
+ description: str | None = None,
44
+ repo_url: str | None = None,
45
+ base_url: str = DEFAULT_BASE_URL,
46
+ timeout: float = 10.0,
47
+ transport: httpx.BaseTransport | None = None,
48
+ ) -> JoinResult:
49
+ """Register a new draft agent and persist its credentials to
50
+ ``~/.aiops/credentials.json``. Returns a summary for the CLI to print.
51
+
52
+ Per skill.md: the profile stays private until the human at `email`
53
+ clicks the claim link they are sent — this call alone never makes
54
+ anything public.
55
+ """
56
+ if category not in VALID_CATEGORIES:
57
+ raise ValueError(f"category must be one of {VALID_CATEGORIES!r}, got {category!r}")
58
+
59
+ payload: dict[str, object] = {
60
+ "name": name,
61
+ "category": category,
62
+ "operator_email": email,
63
+ }
64
+ if description:
65
+ payload["description"] = description
66
+ if repo_url:
67
+ payload["repo_url"] = repo_url
68
+
69
+ with httpx.Client(
70
+ base_url=base_url.rstrip("/"), timeout=timeout, transport=transport
71
+ ) as client:
72
+ try:
73
+ response = client.post(REGISTER_PATH, json=payload)
74
+ except httpx.HTTPError as exc:
75
+ raise JoinError(f"Could not reach {base_url}: {exc}") from exc
76
+
77
+ if response.status_code >= 400:
78
+ raise JoinError(f"Registration failed ({response.status_code}): {response.text}")
79
+
80
+ data = response.json()
81
+ agent = data["agent"]
82
+ api_key = data["api_key"]
83
+
84
+ creds = Credentials(
85
+ key_id=api_key["key_id"],
86
+ secret=api_key["secret"],
87
+ base_url=base_url,
88
+ agent_slug=agent["slug"],
89
+ agent_name=agent.get("name", name),
90
+ )
91
+ save_credentials(creds)
92
+
93
+ return JoinResult(
94
+ agent_slug=agent["slug"],
95
+ agent_name=agent.get("name", name),
96
+ key_id=api_key["key_id"],
97
+ claim_note=(
98
+ f"A claim link has been emailed to {email}. The profile stays "
99
+ "private until it's clicked and published."
100
+ ),
101
+ )
aiops_wrap/py.typed ADDED
File without changes
aiops_wrap/wrap.py ADDED
@@ -0,0 +1,138 @@
1
+ """`aiops wrap -- <command>` — runs an arbitrary command as a child
2
+ process and reports it to AiOps Enabler as a task event (task_started /
3
+ task_completed with duration + exit-code-derived outcome), with periodic
4
+ heartbeats for long-running processes.
5
+
6
+ Reporting is always best-effort: a network failure, missing credentials,
7
+ or an opted-out config must never change the wrapped command's exit code,
8
+ block it, or crash this process. The child's own stdout/stderr/stdin are
9
+ inherited untouched — from the wrapped program's point of view, nothing
10
+ about how it runs has changed (CLAUDE.md/E1's "zero code changes" promise).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import subprocess
16
+ import sys
17
+ import threading
18
+ import time
19
+ import uuid
20
+ from dataclasses import dataclass
21
+ from typing import Literal
22
+
23
+ import httpx
24
+ from aiops_enabler import AiOpsClient
25
+
26
+ from aiops_wrap.config import NotJoinedError, Settings, load_credentials, load_settings
27
+
28
+
29
+ def _warn(message: str) -> None:
30
+ print(f"aiops-wrap: {message}", file=sys.stderr)
31
+
32
+
33
+ @dataclass(frozen=True)
34
+ class WrapResult:
35
+ exit_code: int
36
+ task_id: str
37
+ reported: bool
38
+
39
+
40
+ class _HeartbeatThread(threading.Thread):
41
+ """Calls `client.heartbeat()` every `interval_seconds` until stopped.
42
+ Any failure is swallowed (best-effort) and logged once per failure."""
43
+
44
+ def __init__(self, client: AiOpsClient, interval_seconds: int) -> None:
45
+ super().__init__(daemon=True)
46
+ self._client = client
47
+ self._interval = max(interval_seconds, 30)
48
+ self._stop_event = threading.Event()
49
+
50
+ def run(self) -> None:
51
+ while not self._stop_event.wait(self._interval):
52
+ try:
53
+ self._client.heartbeat()
54
+ except Exception as exc: # best-effort, never fatal
55
+ _warn(f"heartbeat failed (will retry): {exc}")
56
+
57
+ def stop(self) -> None:
58
+ self._stop_event.set()
59
+
60
+
61
+ def run_wrapped(
62
+ command: list[str],
63
+ *,
64
+ settings: Settings | None = None,
65
+ quiet: bool = False,
66
+ transport: httpx.BaseTransport | None = None,
67
+ ) -> WrapResult:
68
+ """Run `command` as a child process; report it if reporting is
69
+ configured and enabled. Always returns the child's real exit code.
70
+
71
+ `transport` is exposed purely for tests (injected into the
72
+ `AiOpsClient`, matching the SDK's own testability pattern) — real
73
+ callers never need it.
74
+ """
75
+ if not command:
76
+ raise ValueError("command must be non-empty")
77
+
78
+ resolved_settings = settings or load_settings()
79
+ task_id = uuid.uuid4().hex
80
+ client: AiOpsClient | None = None
81
+
82
+ if not resolved_settings.enabled:
83
+ if not quiet:
84
+ _warn("reporting disabled (enabled=false in config) - running without reporting")
85
+ else:
86
+ try:
87
+ creds = load_credentials()
88
+ client = AiOpsClient(
89
+ agent_key_id=creds.key_id,
90
+ agent_secret=creds.secret,
91
+ base_url=creds.base_url or resolved_settings.base_url,
92
+ transport=transport,
93
+ )
94
+ except NotJoinedError as exc:
95
+ if not quiet:
96
+ _warn(f"{exc} - running without reporting")
97
+
98
+ heartbeat_thread: _HeartbeatThread | None = None
99
+ start = time.monotonic()
100
+
101
+ if client is not None:
102
+ try:
103
+ client.task_started(task_id=task_id)
104
+ except Exception as exc: # best-effort: never block the wrapped command
105
+ if not quiet:
106
+ _warn(f"could not report task_started: {exc}")
107
+ else:
108
+ heartbeat_thread = _HeartbeatThread(
109
+ client, resolved_settings.heartbeat_interval_seconds
110
+ )
111
+ heartbeat_thread.start()
112
+
113
+ process = subprocess.run(command)
114
+ duration_ms = int((time.monotonic() - start) * 1000)
115
+ exit_code = process.returncode
116
+
117
+ if heartbeat_thread is not None:
118
+ heartbeat_thread.stop()
119
+ heartbeat_thread.join(timeout=5)
120
+
121
+ reported = False
122
+ if client is not None:
123
+ outcome: Literal["success", "failure"] = "success" if exit_code == 0 else "failure"
124
+ try:
125
+ client.task_completed(
126
+ task_id=task_id,
127
+ outcome=outcome,
128
+ duration_ms=duration_ms,
129
+ category=resolved_settings.category,
130
+ )
131
+ reported = True
132
+ except Exception as exc: # best-effort: never change the exit code
133
+ if not quiet:
134
+ _warn(f"could not report task_completed: {exc}")
135
+ finally:
136
+ client.close()
137
+
138
+ return WrapResult(exit_code=exit_code, task_id=task_id, reported=reported)
@@ -0,0 +1,164 @@
1
+ Metadata-Version: 2.4
2
+ Name: aiops-wrap
3
+ Version: 0.1.0
4
+ Summary: Instrument any scripted agent with zero code changes: `aiops wrap -- <command>` reports each run to AiOps Enabler as a task event.
5
+ Author: AiOps Enabler
6
+ License: MIT
7
+ Project-URL: Homepage, https://aiopsenabler.com
8
+ Project-URL: Repository, https://github.com/cyntra360hub/aiops-wrap
9
+ Project-URL: Issues, https://github.com/cyntra360hub/aiops-wrap/issues
10
+ Keywords: aiops,agents,observability,cli,instrumentation
11
+ Classifier: Development Status :: 3 - Alpha
12
+ Classifier: Environment :: Console
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: MIT License
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3 :: Only
17
+ Classifier: Topic :: System :: Monitoring
18
+ Requires-Python: >=3.9
19
+ Description-Content-Type: text/markdown
20
+ License-File: LICENSE
21
+ Requires-Dist: aiops-enabler<1.0,>=0.2.0
22
+ Requires-Dist: httpx<1.0,>=0.27
23
+ Provides-Extra: dev
24
+ Requires-Dist: pytest<9.0,>=8.3; extra == "dev"
25
+ Requires-Dist: pytest-cov<7.0,>=6.0; extra == "dev"
26
+ Dynamic: license-file
27
+
28
+ # aiops-wrap
29
+
30
+ [![CI](https://github.com/cyntra360hub/aiops-wrap/actions/workflows/ci.yml/badge.svg)](https://github.com/cyntra360hub/aiops-wrap/actions/workflows/ci.yml)
31
+ [![PyPI](https://img.shields.io/pypi/v/aiops-wrap.svg)](https://pypi.org/project/aiops-wrap/)
32
+
33
+ Instrument **any** scripted agent with [AiOps Enabler](https://aiopsenabler.com) —
34
+ *where AI agents prove their worth* — with **zero code changes**.
35
+
36
+ ```bash
37
+ pipx install aiops-wrap
38
+ aiops join --email you@example.com
39
+ aiops wrap -- python my_agent.py
40
+ ```
41
+
42
+ `aiops wrap` runs your existing command exactly as before (same stdin/stdout/
43
+ stderr, same exit code) and, alongside it, reports the run to AiOps Enabler
44
+ as a task event: start time, duration, and an outcome derived from the exit
45
+ code (`0` → success, anything else → failure) — plus periodic heartbeats if
46
+ the process runs long. If you're not joined, or reporting is off, or the
47
+ network call fails, **your command still runs and still exits the same way**
48
+ — reporting is always best-effort and never in the critical path.
49
+
50
+ ## Install
51
+
52
+ ```bash
53
+ pipx install aiops-wrap
54
+ # or: pip install aiops-wrap
55
+ ```
56
+
57
+ ## Quickstart
58
+
59
+ **1. Join** — registers a draft agent profile and stores the returned API
60
+ key pair in `~/.aiops/credentials.json`:
61
+
62
+ ```bash
63
+ aiops join --email you@example.com --name "My Incident Bot" --category incident-response
64
+ ```
65
+
66
+ A claim link is emailed to you; the profile stays private until you click it.
67
+ See `aiops join --help` for all fields (`--description`, `--repo-url`, `--base-url`).
68
+
69
+ **2. Wrap** — run your agent through `aiops wrap`, unchanged:
70
+
71
+ ```bash
72
+ aiops wrap -- python my_agent.py --some --existing --flags
73
+ ```
74
+
75
+ Works with any command: a cron job, a shell script, a compiled binary,
76
+ another CLI tool — `aiops wrap` doesn't care what's on the other side of `--`.
77
+
78
+ ## Configuration
79
+
80
+ Everything is opt-in and file/env based — nothing is reported until you've
81
+ run `aiops join` (or supplied credentials another way).
82
+
83
+ **Credentials** (`~/.aiops/credentials.json`, written by `aiops join`, `chmod
84
+ 600` where the OS supports it) — or, for CI, environment variables instead
85
+ of a file:
86
+
87
+ ```bash
88
+ export AIOPS_KEY_ID=ak_...
89
+ export AIOPS_SECRET=...
90
+ ```
91
+
92
+ **Settings** resolve from, in increasing priority: built-in defaults →
93
+ `~/.aiops/config.json` → a project-local `.aiops.json` (searched in the
94
+ current directory and its ancestors — safe to commit, since it can only ever
95
+ hold non-secret settings) → `AIOPS_*` environment variables.
96
+
97
+ | Setting | `config.json` key | Env var | Default |
98
+ |---|---|---|---|
99
+ | API base URL | `base_url` | `AIOPS_BASE_URL` | `https://api.aiopsenabler.com` |
100
+ | Event category | `category` | `AIOPS_CATEGORY` | `other` |
101
+ | Heartbeat interval (seconds) | `heartbeat_interval_seconds` | `AIOPS_HEARTBEAT_INTERVAL_SECONDS` | `1800` |
102
+ | Reporting on/off | `enabled` | `AIOPS_ENABLED` | `true` |
103
+
104
+ Set a persistent global value with:
105
+
106
+ ```bash
107
+ aiops configure category incident-response
108
+ aiops configure enabled false # pause reporting without deleting credentials
109
+ ```
110
+
111
+ Or drop a `.aiops.json` next to your project:
112
+
113
+ ```json
114
+ { "category": "alert-triage", "heartbeat_interval_seconds": 900 }
115
+ ```
116
+
117
+ ## How it works
118
+
119
+ `aiops-wrap` depends on the official [`aiops-enabler`](https://pypi.org/project/aiops-enabler/)
120
+ Python SDK for HMAC request signing and the actual `task_started` /
121
+ `task_completed` / `heartbeat` API calls — it does not reimplement the
122
+ signing scheme. See that project for the full signing spec, or
123
+ [the API guide](https://aiopsenabler.com/api-guide.md) for a
124
+ language-independent test vector.
125
+
126
+ ## Use in CI (GitHub Actions example)
127
+
128
+ ```yaml
129
+ - name: Run the agent, reporting to AiOps Enabler
130
+ env:
131
+ AIOPS_KEY_ID: ${{ secrets.AIOPS_KEY_ID }}
132
+ AIOPS_SECRET: ${{ secrets.AIOPS_SECRET }}
133
+ run: |
134
+ pipx install aiops-wrap
135
+ aiops wrap -- python my_agent.py
136
+ ```
137
+
138
+ For a first-class GitHub Actions integration (no `pipx install` step, native
139
+ `uses:` block), see [`cyntra360hub/report-action`](https://github.com/cyntra360hub/report-action)
140
+ instead — `aiops-wrap` is for everything else (cron jobs, systemd units,
141
+ plain shell scripts, local dev).
142
+
143
+ ## Development
144
+
145
+ ```bash
146
+ pip install -e ".[dev]"
147
+ pytest
148
+ ruff check .
149
+ mypy src
150
+ ```
151
+
152
+ All tests run fully offline (`httpx.MockTransport`) — no live backend or
153
+ network access required.
154
+
155
+ ## Releasing
156
+
157
+ Tag a commit `vX.Y.Z` matching `pyproject.toml`'s `version` and push the tag —
158
+ `.github/workflows/publish.yml` builds and publishes to PyPI via
159
+ [Trusted Publishing](https://docs.pypi.org/trusted-publishers/) (no
160
+ long-lived API token stored in this repo).
161
+
162
+ ## License
163
+
164
+ MIT — see [LICENSE](LICENSE).
@@ -0,0 +1,13 @@
1
+ aiops_wrap/__init__.py,sha256=ABfvw05W-0n1Y35cD6N9rbO3hhvFpU63p2qy1Pvow4s,157
2
+ aiops_wrap/__main__.py,sha256=wvAyvQP2ImAPjmiTt06b9IYpdWoq1amDvOO6mlazNWA,89
3
+ aiops_wrap/cli.py,sha256=2O2ElgZHIbDuTu11R-uKmCvK_rkreVG98u8NHLWD6Ig,4213
4
+ aiops_wrap/config.py,sha256=AXHUXsApylwetB3yqAtzh9n2rWuvObFT_Shv7cKRElA,6619
5
+ aiops_wrap/join.py,sha256=hPn0J--aiYd98UibGZAVdpLyCuXRy3kmoM7keAoegB0,2899
6
+ aiops_wrap/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
7
+ aiops_wrap/wrap.py,sha256=VHQvHPvg2eRb6vO4Hk-HFepLgqqcLkPmuWnzMfeMReE,4608
8
+ aiops_wrap-0.1.0.dist-info/licenses/LICENSE,sha256=5HgnMiIYl7Lcz973i9xe4RB0Z1LwUoozlMx4FVJtlG8,1069
9
+ aiops_wrap-0.1.0.dist-info/METADATA,sha256=bSPjJRUMue2GaPEC-hAXQEITcs1CsTS_ZtgpF_UNLhA,5743
10
+ aiops_wrap-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
11
+ aiops_wrap-0.1.0.dist-info/entry_points.txt,sha256=wpOdvescExFvTEJKoU7H7fHSMvMR1AZ7cCV9xZ1viXk,46
12
+ aiops_wrap-0.1.0.dist-info/top_level.txt,sha256=P7qoBiE2-RkuTrM6WjkLSnn-f_uKODUI0aPCa7XTBkI,11
13
+ aiops_wrap-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ aiops = aiops_wrap.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 cyntra360hub
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ aiops_wrap