conduct-cli 0.4.27__tar.gz → 0.4.29__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.27
3
+ Version: 0.4.29
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.27"
7
+ version = "0.4.29"
8
8
  description = "CLI for Conduct AI — install agents, manage projects, run tests"
9
9
  readme = "README.md"
10
10
  license = { text = "MIT" }
@@ -31,8 +31,9 @@ Repository = "https://github.com/sseshachala/conductai"
31
31
  "Bug Tracker" = "https://github.com/sseshachala/conductai/issues"
32
32
 
33
33
  [project.scripts]
34
- conduct = "conduct_cli.main:main"
35
- conductguard-mcp = "conduct_cli.guardmcp:main"
34
+ conduct = "conduct_cli.main:main"
35
+ conduct-mcp = "conduct_cli.mcp_server:main"
36
+ conductguard-mcp = "conduct_cli.guardmcp:main"
36
37
  conductguard-post = "conduct_cli.guard:post_usage_main"
37
38
 
38
39
  [tool.setuptools.packages.find]
@@ -192,6 +192,148 @@ def _poll_run(server: str, workflow_id: str, run_id: str, hdrs: dict) -> bool:
192
192
 
193
193
  # ── Commands ──────────────────────────────────────────────────────────────────
194
194
 
195
+ def _write_claude_mcp_settings() -> bool:
196
+ """Write conduct-mcp into ~/.claude/settings.json. Returns True if written."""
197
+ settings_path = Path.home() / ".claude" / "settings.json"
198
+ try:
199
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
200
+ servers = existing.setdefault("mcpServers", {})
201
+ if "conduct" in servers:
202
+ return True # already registered
203
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
204
+ settings_path.parent.mkdir(parents=True, exist_ok=True)
205
+ settings_path.write_text(json.dumps(existing, indent=2))
206
+ return True
207
+ except Exception:
208
+ return False
209
+
210
+
211
+ def _write_codex_mcp_config() -> bool:
212
+ """Write conduct-mcp into ~/.codex/config.toml. Returns True if written."""
213
+ codex_dir = Path.home() / ".codex"
214
+ if not codex_dir.exists():
215
+ return False
216
+ config_path = codex_dir / "config.toml"
217
+ try:
218
+ content = config_path.read_text() if config_path.exists() else ""
219
+ if "conduct-mcp" in content:
220
+ return True
221
+ mcp_block = '\n[[mcp_servers]]\nname = "conduct"\ncommand = "conduct-mcp"\nargs = []\n'
222
+ config_path.write_text(content + mcp_block)
223
+ return True
224
+ except Exception:
225
+ return False
226
+
227
+
228
+ def _write_cursor_mcp_config() -> bool:
229
+ """Write conduct-mcp into ~/.cursor/mcp.json. Returns True if written."""
230
+ cursor_dir = Path.home() / ".cursor"
231
+ if not cursor_dir.exists():
232
+ return False
233
+ config_path = cursor_dir / "mcp.json"
234
+ try:
235
+ existing = json.loads(config_path.read_text()) if config_path.exists() else {}
236
+ servers = existing.setdefault("mcpServers", {})
237
+ if "conduct" in servers:
238
+ return True
239
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
240
+ config_path.write_text(json.dumps(existing, indent=2))
241
+ return True
242
+ except Exception:
243
+ return False
244
+
245
+
246
+ def _write_windsurf_mcp_config() -> bool:
247
+ """Write conduct-mcp into ~/.codeium/windsurf/mcp_config.json. Returns True if written."""
248
+ config_path = Path.home() / ".codeium" / "windsurf" / "mcp_config.json"
249
+ if not config_path.parent.exists():
250
+ return False
251
+ try:
252
+ existing = json.loads(config_path.read_text()) if config_path.exists() else {}
253
+ servers = existing.setdefault("mcpServers", {})
254
+ if "conduct" in servers:
255
+ return True
256
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
257
+ config_path.write_text(json.dumps(existing, indent=2))
258
+ return True
259
+ except Exception:
260
+ return False
261
+
262
+
263
+ def _write_vscode_mcp_config() -> bool:
264
+ """Write conduct-mcp into VS Code settings.json (mcp.servers). Returns True if written."""
265
+ # Check both standard locations
266
+ candidates = [
267
+ Path.home() / ".vscode" / "settings.json",
268
+ Path.home() / "Library" / "Application Support" / "Code" / "User" / "settings.json",
269
+ Path.home() / ".config" / "Code" / "User" / "settings.json",
270
+ ]
271
+ settings_path = next((p for p in candidates if p.exists()), None)
272
+ if not settings_path:
273
+ return False
274
+ try:
275
+ existing = json.loads(settings_path.read_text()) if settings_path.exists() else {}
276
+ servers = existing.setdefault("mcp", {}).setdefault("servers", {})
277
+ if "conduct" in servers:
278
+ return True
279
+ servers["conduct"] = {"command": "conduct-mcp", "args": []}
280
+ settings_path.write_text(json.dumps(existing, indent=2))
281
+ return True
282
+ except Exception:
283
+ return False
284
+
285
+
286
+ def cmd_mcp_install(args):
287
+ """Register conduct-mcp in Claude Code, Codex, Cursor, Windsurf, and VS Code."""
288
+ import shutil
289
+ import subprocess
290
+
291
+ registered = []
292
+
293
+ # --- Claude Code ---
294
+ if shutil.which("claude"):
295
+ try:
296
+ result = subprocess.run(
297
+ ["claude", "mcp", "add", "conduct", "conduct-mcp"],
298
+ capture_output=True, text=True, timeout=15,
299
+ )
300
+ if result.returncode == 0:
301
+ registered.append("Claude Code")
302
+ else:
303
+ _write_claude_mcp_settings()
304
+ registered.append("Claude Code (settings.json)")
305
+ except Exception:
306
+ _write_claude_mcp_settings()
307
+ registered.append("Claude Code (settings.json)")
308
+ else:
309
+ if _write_claude_mcp_settings():
310
+ registered.append("Claude Code (settings.json)")
311
+
312
+ # --- Codex CLI ---
313
+ if _write_codex_mcp_config():
314
+ registered.append("Codex")
315
+
316
+ # --- Cursor ---
317
+ if _write_cursor_mcp_config():
318
+ registered.append("Cursor")
319
+
320
+ # --- Windsurf ---
321
+ if _write_windsurf_mcp_config():
322
+ registered.append("Windsurf")
323
+
324
+ # --- VS Code (Copilot) ---
325
+ if _write_vscode_mcp_config():
326
+ registered.append("VS Code (Copilot)")
327
+
328
+ if registered:
329
+ print(f"{GREEN}✓ conduct-mcp registered in: {', '.join(registered)}{RESET}")
330
+ print(f"{GRAY} Restart your AI tools to pick up the new MCP server.{RESET}")
331
+ else:
332
+ print(f"{YELLOW}⚠ No supported AI tools detected on this machine.{RESET}")
333
+ print(f"{GRAY} Supported: Claude Code, Codex, Cursor, Windsurf, VS Code{RESET}")
334
+ print(f"{GRAY} After installing any of these, re-run: conduct mcp install{RESET}")
335
+
336
+
195
337
  def cmd_login(args):
196
338
  server = args.server
197
339
  api_key = args.api_key
@@ -254,6 +396,13 @@ def cmd_login(args):
254
396
  except Exception:
255
397
  pass # Never block login on Guard errors
256
398
 
399
+ # Auto-register MCP servers in Claude Code / Codex
400
+ try:
401
+ import types
402
+ cmd_mcp_install(types.SimpleNamespace())
403
+ except Exception:
404
+ pass # Never block login on MCP registration errors
405
+
257
406
 
258
407
  def cmd_agents(args):
259
408
  server, workspace_id, api_key, token = _require_auth(args)
@@ -1179,6 +1328,11 @@ def main():
1179
1328
  # conduct guard
1180
1329
  guard_p, _guard_sub = _guard.register_guard_parser(sub)
1181
1330
 
1331
+ # conduct mcp
1332
+ mcp_p = sub.add_parser("mcp", help="Manage the Conduct MCP server")
1333
+ mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
1334
+ mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
1335
+
1182
1336
  args = parser.parse_args()
1183
1337
 
1184
1338
  if args.command == "login":
@@ -1223,6 +1377,11 @@ def main():
1223
1377
  cmd_run(args)
1224
1378
  elif args.command == "guard":
1225
1379
  _guard.dispatch_guard(args, guard_p)
1380
+ elif args.command == "mcp":
1381
+ if getattr(args, "mcp_command", None) == "install":
1382
+ cmd_mcp_install(args)
1383
+ else:
1384
+ mcp_p.print_help()
1226
1385
  else:
1227
1386
  parser.print_help()
1228
1387
 
@@ -0,0 +1,334 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ conduct-mcp — Conduct AI MCP server.
4
+
5
+ Runs as a subprocess started by Claude Code / Cursor / Windsurf via the
6
+ mcpServers config written by `conduct mcp install`. Communicates over
7
+ stdin/stdout using JSON-RPC 2.0 (MCP stdio transport).
8
+
9
+ Exposes six tools:
10
+ conduct_list_agents — list agents in the workspace
11
+ conduct_list_projects — list projects in the workspace
12
+ conduct_list_playbooks — list available playbook templates
13
+ conduct_run_workflow — trigger a workflow run
14
+ conduct_get_run — get run status / result
15
+ conduct_guard_status — ConductGuard policy status
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import json
20
+ import sys
21
+ import urllib.error
22
+ import urllib.request
23
+ from pathlib import Path
24
+
25
+ CONDUCT_CONFIG_PATH = Path.home() / ".conduct" / "config.json"
26
+ GUARD_DIR = Path.home() / ".conductguard"
27
+ GUARD_POLICY_PATH = GUARD_DIR / "policy.json"
28
+ GUARD_CONFIG_PATH = GUARD_DIR / "config.json"
29
+
30
+ PROTOCOL_VERSION = "2024-11-05"
31
+
32
+ _TOOLS = [
33
+ {
34
+ "name": "conduct_list_agents",
35
+ "description": "List all installed agents in your Conduct workspace.",
36
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
37
+ },
38
+ {
39
+ "name": "conduct_list_projects",
40
+ "description": "List all projects in your Conduct workspace.",
41
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
42
+ },
43
+ {
44
+ "name": "conduct_list_playbooks",
45
+ "description": "List available Conduct playbooks (workflow templates).",
46
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
47
+ },
48
+ {
49
+ "name": "conduct_run_workflow",
50
+ "description": "Trigger a workflow run in Conduct. Returns the run ID.",
51
+ "inputSchema": {
52
+ "type": "object",
53
+ "properties": {
54
+ "workflow_id": {
55
+ "type": "string",
56
+ "description": "The workflow UUID to run",
57
+ },
58
+ "payload": {
59
+ "type": "object",
60
+ "description": "Optional trigger payload (key-value pairs)",
61
+ },
62
+ },
63
+ "required": ["workflow_id"],
64
+ },
65
+ },
66
+ {
67
+ "name": "conduct_get_run",
68
+ "description": "Get the status and result of a workflow run.",
69
+ "inputSchema": {
70
+ "type": "object",
71
+ "properties": {
72
+ "workflow_id": {"type": "string"},
73
+ "run_id": {"type": "string"},
74
+ },
75
+ "required": ["workflow_id", "run_id"],
76
+ },
77
+ },
78
+ {
79
+ "name": "conduct_guard_status",
80
+ "description": (
81
+ "Show current ConductGuard policy status — active rules, "
82
+ "today's spend, and your team info."
83
+ ),
84
+ "inputSchema": {"type": "object", "properties": {}, "required": []},
85
+ },
86
+ ]
87
+
88
+
89
+ # ── Config helpers ─────────────────────────────────────────────────────────────
90
+
91
+ def _load_conduct_config() -> dict:
92
+ if CONDUCT_CONFIG_PATH.exists():
93
+ try:
94
+ return json.loads(CONDUCT_CONFIG_PATH.read_text())
95
+ except Exception:
96
+ pass
97
+ return {}
98
+
99
+
100
+ def _load_guard_policy() -> dict:
101
+ if GUARD_POLICY_PATH.exists():
102
+ try:
103
+ return json.loads(GUARD_POLICY_PATH.read_text())
104
+ except Exception:
105
+ pass
106
+ return {"rules": []}
107
+
108
+
109
+ def _load_guard_config() -> dict:
110
+ if GUARD_CONFIG_PATH.exists():
111
+ try:
112
+ return json.loads(GUARD_CONFIG_PATH.read_text())
113
+ except Exception:
114
+ pass
115
+ return {}
116
+
117
+
118
+ # ── HTTP helpers ───────────────────────────────────────────────────────────────
119
+
120
+ def _api_get(url: str, token: str | None, api_key: str | None) -> dict | list:
121
+ headers = {"Content-Type": "application/json"}
122
+ if token:
123
+ headers["Authorization"] = f"Bearer {token}"
124
+ elif api_key:
125
+ headers["Authorization"] = f"Bearer {api_key}"
126
+ req = urllib.request.Request(url, headers=headers)
127
+ with urllib.request.urlopen(req, timeout=10) as resp:
128
+ return json.loads(resp.read())
129
+
130
+
131
+ def _api_post(url: str, body: dict, token: str | None, api_key: str | None) -> dict | list:
132
+ headers = {"Content-Type": "application/json"}
133
+ if token:
134
+ headers["Authorization"] = f"Bearer {token}"
135
+ elif api_key:
136
+ headers["Authorization"] = f"Bearer {api_key}"
137
+ data = json.dumps(body).encode()
138
+ req = urllib.request.Request(url, data=data, headers=headers, method="POST")
139
+ with urllib.request.urlopen(req, timeout=10) as resp:
140
+ return json.loads(resp.read())
141
+
142
+
143
+ # ── Tool handlers ──────────────────────────────────────────────────────────────
144
+
145
+ def _handle_list_agents(server: str, workspace_id: str, token: str | None, api_key: str | None) -> str:
146
+ try:
147
+ url = f"{server}/agents?workspace_id={workspace_id}"
148
+ result = _api_get(url, token, api_key)
149
+ agents = [
150
+ {"id": a.get("id"), "name": a.get("name"), "status": a.get("status")}
151
+ for a in (result if isinstance(result, list) else result.get("items", []))
152
+ ]
153
+ return json.dumps(agents, indent=2)
154
+ except urllib.error.HTTPError as e:
155
+ return f"Error — HTTP {e.code}: {e.read().decode()[:200]}"
156
+ except Exception as e:
157
+ return f"Error — {e}"
158
+
159
+
160
+ def _handle_list_projects(server: str, workspace_id: str, token: str | None, api_key: str | None) -> str:
161
+ try:
162
+ url = f"{server}/projects?workspace_id={workspace_id}"
163
+ result = _api_get(url, token, api_key)
164
+ return json.dumps(result, indent=2)
165
+ except urllib.error.HTTPError as e:
166
+ return f"Error — HTTP {e.code}: {e.read().decode()[:200]}"
167
+ except Exception as e:
168
+ return f"Error — {e}"
169
+
170
+
171
+ def _handle_list_playbooks(server: str, workspace_id: str, token: str | None, api_key: str | None) -> str:
172
+ try:
173
+ url = f"{server}/playbooks?workspace_id={workspace_id}"
174
+ result = _api_get(url, token, api_key)
175
+ return json.dumps(result, indent=2)
176
+ except urllib.error.HTTPError as e:
177
+ return f"Error — HTTP {e.code}: {e.read().decode()[:200]}"
178
+ except Exception as e:
179
+ return f"Error — {e}"
180
+
181
+
182
+ def _handle_run_workflow(
183
+ arguments: dict,
184
+ server: str,
185
+ workspace_id: str,
186
+ token: str | None,
187
+ api_key: str | None,
188
+ ) -> str:
189
+ workflow_id = arguments.get("workflow_id", "")
190
+ payload = arguments.get("payload") or {}
191
+ if not workflow_id:
192
+ return "Error — workflow_id is required."
193
+ try:
194
+ url = f"{server}/workflows/{workflow_id}/runs"
195
+ body = {"workspace_id": workspace_id, "payload": payload}
196
+ run = _api_post(url, body, token, api_key)
197
+ return json.dumps({"run_id": run.get("id") or run.get("run_id"), "status": run.get("status")}, indent=2)
198
+ except urllib.error.HTTPError as e:
199
+ return f"Error — HTTP {e.code}: {e.read().decode()[:200]}"
200
+ except Exception as e:
201
+ return f"Error — {e}"
202
+
203
+
204
+ def _handle_get_run(
205
+ arguments: dict,
206
+ server: str,
207
+ token: str | None,
208
+ api_key: str | None,
209
+ ) -> str:
210
+ workflow_id = arguments.get("workflow_id", "")
211
+ run_id = arguments.get("run_id", "")
212
+ if not workflow_id or not run_id:
213
+ return "Error — workflow_id and run_id are both required."
214
+ try:
215
+ url = f"{server}/workflows/{workflow_id}/runs/{run_id}"
216
+ run = _api_get(url, token, api_key)
217
+ return json.dumps(
218
+ {
219
+ "status": run.get("status"),
220
+ "outcome": run.get("outcome"),
221
+ "started_at": run.get("started_at"),
222
+ "completed_at": run.get("completed_at"),
223
+ },
224
+ indent=2,
225
+ )
226
+ except urllib.error.HTTPError as e:
227
+ return f"Error — HTTP {e.code}: {e.read().decode()[:200]}"
228
+ except Exception as e:
229
+ return f"Error — {e}"
230
+
231
+
232
+ def _handle_guard_status(workspace_id: str) -> str:
233
+ cfg = _load_guard_config()
234
+ policy = _load_guard_policy()
235
+ return json.dumps(
236
+ {
237
+ "workspace_id": workspace_id,
238
+ "email": cfg.get("user_email", ""),
239
+ "rules_active": len(policy.get("rules", [])),
240
+ "policy_version": policy.get("version", ""),
241
+ },
242
+ indent=2,
243
+ )
244
+
245
+
246
+ def _dispatch_tool(
247
+ name: str,
248
+ arguments: dict,
249
+ server: str,
250
+ workspace_id: str,
251
+ token: str | None,
252
+ api_key: str | None,
253
+ ) -> str:
254
+ if name == "conduct_list_agents":
255
+ return _handle_list_agents(server, workspace_id, token, api_key)
256
+ if name == "conduct_list_projects":
257
+ return _handle_list_projects(server, workspace_id, token, api_key)
258
+ if name == "conduct_list_playbooks":
259
+ return _handle_list_playbooks(server, workspace_id, token, api_key)
260
+ if name == "conduct_run_workflow":
261
+ return _handle_run_workflow(arguments, server, workspace_id, token, api_key)
262
+ if name == "conduct_get_run":
263
+ return _handle_get_run(arguments, server, token, api_key)
264
+ if name == "conduct_guard_status":
265
+ return _handle_guard_status(workspace_id)
266
+ return f"Unknown tool: {name}"
267
+
268
+
269
+ # ── JSON-RPC helpers ───────────────────────────────────────────────────────────
270
+
271
+ def _send(obj: dict) -> None:
272
+ print(json.dumps(obj), flush=True)
273
+
274
+
275
+ def _ok(msg_id, result: dict) -> None:
276
+ _send({"jsonrpc": "2.0", "id": msg_id, "result": result})
277
+
278
+
279
+ def _err(msg_id, code: int, message: str) -> None:
280
+ _send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}})
281
+
282
+
283
+ # ── Main loop ──────────────────────────────────────────────────────────────────
284
+
285
+ def main() -> None:
286
+ cfg = _load_conduct_config()
287
+ server = cfg.get("server", "").rstrip("/")
288
+ workspace_id = cfg.get("workspace", "")
289
+ api_key = cfg.get("api_key")
290
+ token = cfg.get("token")
291
+
292
+ for raw in sys.stdin:
293
+ raw = raw.strip()
294
+ if not raw:
295
+ continue
296
+
297
+ try:
298
+ msg = json.loads(raw)
299
+ except json.JSONDecodeError:
300
+ continue
301
+
302
+ msg_id = msg.get("id") # None for notifications
303
+ method = msg.get("method", "")
304
+ params = msg.get("params") or {}
305
+
306
+ if method == "initialize":
307
+ _ok(msg_id, {
308
+ "protocolVersion": PROTOCOL_VERSION,
309
+ "capabilities": {"tools": {}},
310
+ "serverInfo": {"name": "conduct", "version": "1.0.0"},
311
+ })
312
+
313
+ elif method == "notifications/initialized":
314
+ pass # notification — no response
315
+
316
+ elif method == "tools/list":
317
+ _ok(msg_id, {"tools": _TOOLS})
318
+
319
+ elif method == "tools/call":
320
+ tool_name = params.get("name", "")
321
+ arguments = params.get("arguments") or {}
322
+ text = _dispatch_tool(tool_name, arguments, server, workspace_id, token, api_key)
323
+ _ok(msg_id, {"content": [{"type": "text", "text": text}]})
324
+
325
+ elif method == "ping":
326
+ _ok(msg_id, {})
327
+
328
+ else:
329
+ if msg_id is not None:
330
+ _err(msg_id, -32601, f"Method not found: {method}")
331
+
332
+
333
+ if __name__ == "__main__":
334
+ main()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: conduct-cli
3
- Version: 0.4.27
3
+ Version: 0.4.29
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
@@ -6,6 +6,7 @@ src/conduct_cli/api.py
6
6
  src/conduct_cli/guard.py
7
7
  src/conduct_cli/guardmcp.py
8
8
  src/conduct_cli/main.py
9
+ src/conduct_cli/mcp_server.py
9
10
  src/conduct_cli.egg-info/PKG-INFO
10
11
  src/conduct_cli.egg-info/SOURCES.txt
11
12
  src/conduct_cli.egg-info/dependency_links.txt
@@ -1,4 +1,5 @@
1
1
  [console_scripts]
2
2
  conduct = conduct_cli.main:main
3
+ conduct-mcp = conduct_cli.mcp_server:main
3
4
  conductguard-mcp = conduct_cli.guardmcp:main
4
5
  conductguard-post = conduct_cli.guard:post_usage_main
File without changes
File without changes
File without changes