conduct-cli 0.4.14__tar.gz → 0.4.16__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "conduct-cli"
7
- version = "0.4.14"
7
+ version = "0.4.16"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -55,11 +55,14 @@ def _fetch_budget_status():
55
55
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
56
56
  except Exception:
57
57
  return False, None
58
- workspace_id = cfg.get("workspace_id")
59
- api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
58
+ workspace_id = cfg.get("workspace_id")
59
+ clerk_user_id = cfg.get("clerk_user_id") or ""
60
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
60
61
  if not workspace_id:
61
62
  return False, None
62
63
  url = f"{api_url}/guard/spend/budget-check?workspace_id={workspace_id}"
64
+ if clerk_user_id:
65
+ url += f"&clerk_user_id={clerk_user_id}"
63
66
  try:
64
67
  with urllib.request.urlopen(urllib.request.Request(url), timeout=5) as resp:
65
68
  data = json.loads(resp.read())
@@ -249,54 +252,111 @@ def _read_tokens_from_transcript(transcript_path, tool_use_id):
249
252
  return 0, 0
250
253
 
251
254
 
252
- def _read_codex_tokens_from_transcript(transcript_path):
253
- """Read last_token_usage from a Codex session JSONL (event_msg/token_count entries)."""
255
+ def _scan_codex_tokens(transcript_path):
256
+ """Robustly scan a Codex transcript for the last token_count event.
257
+
258
+ Reads in 512 KB chunks from the end so it handles arbitrarily large
259
+ tool-output lines without a fixed cutoff.
260
+ """
254
261
  try:
255
262
  path = Path(transcript_path)
256
263
  if not path.exists():
257
264
  return 0, 0
258
- lines = _tail_lines(path)
259
- for line in reversed(lines):
260
- if "token_count" not in line:
261
- continue
262
- try:
263
- entry = json.loads(line)
264
- if entry.get("type") == "event_msg":
265
- info = entry.get("payload", {}).get("info", {})
266
- usage = info.get("last_token_usage", {})
267
- if usage:
268
- total_in = usage.get("input_tokens", 0)
269
- total_out = usage.get("output_tokens", 0) + usage.get("reasoning_output_tokens", 0)
270
- return total_in, total_out
271
- except Exception:
272
- continue
265
+ size = path.stat().st_size
266
+ chunk_size = 524288 # 512 KB
267
+ buf = b""
268
+ pos = size
269
+ while pos >= 0:
270
+ read_size = min(chunk_size, pos)
271
+ pos -= read_size
272
+ with open(path, "rb") as f:
273
+ f.seek(pos)
274
+ buf = f.read(read_size) + buf
275
+ text = buf.decode("utf-8", errors="ignore")
276
+ # Split; if we haven't reached the start the first fragment may be partial
277
+ parts = text.split("\n")
278
+ start = 1 if pos > 0 else 0
279
+ for line in reversed(parts[start:]):
280
+ if "token_count" not in line or not line.strip():
281
+ continue
282
+ try:
283
+ entry = json.loads(line)
284
+ if entry.get("type") == "event_msg":
285
+ info = entry.get("payload", {}).get("info", {})
286
+ usage = info.get("last_token_usage", {})
287
+ if usage:
288
+ total_in = usage.get("input_tokens", 0)
289
+ total_out = (usage.get("output_tokens", 0)
290
+ + usage.get("reasoning_output_tokens", 0))
291
+ return total_in, total_out
292
+ except Exception:
293
+ continue
294
+ if pos == 0:
295
+ break
273
296
  except Exception:
274
297
  pass
275
298
  return 0, 0
276
299
 
277
300
 
278
- def post_usage_main():
279
- """PostToolUse hook entrypointcaptures token usage and duration."""
301
+ def post_codex_main():
302
+ """Delayed Codex token reader spawned as background by post_usage_main.
303
+
304
+ Reads args from a pending JSON file, sleeps 2 s to let Codex flush the
305
+ token_count event, then scans the transcript and POSTs to the API.
306
+ """
307
+ import time
308
+ if len(sys.argv) < 3:
309
+ sys.exit(0)
310
+ pending_path = Path(sys.argv[2])
280
311
  try:
281
- data = json.load(sys.stdin)
312
+ args = json.loads(pending_path.read_text())
313
+ pending_path.unlink(missing_ok=True)
282
314
  except Exception:
283
315
  sys.exit(0)
316
+ time.sleep(2)
317
+ transcript_path = args.get("transcript_path", "")
318
+ tokens_in, tokens_out = _scan_codex_tokens(transcript_path)
319
+ if tokens_in or tokens_out:
320
+ _post_usage(args.get("session_id"), args.get("tool_name"),
321
+ tokens_in, tokens_out, None)
322
+ sys.exit(0)
323
+
324
+
325
+ def post_usage_main():
326
+ """PostToolUse hook entrypoint — exits immediately; heavy work is async."""
284
327
  try:
285
- (GUARD_DIR / "debug_post.json").write_text(json.dumps(data, indent=2))
328
+ data = json.load(sys.stdin)
286
329
  except Exception:
287
- pass
330
+ sys.exit(0)
288
331
  session_id = data.get("session_id")
289
332
  tool_name = (data.get("tool_name") or "").lower()
290
333
  tool_use_id = data.get("tool_use_id")
291
334
  transcript_path = data.get("transcript_path")
292
335
  is_codex = (tool_use_id or "").startswith("call_")
293
- if transcript_path and is_codex:
294
- tokens_input, tokens_output = _read_codex_tokens_from_transcript(transcript_path)
336
+
337
+ if is_codex and transcript_path:
338
+ # Write pending args; spawn delayed background reader so hook exits instantly
339
+ import uuid as _uuid
340
+ pending = GUARD_DIR / f"codex_pending_{_uuid.uuid4().hex[:8]}.json"
341
+ try:
342
+ pending.write_text(json.dumps({
343
+ "session_id": session_id,
344
+ "tool_name": tool_name,
345
+ "transcript_path": transcript_path,
346
+ }))
347
+ hook_path = Path(__file__).resolve()
348
+ subprocess.Popen(
349
+ [sys.executable, str(hook_path), "post-codex", str(pending)],
350
+ stdout=subprocess.DEVNULL,
351
+ stderr=subprocess.DEVNULL,
352
+ start_new_session=True,
353
+ )
354
+ except Exception:
355
+ pass
295
356
  elif transcript_path:
296
357
  tokens_input, tokens_output = _read_tokens_from_transcript(transcript_path, tool_use_id)
297
- else:
298
- tokens_input, tokens_output = 0, 0
299
- _post_usage(session_id, tool_name, tokens_input, tokens_output, None)
358
+ _post_usage(session_id, tool_name, tokens_input, tokens_output, None)
359
+
300
360
  sys.exit(0)
301
361
 
302
362
 
@@ -336,6 +396,8 @@ def main():
336
396
  if __name__ == "__main__":
337
397
  if len(sys.argv) > 1 and sys.argv[1] == "post":
338
398
  post_usage_main()
399
+ elif len(sys.argv) > 1 and sys.argv[1] == "post-codex":
400
+ post_codex_main()
339
401
  else:
340
402
  main()
341
403
  '''
@@ -607,16 +669,18 @@ def cmd_guard_install(args):
607
669
  print(f" {GRAY}Guard not installed for this workspace — skipping{RESET}")
608
670
  return
609
671
 
610
- member_token = result.get("member_token") or ""
611
- user_email = result.get("user_email") or ""
672
+ member_token = result.get("member_token") or ""
673
+ user_email = result.get("user_email") or ""
674
+ clerk_user_id = result.get("clerk_user_id") or ""
612
675
 
613
676
  # Persist guard config — include api_key so CLI commands can authenticate
614
677
  _save_guard_config({
615
- "workspace_id": workspace_id,
616
- "member_token": member_token,
617
- "user_email": user_email,
618
- "api_key": api_key,
619
- "api_url": server,
678
+ "workspace_id": workspace_id,
679
+ "member_token": member_token,
680
+ "user_email": user_email,
681
+ "clerk_user_id": clerk_user_id,
682
+ "api_key": api_key,
683
+ "api_url": server,
620
684
  })
621
685
 
622
686
  # Download policies
@@ -735,19 +799,22 @@ def cmd_guard_status(args):
735
799
  api_key = cfg.get("api_key", "")
736
800
  base_url = _api_url(cfg)
737
801
 
738
- # Auto-refresh user_email into config if it was installed before this was wired up
739
- if not user_email and api_key:
802
+ # Auto-refresh user_email + clerk_user_id into config if missing
803
+ if (not user_email or not cfg.get("clerk_user_id")) and api_key:
740
804
  try:
741
805
  installed = _req("GET", f"{base_url}/guard/config/installed", api_key=api_key)
742
806
  fetched_email = installed.get("user_email") or ""
807
+ fetched_clerk = installed.get("clerk_user_id") or ""
743
808
  if fetched_email:
744
809
  cfg["user_email"] = fetched_email
745
- _save_guard_config(cfg)
746
- # Rewrite hook script so future events carry the email
747
- hook_path = GUARD_DIR / "hook.py"
748
- hook_path.write_text(_HOOK_SCRIPT)
749
- hook_path.chmod(0o755)
750
810
  user_email = fetched_email
811
+ if fetched_clerk:
812
+ cfg["clerk_user_id"] = fetched_clerk
813
+ _save_guard_config(cfg)
814
+ # Rewrite hook script so future events carry the email
815
+ hook_path = GUARD_DIR / "hook.py"
816
+ hook_path.write_text(_HOOK_SCRIPT)
817
+ hook_path.chmod(0o755)
751
818
  except Exception:
752
819
  pass
753
820
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.14
3
+ Version: 0.4.16
4
4
  Summary: CLI for Conduct AI — install agents, manage projects, run tests
5
5
  Author-email: Conduct AI <hello@conductai.ai>
6
6
  License: MIT
File without changes
File without changes
File without changes