conduct-cli 0.4.27__tar.gz → 0.4.28__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.28
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.28"
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,81 @@ 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 # already registered
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 cmd_mcp_install(args):
229
+ """Register conduct-mcp in Claude Code and Codex CLI."""
230
+ import shutil
231
+ import subprocess
232
+
233
+ registered = []
234
+
235
+ # --- Claude Code ---
236
+ if shutil.which("claude"):
237
+ try:
238
+ result = subprocess.run(
239
+ ["claude", "mcp", "add", "conduct", "conduct-mcp"],
240
+ capture_output=True, text=True, timeout=15,
241
+ )
242
+ if result.returncode == 0:
243
+ registered.append("Claude Code")
244
+ else:
245
+ # claude mcp add is idempotent; also try writing settings.json directly as fallback
246
+ _write_claude_mcp_settings()
247
+ registered.append("Claude Code (settings.json)")
248
+ except Exception:
249
+ _write_claude_mcp_settings()
250
+ registered.append("Claude Code (settings.json)")
251
+ else:
252
+ # claude CLI not found but .claude/ might exist (IDE extension)
253
+ written = _write_claude_mcp_settings()
254
+ if written:
255
+ registered.append("Claude Code (settings.json)")
256
+
257
+ # --- Codex CLI ---
258
+ written = _write_codex_mcp_config()
259
+ if written:
260
+ registered.append("Codex")
261
+
262
+ if registered:
263
+ print(f"{GREEN}✓ conduct-mcp registered in: {', '.join(registered)}{RESET}")
264
+ print(f"{GRAY} Restart Claude Code / Codex to pick up the new MCP server.{RESET}")
265
+ else:
266
+ print(f"{YELLOW}⚠ No supported AI tools detected. Install Claude Code or Codex first.{RESET}")
267
+ print(f"{GRAY} Then re-run: conduct mcp install{RESET}")
268
+
269
+
195
270
  def cmd_login(args):
196
271
  server = args.server
197
272
  api_key = args.api_key
@@ -254,6 +329,13 @@ def cmd_login(args):
254
329
  except Exception:
255
330
  pass # Never block login on Guard errors
256
331
 
332
+ # Auto-register MCP servers in Claude Code / Codex
333
+ try:
334
+ import types
335
+ cmd_mcp_install(types.SimpleNamespace())
336
+ except Exception:
337
+ pass # Never block login on MCP registration errors
338
+
257
339
 
258
340
  def cmd_agents(args):
259
341
  server, workspace_id, api_key, token = _require_auth(args)
@@ -1179,6 +1261,11 @@ def main():
1179
1261
  # conduct guard
1180
1262
  guard_p, _guard_sub = _guard.register_guard_parser(sub)
1181
1263
 
1264
+ # conduct mcp
1265
+ mcp_p = sub.add_parser("mcp", help="Manage the Conduct MCP server")
1266
+ mcp_sub = mcp_p.add_subparsers(dest="mcp_command")
1267
+ mcp_sub.add_parser("install", help="Register conduct-mcp in Claude Code and Codex")
1268
+
1182
1269
  args = parser.parse_args()
1183
1270
 
1184
1271
  if args.command == "login":
@@ -1223,6 +1310,11 @@ def main():
1223
1310
  cmd_run(args)
1224
1311
  elif args.command == "guard":
1225
1312
  _guard.dispatch_guard(args, guard_p)
1313
+ elif args.command == "mcp":
1314
+ if getattr(args, "mcp_command", None) == "install":
1315
+ cmd_mcp_install(args)
1316
+ else:
1317
+ mcp_p.print_help()
1226
1318
  else:
1227
1319
  parser.print_help()
1228
1320
 
@@ -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.28
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