conduct-cli 0.4.4__tar.gz → 0.4.6__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.6
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.6"
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}")
@@ -195,7 +246,10 @@ def main():
195
246
 
196
247
 
197
248
  if __name__ == "__main__":
198
- main()
249
+ if len(sys.argv) > 1 and sys.argv[1] == "post":
250
+ post_usage_main()
251
+ else:
252
+ main()
199
253
  '''
200
254
 
201
255
  # ── Guard config helpers ──────────────────────────────────────────────────────
@@ -265,7 +319,7 @@ def _register_mcp(workspace_id: str, member_token: str, api_url: str) -> None:
265
319
 
266
320
 
267
321
  def _install_codex_hook(hook_path: Path) -> None:
268
- """Register hook in ~/.codex/hooks.json (same format as Claude Code)."""
322
+ """Register PreToolUse and PostToolUse hooks in ~/.codex/hooks.json."""
269
323
  codex_hooks = Path.home() / ".codex" / "hooks.json"
270
324
  if not (Path.home() / ".codex").exists():
271
325
  return # Codex not installed
@@ -277,20 +331,53 @@ def _install_codex_hook(hook_path: Path) -> None:
277
331
  except json.JSONDecodeError:
278
332
  hooks = {}
279
333
 
280
- cmd = f"python3 {hook_path}"
281
- pre = hooks.setdefault("hooks", {}).setdefault("PreToolUse", [])
282
- already = any(
283
- e.get("command") == cmd
334
+ hook_section = hooks.setdefault("hooks", {})
335
+
336
+ # PreToolUse
337
+ pre_cmd = f"python3 {hook_path}"
338
+ pre = hook_section.setdefault("PreToolUse", [])
339
+ pre_already = any(
340
+ e.get("command") == pre_cmd
284
341
  for h in pre
285
342
  for e in h.get("hooks", [])
286
343
  )
287
- if not already:
288
- pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": cmd}]})
344
+ changed = False
345
+ if not pre_already:
346
+ pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": pre_cmd}]})
347
+ changed = True
348
+
349
+ # PostToolUse — self-contained: python3 /path/hook.py post (no PATH dependency)
350
+ post_cmd = f"python3 {hook_path} post"
351
+ post = hook_section.setdefault("PostToolUse", [])
352
+ # Remove stale conductguard-post entries registered by older CLI versions
353
+ stale = "conductguard-post"
354
+ cleaned = False
355
+ for h in post:
356
+ before = len(h.get("hooks", []))
357
+ h["hooks"] = [e for e in h.get("hooks", []) if e.get("command") != stale]
358
+ if len(h["hooks"]) < before:
359
+ cleaned = True
360
+ post[:] = [h for h in post if h.get("hooks")]
361
+ post_already = any(
362
+ e.get("command") == post_cmd
363
+ for h in post
364
+ for e in h.get("hooks", [])
365
+ )
366
+ if not post_already:
367
+ post.append({"matcher": ".*", "hooks": [{"type": "command", "command": post_cmd}]})
368
+ changed = True
369
+ if cleaned:
370
+ changed = True
371
+
372
+ if changed:
289
373
  codex_hooks.parent.mkdir(parents=True, exist_ok=True)
290
374
  codex_hooks.write_text(json.dumps(hooks, indent=2))
291
- print(f" {GREEN}Codex hook registered{RESET}")
375
+ if not pre_already:
376
+ print(f" {GREEN}Codex PreToolUse hook registered{RESET}")
377
+ if not post_already or cleaned:
378
+ print(f" {GREEN}Codex PostToolUse hook registered{RESET}")
292
379
  else:
293
- print(f" {GRAY}Codex hook already registered{RESET}")
380
+ print(f" {GRAY}Codex hooks already registered{RESET}")
294
381
 
295
382
 
296
383
  # ── HTTP helpers (no third-party deps — mirrors api.py style) ─────────────────
@@ -342,7 +429,7 @@ def _parse_since(since_str: str) -> str:
342
429
  # ── Hook helpers ─────────────────────────────────────────────────────────────
343
430
 
344
431
  def _install_claude_hook(hook_path: Path) -> None:
345
- """Register hook_path as a PreToolUse hook in ~/.claude/settings.json."""
432
+ """Register PreToolUse and PostToolUse hooks in ~/.claude/settings.json."""
346
433
  claude_settings = Path.home() / ".claude" / "settings.json"
347
434
  settings: dict = {}
348
435
  if claude_settings.exists():
@@ -352,21 +439,52 @@ def _install_claude_hook(hook_path: Path) -> None:
352
439
  settings = {}
353
440
 
354
441
  hooks = settings.setdefault("hooks", {})
355
- pre = hooks.setdefault("PreToolUse", [])
356
- cmd = f"python3 {hook_path}"
357
442
 
358
- already = any(
359
- e.get("command") == cmd
443
+ # PreToolUse — existing hook script
444
+ pre = hooks.setdefault("PreToolUse", [])
445
+ pre_cmd = f"python3 {hook_path}"
446
+ pre_already = any(
447
+ e.get("command") == pre_cmd
360
448
  for h in pre
361
449
  for e in h.get("hooks", [])
362
450
  )
363
- if not already:
364
- pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": cmd}]})
451
+ changed = False
452
+ if not pre_already:
453
+ pre.append({"matcher": ".*", "hooks": [{"type": "command", "command": pre_cmd}]})
454
+ changed = True
455
+
456
+ # PostToolUse — self-contained: python3 /path/hook.py post (no PATH dependency)
457
+ post = hooks.setdefault("PostToolUse", [])
458
+ post_cmd = f"python3 {hook_path} post"
459
+ # Remove stale conductguard-post entries registered by older CLI versions
460
+ stale = "conductguard-post"
461
+ cleaned = False
462
+ for h in post:
463
+ before = len(h.get("hooks", []))
464
+ h["hooks"] = [e for e in h.get("hooks", []) if e.get("command") != stale]
465
+ if len(h["hooks"]) < before:
466
+ cleaned = True
467
+ post[:] = [h for h in post if h.get("hooks")]
468
+ post_already = any(
469
+ e.get("command") == post_cmd
470
+ for h in post
471
+ for e in h.get("hooks", [])
472
+ )
473
+ if not post_already:
474
+ post.append({"matcher": ".*", "hooks": [{"type": "command", "command": post_cmd}]})
475
+ changed = True
476
+ if cleaned:
477
+ changed = True
478
+
479
+ if changed:
365
480
  claude_settings.parent.mkdir(parents=True, exist_ok=True)
366
481
  claude_settings.write_text(json.dumps(settings, indent=2))
367
- print(f" {GREEN}Claude Code hook registered{RESET}")
482
+ if not pre_already:
483
+ print(f" {GREEN}Claude Code PreToolUse hook registered{RESET}")
484
+ if not post_already or cleaned:
485
+ print(f" {GREEN}Claude Code PostToolUse hook registered{RESET}")
368
486
  else:
369
- print(f" {GRAY}Claude Code hook already registered{RESET}")
487
+ print(f" {GRAY}Claude Code hooks already registered{RESET}")
370
488
 
371
489
 
372
490
  # ── 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.6
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