@commonlyai/cli 0.1.192 → 0.1.194

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.
@@ -0,0 +1,54 @@
1
+ """
2
+ Hello-world Commonly agent — emitted by `commonly agent init --language python`.
3
+
4
+ Reads its runtime token from .commonly-env (or COMMONLY_TOKEN) and echoes
5
+ chat events back into the pod. Replace `handle_event()` with your logic.
6
+ """
7
+
8
+ import os
9
+ import sys
10
+ from pathlib import Path
11
+
12
+ # `commonly.py` is dropped in the same dir by the scaffolder.
13
+ sys.path.insert(0, str(Path(__file__).parent))
14
+ from commonly import Commonly # noqa: E402
15
+
16
+ CHAT_EVENT_TYPES = {"chat.mention", "message.posted", "dm.message"}
17
+
18
+
19
+ def load_token() -> str:
20
+ if os.environ.get("COMMONLY_TOKEN"):
21
+ return os.environ["COMMONLY_TOKEN"]
22
+ env_file = Path(__file__).parent / ".commonly-env"
23
+ if env_file.exists():
24
+ # KEY=VALUE format so `source .commonly-env` works too. We only need
25
+ # the token line; ignore comments and other keys.
26
+ for line in env_file.read_text(encoding="utf-8").splitlines():
27
+ line = line.strip()
28
+ if not line or line.startswith("#"):
29
+ continue
30
+ if line.startswith("COMMONLY_TOKEN="):
31
+ return line.split("=", 1)[1].strip().strip("'\"")
32
+ raise SystemExit("No runtime token found. Set COMMONLY_TOKEN or write .commonly-env.")
33
+
34
+
35
+ def handle_event(evt: dict) -> str | None:
36
+ """Echo the user's message back. Replace this with your real logic."""
37
+ if evt.get("type") not in CHAT_EVENT_TYPES:
38
+ return None
39
+ payload = evt.get("payload") or {}
40
+ incoming = payload.get("content") or payload.get("prompt") or payload.get("text")
41
+ if not incoming:
42
+ return None
43
+ return f"echo: {incoming}"
44
+
45
+
46
+ def main() -> None:
47
+ base_url = os.environ.get("COMMONLY_BASE_URL", "https://api.commonly.me")
48
+ bot = Commonly(base_url=base_url, runtime_token=load_token())
49
+ print(f"[hello-world] polling {base_url} (Ctrl+C to stop)", flush=True)
50
+ bot.run(handle_event)
51
+
52
+
53
+ if __name__ == "__main__":
54
+ main()
@@ -0,0 +1,231 @@
1
+ """
2
+ Commonly Python SDK — single-file reference implementation of the four
3
+ Commonly Agent Protocol (CAP) verbs. ADR-006 §SDK shape.
4
+
5
+ Stdlib only. No deps. Drop-copy this file into your project and import.
6
+
7
+ Usage (high-level):
8
+
9
+ from commonly import Commonly
10
+
11
+ bot = Commonly(base_url="https://api.commonly.me", runtime_token="cm_agent_…")
12
+ bot.run(lambda evt: f"echo: {evt['payload'].get('content', '')}")
13
+
14
+ Usage (manual loop):
15
+
16
+ bot = Commonly(...)
17
+ while True:
18
+ for evt in bot.poll_events():
19
+ try:
20
+ reply = handle(evt)
21
+ if reply and evt.get("podId"):
22
+ bot.get_pod_context(evt["podId"]) # read first, or the send guard refuses
23
+ bot.post_message(evt["podId"], reply)
24
+ finally:
25
+ bot.ack(evt["_id"], delivery_id=evt.get("payload", {}).get("deliveryId"))
26
+ time.sleep(5)
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import time
33
+ import urllib.error
34
+ import urllib.request
35
+ from typing import Any, Callable, Optional
36
+
37
+
38
+ # --------------------------------------------------------------------------- #
39
+ # HTTP error type — subclasses propagate `status` so callers can branch on
40
+ # 401/403/404 without parsing the message string.
41
+ # --------------------------------------------------------------------------- #
42
+
43
+
44
+ class CommonlyError(Exception):
45
+ def __init__(self, message: str, status: Optional[int] = None, body: Any = None):
46
+ super().__init__(message)
47
+ self.status = status
48
+ self.body = body
49
+
50
+
51
+ def _describe_http_error(status: int, method: str, path: str, payload: Any) -> str:
52
+ """Name the server's reason on every 4xx. A 409 from the freshness guard
53
+ carries `message` ("N new messages … Read the conversation …"); dropping it
54
+ left the stock bot logging a bare status with no cause."""
55
+ head = f"HTTP {status} {method} {path}"
56
+ if isinstance(payload, dict):
57
+ reason = payload.get("message") or payload.get("error") or payload.get("code")
58
+ if reason:
59
+ return f"{head}: {reason}"
60
+ elif isinstance(payload, str) and payload.strip():
61
+ return f"{head}: {payload.strip()[:200]}"
62
+ return head
63
+
64
+
65
+ # --------------------------------------------------------------------------- #
66
+ # Client
67
+ # --------------------------------------------------------------------------- #
68
+
69
+
70
+ class Commonly:
71
+ """Thin client over the four CAP verbs. Sync; one HTTP request per call."""
72
+
73
+ def __init__(self, *, base_url: str, runtime_token: str, timeout_s: float = 30.0):
74
+ self.base_url = base_url.rstrip("/")
75
+ self.runtime_token = runtime_token
76
+ self.timeout_s = timeout_s
77
+
78
+ # ----- internal -------------------------------------------------------- #
79
+
80
+ @staticmethod
81
+ def _path(base_path: str, **params: Any) -> str:
82
+ from urllib.parse import urlencode
83
+ kept = {k: v for k, v in params.items() if v is not None}
84
+ if not kept:
85
+ return base_path
86
+ return f"{base_path}?{urlencode(kept)}"
87
+
88
+ def _request(self, method: str, path: str, body: Optional[dict] = None) -> Any:
89
+ url = f"{self.base_url}{path}"
90
+ data = json.dumps(body).encode("utf-8") if body is not None else None
91
+ req = urllib.request.Request(url, data=data, method=method)
92
+ req.add_header("Authorization", f"Bearer {self.runtime_token}")
93
+ req.add_header("User-Agent", "commonly-sdk/0.1")
94
+ if data is not None:
95
+ req.add_header("Content-Type", "application/json")
96
+ try:
97
+ with urllib.request.urlopen(req, timeout=self.timeout_s) as resp:
98
+ raw = resp.read().decode("utf-8") or "{}"
99
+ return json.loads(raw)
100
+ except urllib.error.HTTPError as e:
101
+ raw = e.read().decode("utf-8", errors="replace") if e.fp else ""
102
+ try:
103
+ payload = json.loads(raw) if raw else None
104
+ except Exception:
105
+ payload = raw
106
+ raise CommonlyError(_describe_http_error(e.code, method, path, payload),
107
+ status=e.code, body=payload) from e
108
+
109
+ # ----- CAP verbs ------------------------------------------------------- #
110
+
111
+ def poll_events(self, *, limit: int = 10) -> list:
112
+ """CAP verb 1 — pull queued events. Immediate return; no long-poll."""
113
+ body = self._request("GET", self._path("/api/agents/runtime/events", limit=int(limit)))
114
+ return body.get("events", []) if isinstance(body, dict) else []
115
+
116
+ def ack(self, event_id: str, *, delivery_id: Optional[str] = None,
117
+ outcome: str = "acknowledged", content: Optional[str] = None) -> None:
118
+ """CAP verb 2 — acknowledge an event. Forward the delivery_id from
119
+ event.payload.deliveryId when present; skip on adapter errors so the
120
+ kernel re-delivers (ADR-005 §Spawning semantics)."""
121
+ result = {"outcome": outcome}
122
+ if content is not None:
123
+ result["content"] = content
124
+ body: dict = {"result": result}
125
+ if delivery_id:
126
+ body["deliveryId"] = delivery_id
127
+ self._request("POST", f"/api/agents/runtime/events/{event_id}/ack", body)
128
+
129
+ def post_message(self, pod_id: str, content: str, *,
130
+ reply_to_message_id: Optional[str] = None,
131
+ metadata: Optional[dict] = None) -> dict:
132
+ """CAP verb 3 — post a chat message into a pod."""
133
+ body: dict = {"content": content}
134
+ if reply_to_message_id is not None:
135
+ body["reply_to_message_id"] = reply_to_message_id
136
+ if metadata is not None:
137
+ body["metadata"] = metadata
138
+ return self._request("POST", f"/api/agents/runtime/pods/{pod_id}/messages", body)
139
+
140
+ def get_pod_context(self, pod_id: str) -> dict:
141
+ """Read the pod before replying. The server records this read; the
142
+ send guard refuses a post to a pod the agent has not read since its
143
+ newest message (409 unseen_messages). Reading first is the contract."""
144
+ return self._request("GET", f"/api/agents/runtime/pods/{pod_id}/context")
145
+
146
+ def _post_reply(self, pod_id: str, content: str) -> dict:
147
+ """Read the pod, then post. If a message lands between the read and the
148
+ post, read once more and retry; a second 409 propagates with its reason."""
149
+ self.get_pod_context(pod_id)
150
+ try:
151
+ return self.post_message(pod_id, content)
152
+ except CommonlyError as exc:
153
+ if exc.status != 409:
154
+ raise
155
+ self.get_pod_context(pod_id)
156
+ return self.post_message(pod_id, content)
157
+
158
+ def get_memory(self) -> dict:
159
+ """CAP verb 4a — read the agent's memory envelope (ADR-003)."""
160
+ return self._request("GET", "/api/agents/runtime/memory")
161
+
162
+ def sync_memory(self, sections: dict, *, mode: str = "patch",
163
+ source_runtime: str = "webhook-sdk-py") -> dict:
164
+ """CAP verb 4b — patch-or-replace the memory envelope.
165
+
166
+ ADR-003 invariant #9: callers supply `content` + `visibility` only on
167
+ each section. byteSize / updatedAt / schemaVersion are server-stamped
168
+ and silently discarded if sent.
169
+ """
170
+ if mode not in ("patch", "full"):
171
+ raise ValueError("mode must be 'patch' or 'full'")
172
+ return self._request("POST", "/api/agents/runtime/memory/sync",
173
+ {"mode": mode, "sourceRuntime": source_runtime,
174
+ "sections": sections})
175
+
176
+ # ----- run loop -------------------------------------------------------- #
177
+
178
+ def run(self, on_event: Callable[[dict], Optional[str]], *,
179
+ interval_s: float = 5.0, max_auth_errors: int = 3) -> None:
180
+ """Convenience loop. Calls `on_event(evt)` for each polled event; if
181
+ the handler returns a non-empty string AND the event has a podId, the
182
+ string is posted back, then the event is acked.
183
+
184
+ On handler exception the ack is SKIPPED so the kernel re-delivers
185
+ (matches ADR-005 §Spawning semantics — at-least-once + driver
186
+ idempotency). Override `run()` if you want custom retry semantics.
187
+
188
+ Exits after `max_auth_errors` consecutive 401/403 poll responses so a
189
+ revoked token doesn't produce an invisible infinite backoff loop. The
190
+ caller should surface the exit to the user (e.g. prompt them to
191
+ re-issue a token)."""
192
+ consecutive_auth_errors = 0
193
+ while True:
194
+ try:
195
+ events = self.poll_events()
196
+ consecutive_auth_errors = 0
197
+ except CommonlyError as exc:
198
+ if exc.status in (401, 403):
199
+ consecutive_auth_errors += 1
200
+ if consecutive_auth_errors >= max_auth_errors:
201
+ print(
202
+ f"[commonly] runtime token rejected {consecutive_auth_errors}x in a row — exiting. "
203
+ "The token is likely revoked; re-issue or uninstall the agent.",
204
+ flush=True,
205
+ )
206
+ return
207
+ print(f"[commonly] poll failed ({exc.status}): {exc}", flush=True)
208
+ time.sleep(min(interval_s * 4, 60))
209
+ continue
210
+ for evt in events:
211
+ pod_id = evt.get("podId")
212
+ try:
213
+ reply = on_event(evt)
214
+ except Exception as exc: # adapter-side error — skip ack so kernel re-delivers
215
+ print(f"[commonly] handler error on {evt.get('_id')}: {exc}", flush=True)
216
+ continue
217
+ if reply and pod_id:
218
+ try:
219
+ self._post_reply(pod_id, reply)
220
+ except CommonlyError as exc:
221
+ print(f"[commonly] post failed ({exc.status}): {exc}", flush=True)
222
+ continue
223
+ try:
224
+ self.ack(
225
+ evt["_id"],
226
+ delivery_id=evt.get("payload", {}).get("deliveryId"),
227
+ outcome="posted" if reply else "no_action",
228
+ )
229
+ except CommonlyError as exc:
230
+ print(f"[commonly] ack failed ({exc.status}): {exc}", flush=True)
231
+ time.sleep(interval_s)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@commonlyai/cli",
3
- "version": "0.1.192",
3
+ "version": "0.1.194",
4
4
  "license": "Apache-2.0",
5
5
  "description": "The Commonly CLI — connect agents, manage pods, iterate fast",
6
6
  "type": "module",
@@ -10,6 +10,7 @@
10
10
  },
11
11
  "files": [
12
12
  "src",
13
+ "assets",
13
14
  "README.md",
14
15
  "skills"
15
16
  ],
@@ -32,7 +33,7 @@
32
33
  "lint:fix": "eslint src --ext .js --fix",
33
34
  "test": "node --experimental-vm-modules node_modules/.bin/jest",
34
35
  "test:temp-cleanup": "node scripts/verify-temp-cleanup.mjs",
35
- "prepack": "node scripts/require-bundled-seats.mjs",
36
+ "prepack": "node scripts/require-bundled-seats.mjs && node scripts/bundle-sdk-examples.mjs",
36
37
  "prepublishOnly": "node -e \"require('fs').copyFileSync('../docs/agents/skills/commonly/SKILL.md','skills/commonly/SKILL.md')\""
37
38
  },
38
39
  "dependencies": {
@@ -3340,19 +3340,20 @@ const SUPPORTED_LANGUAGES = ['python'];
3340
3340
  // three levels up. The scaffolder copies the canonical SDK file from the repo
3341
3341
  // into the user's cwd; ADR-006 §SDK lives = "live-copy, not dependency".
3342
3342
  //
3343
- // Removal condition: ADR-006 §Migration path Phase 4 — when the CLI is
3344
- // published as `@commonlyai/cli` on npm, the example files won't sit at a
3345
- // repo-relative path; we'll need to bundle them into the package at build
3346
- // time and resolve via `import.meta.resolve` or a packaged-data lookup.
3347
- // Until that phase, repo-relative is the simplest correct answer.
3348
- const repoFile = (...parts) => pathResolve(
3349
- dirname(fileURLToPath(import.meta.url)), '..', '..', '..', ...parts,
3350
- );
3343
+ // The published package carries these files under cli/assets/webhook-sdk/,
3344
+ // bundled from examples/ by scripts/bundle-sdk-examples.mjs at prepack (npm
3345
+ // pack and publish run it). A repo clone that has not packed yet falls back to
3346
+ // the examples/ tree, which stays the single source of truth.
3347
+ const cliRoot = pathResolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
3348
+ const sdkAsset = (bundledRel, repoRel) => {
3349
+ const bundled = pathResolve(cliRoot, 'assets', 'webhook-sdk', bundledRel);
3350
+ return existsSync(bundled) ? bundled : pathResolve(cliRoot, '..', 'examples', repoRel);
3351
+ };
3351
3352
 
3352
3353
  const SDK_SOURCES = {
3353
3354
  python: {
3354
- sdkSrc: () => repoFile('examples', 'sdk', 'python', 'commonly.py'),
3355
- botSrc: () => repoFile('examples', 'hello-world-python', 'bot.py'),
3355
+ sdkSrc: () => sdkAsset('python/commonly.py', join('sdk', 'python', 'commonly.py')),
3356
+ botSrc: () => sdkAsset('python/bot.py', join('hello-world-python', 'bot.py')),
3356
3357
  sdkOut: 'commonly.py',
3357
3358
  botOut: (name) => `${name}.py`,
3358
3359
  runHint: (name) => `COMMONLY_TOKEN=$(cat .commonly-env) python3 ${name}.py`,
@@ -70,13 +70,27 @@ const seatAddressId = (agentName, instanceId) => {
70
70
  return `sha256-${createHash('sha256').update(identity).digest('base64url')}`;
71
71
  };
72
72
 
73
+ // This probe runs before every turn and blocks the listener's event loop, so a
74
+ // git child that never exits (one waiting on an inherited stdin, or one whose
75
+ // open() the OS holds pending consent) must not stall the seat. On timeout
76
+ // spawnSync kills the child and sets result.error; the wrapper hook then
77
+ // resolves the original hooks itself at commit time.
78
+ const GIT_PROBE_TIMEOUT_MS = 5000;
79
+
73
80
  const resolveOriginalHooksPath = (cwd, env) => {
74
81
  const result = spawnSync(
75
82
  'git',
76
83
  ['rev-parse', '--path-format=absolute', '--git-path', 'hooks'],
77
- { cwd, env, encoding: 'utf8' },
84
+ {
85
+ cwd,
86
+ env,
87
+ encoding: 'utf8',
88
+ stdio: ['ignore', 'pipe', 'pipe'],
89
+ timeout: GIT_PROBE_TIMEOUT_MS,
90
+ killSignal: 'SIGKILL',
91
+ },
78
92
  );
79
- if (result.status !== 0 || !result.stdout.trim()) return null;
93
+ if (result.error || result.status !== 0 || !result.stdout?.trim()) return null;
80
94
  const hooksPath = resolve(result.stdout.trim());
81
95
  try {
82
96
  return statSync(hooksPath).isDirectory() ? hooksPath : null;