conduct-cli 0.4.4__tar.gz → 0.4.5__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.4
3
+ Version: 0.4.5
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.4"
7
+ version = "0.4.5"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -33,6 +33,7 @@ Repository = "https://github.com/sseshachala/conductai"
33
33
  [project.scripts]
34
34
  conduct = "conduct_cli.main:main"
35
35
  conductguard-mcp = "conduct_cli.guardmcp:main"
36
+ conductguard-post = "conduct_cli.guard:post_usage_main"
36
37
 
37
38
  [tool.setuptools.packages.find]
38
39
  where = ["src"]
@@ -125,7 +125,7 @@ def _detect_ai_tool():
125
125
  return "unknown"
126
126
 
127
127
 
128
- def _post_event(tool_name, tool_input, decision, rule_id=None, message=None):
128
+ def _post_event(tool_name, tool_input, decision, rule_id=None, message=None, tool_use_id=None):
129
129
  try:
130
130
  cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
131
131
  except Exception:
@@ -144,6 +144,7 @@ def _post_event(tool_name, tool_input, decision, rule_id=None, message=None):
144
144
  "decision": decision,
145
145
  "rule_id": rule_id,
146
146
  "rule_message": message,
147
+ "tool_use_id": tool_use_id,
147
148
  })
148
149
  api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
149
150
  script = (
@@ -162,6 +163,55 @@ def _post_event(tool_name, tool_input, decision, rule_id=None, message=None):
162
163
  )
163
164
 
164
165
 
166
+ def _post_usage(tool_use_id, tokens_input, tokens_output, duration_ms, ai_tool):
167
+ """Fire-and-forget POST to /guard/events/usage"""
168
+ try:
169
+ cfg = json.loads(CONFIG_PATH.read_text()) if CONFIG_PATH.exists() else {}
170
+ except Exception:
171
+ return
172
+ workspace_id = cfg.get("workspace_id")
173
+ if not workspace_id or not tool_use_id:
174
+ return
175
+ payload = json.dumps({
176
+ "workspace_id": workspace_id,
177
+ "tool_use_id": tool_use_id,
178
+ "tokens_input": tokens_input,
179
+ "tokens_output": tokens_output,
180
+ "duration_ms": duration_ms,
181
+ "ai_tool": _detect_ai_tool(),
182
+ })
183
+ api_url = cfg.get("api_url", "https://api.conductai.ai").rstrip("/")
184
+ script = (
185
+ "import urllib.request\\n"
186
+ "try:\\n"
187
+ f" req = urllib.request.Request(\\"{api_url}/guard/events/usage\\","
188
+ f" data={repr(payload.encode())}, headers={{\\\"Content-Type\\\": \\\"application/json\\\"}}, method=\\"POST\\")\\n"
189
+ " urllib.request.urlopen(req, timeout=5)\\n"
190
+ "except: pass\\n"
191
+ )
192
+ subprocess.Popen(
193
+ [sys.executable, "-c", script],
194
+ stdout=subprocess.DEVNULL,
195
+ stderr=subprocess.DEVNULL,
196
+ start_new_session=True,
197
+ )
198
+
199
+
200
+ def post_usage_main():
201
+ """PostToolUse hook entrypoint — captures token usage and duration."""
202
+ try:
203
+ data = json.load(sys.stdin)
204
+ except Exception:
205
+ sys.exit(0)
206
+ tool_use_id = data.get("tool_use_id")
207
+ usage = data.get("usage") or {}
208
+ tokens_input = usage.get("input_tokens", 0)
209
+ tokens_output = usage.get("output_tokens", 0)
210
+ duration_ms = data.get("duration_ms")
211
+ _post_usage(tool_use_id, tokens_input, tokens_output, duration_ms, _detect_ai_tool())
212
+ sys.exit(0)
213
+
214
+
165
215
  def main():
166
216
  try:
167
217
  data = json.load(sys.stdin)
@@ -178,12 +228,13 @@ def main():
178
228
 
179
229
  tool_name = (data.get("tool_name") or "").lower()
180
230
  tool_input = data.get("tool_input") or {}
231
+ tool_use_id = data.get("tool_use_id")
181
232
 
182
233
  _, action, rule_id, message = _check_policy(tool_name, tool_input)
183
234
 
184
235
  # Always post an event — "allowed" for normal calls, "blocked"/"warned" for violations
185
236
  decision = {"block": "blocked", "warn": "warned", "approval": "blocked"}.get(action, "allowed")
186
- _post_event(tool_name, tool_input, decision, rule_id, message)
237
+ _post_event(tool_name, tool_input, decision, rule_id, message, tool_use_id=tool_use_id)
187
238
 
188
239
  if action == "block":
189
240
  print(f"[ConductGuard] {message}")
@@ -265,7 +316,7 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
265
316
 
266
317
 
267
318
  def _install_codex_hook(hook_path: Path) -> None:
268
- """Register hook in ~/.codex/hooks.json (same format as Claude Code)."""
319
+ """Register PreToolUse and PostToolUse hooks in ~/.codex/hooks.json."""
269
320
  codex_hooks = Path.home() / ".codex" / "hooks.json"
270
321
  if not (Path.home() / ".codex").exists():
271
322
  return # Codex not installed
@@ -277,20 +328,42 @@ def _install_codex_hook(hook_path: Path) -> None:
277
328
  except json.JSONDecodeError:
278
329
  hooks = {}
279
330
 
280
- cmd = f"python3 {hook_path}"
281
- pre = hooks.setdefault("hooks", {}).setdefault("PreToolUse", [])
282
- already = any(
283
- e.get("command") == cmd
331
+ hook_section = hooks.setdefault("hooks", {})
332
+
333
+ # PreToolUse
334
+ pre_cmd = f"python3 {hook_path}"
335
+ pre = hook_section.setdefault("PreToolUse", [])
336
+ pre_already = any(
337
+ e.get("command") == pre_cmd
284
338
  for h in pre
285
339
  for e in h.get("hooks", [])
286
340
  )
287
- if not already:
288
- pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": cmd}]})
341
+ changed = False
342
+ if not pre_already:
343
+ pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": pre_cmd}]})
344
+ changed = True
345
+
346
+ # PostToolUse
347
+ post_cmd = "conductguard-post"
348
+ post = hook_section.setdefault("PostToolUse", [])
349
+ post_already = any(
350
+ e.get("command") == post_cmd
351
+ for h in post
352
+ for e in h.get("hooks", [])
353
+ )
354
+ if not post_already:
355
+ post.append({"matcher": "*", "hooks": [{"type": "command", "command": post_cmd}]})
356
+ changed = True
357
+
358
+ if changed:
289
359
  codex_hooks.parent.mkdir(parents=True, exist_ok=True)
290
360
  codex_hooks.write_text(json.dumps(hooks, indent=2))
291
- print(f" {GREEN}Codex hook registered{RESET}")
361
+ if not pre_already:
362
+ print(f" {GREEN}Codex PreToolUse hook registered{RESET}")
363
+ if not post_already:
364
+ print(f" {GREEN}Codex PostToolUse hook registered{RESET}")
292
365
  else:
293
- print(f" {GRAY}Codex hook already registered{RESET}")
366
+ print(f" {GRAY}Codex hooks already registered{RESET}")
294
367
 
295
368
 
296
369
  # ── HTTP helpers (no third-party deps — mirrors api.py style) ─────────────────
@@ -342,7 +415,7 @@ def _parse_since(since_str: str) -> str:
342
415
  # ── Hook helpers ─────────────────────────────────────────────────────────────
343
416
 
344
417
  def _install_claude_hook(hook_path: Path) -> None:
345
- """Register hook_path as a PreToolUse hook in ~/.claude/settings.json."""
418
+ """Register PreToolUse and PostToolUse hooks in ~/.claude/settings.json."""
346
419
  claude_settings = Path.home() / ".claude" / "settings.json"
347
420
  settings: dict = {}
348
421
  if claude_settings.exists():
@@ -352,21 +425,41 @@ def _install_claude_hook(hook_path: Path) -> None:
352
425
  settings = {}
353
426
 
354
427
  hooks = settings.setdefault("hooks", {})
355
- pre = hooks.setdefault("PreToolUse", [])
356
- cmd = f"python3 {hook_path}"
357
428
 
358
- already = any(
359
- e.get("command") == cmd
429
+ # PreToolUse — existing hook script
430
+ pre = hooks.setdefault("PreToolUse", [])
431
+ pre_cmd = f"python3 {hook_path}"
432
+ pre_already = any(
433
+ e.get("command") == pre_cmd
360
434
  for h in pre
361
435
  for e in h.get("hooks", [])
362
436
  )
363
- if not already:
364
- pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": cmd}]})
437
+ changed = False
438
+ if not pre_already:
439
+ pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": pre_cmd}]})
440
+ changed = True
441
+
442
+ # PostToolUse — conductguard-post entrypoint
443
+ post = hooks.setdefault("PostToolUse", [])
444
+ post_cmd = "conductguard-post"
445
+ post_already = any(
446
+ e.get("command") == post_cmd
447
+ for h in post
448
+ for e in h.get("hooks", [])
449
+ )
450
+ if not post_already:
451
+ post.append({"matcher": "*", "hooks": [{"type": "command", "command": post_cmd}]})
452
+ changed = True
453
+
454
+ if changed:
365
455
  claude_settings.parent.mkdir(parents=True, exist_ok=True)
366
456
  claude_settings.write_text(json.dumps(settings, indent=2))
367
- print(f" {GREEN}Claude Code hook registered{RESET}")
457
+ if not pre_already:
458
+ print(f" {GREEN}Claude Code PreToolUse hook registered{RESET}")
459
+ if not post_already:
460
+ print(f" {GREEN}Claude Code PostToolUse hook registered{RESET}")
368
461
  else:
369
- print(f" {GRAY}Claude Code hook already registered{RESET}")
462
+ print(f" {GRAY}Claude Code hooks already registered{RESET}")
370
463
 
371
464
 
372
465
  # ── Commands ──────────────────────────────────────────────────────────────────
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.4
3
+ Version: 0.4.5
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
@@ -1,3 +1,4 @@
1
1
  [console_scripts]
2
2
  conduct = conduct_cli.main:main
3
3
  conductguard-mcp = conduct_cli.guardmcp:main
4
+ conductguard-post = conduct_cli.guard:post_usage_main
File without changes
File without changes
File without changes