@xdxer/dingtalk-agent 0.1.4-beta.9 → 0.1.5-beta.1

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.
Files changed (129) hide show
  1. package/CHANGELOG.md +229 -0
  2. package/README.en.md +89 -324
  3. package/README.md +88 -674
  4. package/dist/bin/dingtalk-agent.js +207 -21
  5. package/dist/bin/dingtalk-agent.js.map +1 -1
  6. package/dist/src/agent-audit.js +1014 -89
  7. package/dist/src/agent-audit.js.map +1 -1
  8. package/dist/src/agent-enhance.js +41 -8
  9. package/dist/src/agent-enhance.js.map +1 -1
  10. package/dist/src/agent-platform.js +299 -0
  11. package/dist/src/agent-platform.js.map +1 -0
  12. package/dist/src/config.js +1 -7
  13. package/dist/src/config.js.map +1 -1
  14. package/dist/src/development-workspace.js +31 -5
  15. package/dist/src/development-workspace.js.map +1 -1
  16. package/dist/src/doctor.js +74 -15
  17. package/dist/src/doctor.js.map +1 -1
  18. package/dist/src/host-detect.js +146 -0
  19. package/dist/src/host-detect.js.map +1 -0
  20. package/dist/src/instruction-path.js +270 -0
  21. package/dist/src/instruction-path.js.map +1 -0
  22. package/dist/src/multica-deploy.js +78 -20
  23. package/dist/src/multica-deploy.js.map +1 -1
  24. package/dist/src/multica-provider.js +1 -1
  25. package/dist/src/multica-provider.js.map +1 -1
  26. package/dist/src/opencode-evals.js +710 -225
  27. package/dist/src/opencode-evals.js.map +1 -1
  28. package/dist/src/opencode-isolation.js +124 -0
  29. package/dist/src/opencode-isolation.js.map +1 -0
  30. package/dist/src/opencode-provider.js +1 -0
  31. package/dist/src/opencode-provider.js.map +1 -1
  32. package/dist/src/opencode-workspace.js +21 -10
  33. package/dist/src/opencode-workspace.js.map +1 -1
  34. package/dist/src/remote-state-evals.js +2 -1
  35. package/dist/src/remote-state-evals.js.map +1 -1
  36. package/dist/src/robot-evals.js +2 -1
  37. package/dist/src/robot-evals.js.map +1 -1
  38. package/dist/src/setup.js +6 -5
  39. package/dist/src/setup.js.map +1 -1
  40. package/dist/src/skill-manager.js +141 -12
  41. package/dist/src/skill-manager.js.map +1 -1
  42. package/dist/src/skills.js +2 -1
  43. package/dist/src/skills.js.map +1 -1
  44. package/dist/src/types.js.map +1 -1
  45. package/dist/src/upgrade.js +23 -27
  46. package/dist/src/upgrade.js.map +1 -1
  47. package/dist/src/version.js +73 -0
  48. package/dist/src/version.js.map +1 -0
  49. package/docs/ARCHITECTURE.md +232 -0
  50. package/docs/INSTALLATION.md +3 -3
  51. package/docs/PRIOR-ART.md +122 -0
  52. package/docs/SELF-TEST.md +182 -0
  53. package/docs/assets/agent-delivery-lifecycle.svg +103 -0
  54. package/docs/schemas/agent-platform.schema.json +13 -0
  55. package/docs/schemas/project.schema.json +3 -0
  56. package/docs/schemas/release-readiness.schema.json +2 -1
  57. package/evals/README.md +17 -0
  58. package/examples/agents/README.md +43 -0
  59. package/examples/agents/fde-coach/AGENTS.md +26 -17
  60. package/examples/agents/release-manager/AGENTS.md +26 -17
  61. package/lab/README.md +3 -3
  62. package/lab/agent-eval/catalog.json +5 -5
  63. package/lab/agent-eval/classic-failures.json +9 -9
  64. package/lab/agent-eval/completion-gate-regression.json +6 -6
  65. package/lab/agent-eval/remote-state-workspace/opencode.json +1 -1
  66. package/lab/agent-eval/workspace/AGENTS.md +1 -1
  67. package/lab/project-workspace/fake-multica-provider.mjs +17 -6
  68. package/lab/project-workspace/opencode-provider-suite.json +2 -2
  69. package/lab/robot-eval/suite.json +1 -1
  70. package/lab/robot-eval/workspace/AGENTS.md +1 -1
  71. package/lab/schemas/agent-eval-catalog.schema.json +1 -1
  72. package/package.json +18 -13
  73. package/skills/README.md +23 -0
  74. package/skills/core/dingtalk-agent-compose/SKILL.md +151 -0
  75. package/skills/core/dingtalk-agent-compose/assets/AGENTS.template.md +35 -0
  76. package/skills/core/dingtalk-agent-compose/assets/role-skill.template.md +32 -0
  77. package/skills/core/dingtalk-agent-compose/evals/evals.json +129 -0
  78. package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/agent-definition-contract.md +3 -3
  79. package/skills/core/dingtalk-agent-compose/references/host-loading-contract.md +58 -0
  80. package/skills/core/dingtalk-agent-compose/references/hosts/claude-code.md +48 -0
  81. package/skills/core/dingtalk-agent-compose/references/hosts/opencode.md +77 -0
  82. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/SKILL.md +57 -8
  83. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/assets/eval-catalog.template.json +1 -1
  84. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/evals/evals.json +22 -0
  85. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/eval-topology.md +14 -0
  86. package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/evidence-contract.md +21 -0
  87. package/skills/core/dingtalk-agent-eval/references/failure-to-case.md +35 -0
  88. package/skills/core/dingtalk-agent-eval/references/interactive-debug-channels.md +93 -0
  89. package/skills/core/dingtalk-agent-eval/references/local-connector-smoke.md +75 -0
  90. package/skills/core/dingtalk-basic-behavior/SKILL.md +87 -0
  91. package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/memory-and-evolution.md +12 -0
  92. package/skills/core/dingtalk-basic-behavior/references/risk-authority-and-privacy.md +62 -0
  93. package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/task-lifecycle.md +15 -3
  94. package/skills/core/dingtalk-basic-behavior/references/truth-and-recovery.md +65 -0
  95. package/skills/platforms/deap/PLATFORM.md +3 -0
  96. package/skills/platforms/multica-dingtalk/PLATFORM.md +40 -0
  97. package/skills/{dingtalk-agent-boot-multica → platforms/multica-dingtalk/dingtalk-agent-boot-multica}/SKILL.md +4 -4
  98. package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/SKILL.md +1 -1
  99. package/skills/platforms/multica-dingtalk/multica-external/SKILL.md +282 -0
  100. package/skills/platforms/multica-dingtalk/multica-external/scripts/bootstrap.sh +78 -0
  101. package/skills/platforms/multica-dingtalk/multica-external/scripts/multica_ext.py +1180 -0
  102. package/docs/SECOND-AGENT-ACCEPTANCE.md +0 -62
  103. package/docs/architecture/agent-memory-topology.png +0 -0
  104. package/docs/architecture/agent-memory-topology.svg +0 -132
  105. package/docs/architecture/dingtalk-agent-blueprint.png +0 -0
  106. package/docs/architecture/durable-async-agent-runtime.png +0 -0
  107. package/docs/architecture/general-agent-kernel-topology.png +0 -0
  108. package/docs/architecture/provider-bound-development-workspace.png +0 -0
  109. package/docs/architecture/task-completion-gate.png +0 -0
  110. package/skills/dingtalk-agent-compose/SKILL.md +0 -110
  111. package/skills/dingtalk-agent-compose/assets/AGENTS.template.md +0 -26
  112. package/skills/dingtalk-agent-compose/assets/role-skill.template.md +0 -24
  113. package/skills/dingtalk-agent-compose/evals/evals.json +0 -94
  114. package/skills/dingtalk-agent-compose/references/opencode-host-contract.md +0 -65
  115. package/skills/dingtalk-basic-behavior/SKILL.md +0 -146
  116. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.dingtalk-doc.template.json +0 -0
  117. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.local.template.json +0 -0
  118. /package/skills/{dingtalk-agent-compose/assets → core/dingtalk-agent-compose/assets/hosts/opencode}/opencode.template.json +0 -0
  119. /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/storage-routing.md +0 -0
  120. /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/scenario-taxonomy.md +0 -0
  121. /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/storage-modes.md +0 -0
  122. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/memory-candidate-proposal.json +0 -0
  123. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/task-checkpoint.json +0 -0
  124. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/action-contract.md +0 -0
  125. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/event-to-behavior.md +0 -0
  126. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/perception-and-gates.md +0 -0
  127. /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/runtime-modes.md +0 -0
  128. /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/multica-deployment-contract.md +0 -0
  129. /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/promotion-observation-contract.md +0 -0
@@ -0,0 +1,1180 @@
1
+ #!/usr/bin/env python3
2
+ """multica-ext: standalone wrapper around Multica HTTP APIs that the Go CLI
3
+ does not expose (workspace creation/init, FC-E2B runtime provisioning, agent
4
+ git-source inspection, DingTalk robot/account binding, skill pull/push).
5
+
6
+ Stdlib only; Python >= 3.9. Credentials resolve in order: flags > env
7
+ (MULTICA_SERVER_URL, MULTICA_TOKEN, MULTICA_WORKSPACE_ID) > --profile <name>
8
+ (~/.multica/profiles/<name>/config.json) > ~/.multica/config.json.
9
+
10
+ Proxy env vars (HTTP(S)_PROXY etc.) are deliberately ignored so an exported
11
+ shell proxy cannot break calls to intranet/pre endpoints.
12
+ """
13
+
14
+ import argparse
15
+ import json
16
+ import os
17
+ import re
18
+ import sys
19
+ import time
20
+ import urllib.error
21
+ import urllib.parse
22
+ import urllib.request
23
+ from pathlib import Path
24
+ from typing import Any, Dict, List, Optional
25
+
26
+ UUID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", re.I)
27
+
28
+ # Direct connections only: never route API calls through proxy env vars.
29
+ _OPENER = urllib.request.build_opener(urllib.request.ProxyHandler({}))
30
+
31
+
32
+ def note(msg: str) -> None:
33
+ print(msg, file=sys.stderr)
34
+
35
+
36
+ def out(value: Any) -> None:
37
+ print(json.dumps(value, ensure_ascii=False, indent=2))
38
+
39
+
40
+ def fail(msg: str) -> "NoReturn": # type: ignore[name-defined] # noqa: F821
41
+ note("error: " + msg)
42
+ sys.exit(1)
43
+
44
+
45
+ class ApiError(Exception):
46
+ """status == 0 means a network-level failure (no HTTP response)."""
47
+
48
+ def __init__(self, status: int, message: str, body: Any = None):
49
+ super().__init__(message)
50
+ self.status = status
51
+ self.message = message
52
+ self.body = body
53
+
54
+
55
+ # ---------- config / context ----------
56
+
57
+
58
+ class Ctx:
59
+ def __init__(self, server_url: str, token: str, workspace: str):
60
+ self.server_url = server_url
61
+ self.token = token
62
+ self.workspace = workspace
63
+ self.workspace_id = workspace if UUID_RE.match(workspace) else ""
64
+
65
+
66
+ def load_cli_config(profile: Optional[str]) -> Dict[str, str]:
67
+ base = Path.home() / ".multica"
68
+ path = (base / "profiles" / profile / "config.json") if profile else (base / "config.json")
69
+ try:
70
+ raw = json.loads(path.read_text(encoding="utf-8"))
71
+ except (OSError, ValueError):
72
+ if profile:
73
+ fail(f"cannot read profile config at {path}")
74
+ return {}
75
+ if not isinstance(raw, dict):
76
+ if profile:
77
+ fail(f"profile config at {path} is not a JSON object")
78
+ return {}
79
+ return {k: v for k, v in raw.items() if isinstance(v, str)}
80
+
81
+
82
+ def build_ctx(args: argparse.Namespace) -> Ctx:
83
+ profile = getattr(args, "profile", None)
84
+ if profile == "":
85
+ fail("--profile requires a non-empty value")
86
+ cfg = load_cli_config(profile)
87
+ server_url = (
88
+ getattr(args, "server_url", None)
89
+ or os.environ.get("MULTICA_SERVER_URL")
90
+ or cfg.get("server_url")
91
+ or ""
92
+ ).rstrip("/")
93
+ token = getattr(args, "token", None) or os.environ.get("MULTICA_TOKEN") or cfg.get("token") or ""
94
+ workspace = (
95
+ getattr(args, "workspace", None)
96
+ or os.environ.get("MULTICA_WORKSPACE_ID")
97
+ or cfg.get("workspace_id")
98
+ or ""
99
+ )
100
+ if not server_url:
101
+ fail("no server URL (use --server-url, MULTICA_SERVER_URL, or multica CLI config)")
102
+ if not token:
103
+ fail("no token (use --token, MULTICA_TOKEN, or multica CLI config)")
104
+ return Ctx(server_url, token, workspace)
105
+
106
+
107
+ # ---------- http ----------
108
+
109
+
110
+ def api(
111
+ ctx: Ctx,
112
+ method: str,
113
+ path: str,
114
+ body: Any = None,
115
+ ws_header: bool = False,
116
+ timeout: int = 60,
117
+ ) -> Any:
118
+ headers = {
119
+ "Authorization": f"Bearer {ctx.token}",
120
+ "X-Client-Platform": "cli",
121
+ }
122
+ data = None
123
+ if body is not None:
124
+ data = json.dumps(body).encode("utf-8")
125
+ headers["Content-Type"] = "application/json"
126
+ if ws_header:
127
+ headers["X-Workspace-ID"] = resolve_workspace_id(ctx)
128
+ req = urllib.request.Request(ctx.server_url + path, data=data, headers=headers, method=method)
129
+ try:
130
+ with _OPENER.open(req, timeout=timeout) as resp:
131
+ status = resp.status
132
+ text = resp.read().decode("utf-8", errors="replace")
133
+ except urllib.error.HTTPError as e:
134
+ text = e.read().decode("utf-8", errors="replace")
135
+ parsed: Any
136
+ try:
137
+ parsed = json.loads(text) if text else None
138
+ except ValueError:
139
+ parsed = text
140
+ msg = (
141
+ parsed["error"]
142
+ if isinstance(parsed, dict) and isinstance(parsed.get("error"), str)
143
+ else f"{e.code} {e.reason}"
144
+ )
145
+ raise ApiError(e.code, msg, parsed) from None
146
+ except urllib.error.URLError as e:
147
+ raise ApiError(0, f"network error: {e.reason}") from None
148
+ if status == 204 or not text:
149
+ return None
150
+ try:
151
+ return json.loads(text)
152
+ except ValueError:
153
+ return text
154
+
155
+
156
+ def resolve_workspace_id(ctx: Ctx) -> str:
157
+ if ctx.workspace_id:
158
+ return ctx.workspace_id
159
+ if not ctx.workspace:
160
+ fail("no workspace (use --workspace <slug|uuid>, MULTICA_WORKSPACE_ID, or CLI config)")
161
+ workspaces = api(ctx, "GET", "/api/workspaces")
162
+ hit = next(
163
+ (w for w in workspaces if isinstance(w, dict) and w.get("slug") == ctx.workspace),
164
+ None,
165
+ ) if isinstance(workspaces, list) else None
166
+ if not hit or not hit.get("id"):
167
+ known = ", ".join(
168
+ w.get("slug", "") for w in workspaces if isinstance(w, dict)
169
+ ) if isinstance(workspaces, list) else ""
170
+ fail(f'workspace "{ctx.workspace}" not found among your workspaces ({known or "none"})')
171
+ ctx.workspace_id = hit["id"]
172
+ return ctx.workspace_id
173
+
174
+
175
+ # ---------- helpers ----------
176
+
177
+
178
+ def read_secret(args: argparse.Namespace) -> str:
179
+ secret = getattr(args, "client_secret", None)
180
+ env_var = getattr(args, "client_secret_env", None)
181
+ if not secret and env_var:
182
+ secret = os.environ.get(env_var)
183
+ if not secret and getattr(args, "client_secret_stdin", False):
184
+ secret = sys.stdin.read().strip()
185
+ if not secret:
186
+ fail("provide --client-secret, --client-secret-stdin, or --client-secret-env VAR")
187
+ return secret
188
+
189
+
190
+ def parse_frontmatter(text: str) -> Dict[str, str]:
191
+ """Minimal YAML-frontmatter reader: top-level `key: value` lines only."""
192
+ if not text.startswith("---\n"):
193
+ return {}
194
+ end = text.find("\n---", 4)
195
+ if end == -1:
196
+ return {}
197
+ meta: Dict[str, str] = {}
198
+ for line in text[4:end].splitlines():
199
+ if ":" in line and line[:1] not in (" ", "\t", "#"):
200
+ key, _, value = line.partition(":")
201
+ meta[key.strip()] = value.strip()
202
+ return meta
203
+
204
+
205
+ def safe_rel_path(rel: str) -> bool:
206
+ parts = Path(rel).parts
207
+ return bool(parts) and not Path(rel).is_absolute() and ".." not in parts
208
+
209
+
210
+ def resolve_skill_id(ctx: Ctx, ref: str) -> str:
211
+ if UUID_RE.match(ref):
212
+ return ref
213
+ skills = api(ctx, "GET", "/api/skills", ws_header=True)
214
+ hits = [s for s in skills if isinstance(s, dict) and s.get("name") == ref] if isinstance(skills, list) else []
215
+ if len(hits) == 1 and hits[0].get("id"):
216
+ return hits[0]["id"]
217
+ if len(hits) > 1:
218
+ fail(f'multiple skills named "{ref}"; pass the skill id')
219
+ known = ", ".join(s.get("name", "") for s in skills if isinstance(s, dict)) if isinstance(skills, list) else ""
220
+ fail(f'skill "{ref}" not found (workspace has: {known or "none"})')
221
+
222
+
223
+ # ---------- commands ----------
224
+
225
+
226
+ def cmd_whoami(ctx: Ctx, args: argparse.Namespace) -> None:
227
+ out(api(ctx, "GET", "/api/me"))
228
+
229
+
230
+ def cmd_workspace_list(ctx: Ctx, args: argparse.Namespace) -> None:
231
+ out(api(ctx, "GET", "/api/workspaces"))
232
+
233
+
234
+ def cmd_workspace_create(ctx: Ctx, args: argparse.Namespace) -> None:
235
+ body: Dict[str, Any] = {"name": args.name, "slug": args.slug}
236
+ if args.description:
237
+ body["description"] = args.description
238
+ if args.issue_prefix:
239
+ body["issue_prefix"] = args.issue_prefix
240
+ out(api(ctx, "POST", "/api/workspaces", body=body))
241
+
242
+
243
+ def cmd_workspace_init(ctx: Ctx, args: argparse.Namespace) -> None:
244
+ created = False
245
+ try:
246
+ body: Dict[str, Any] = {"name": args.name, "slug": args.slug}
247
+ if args.description:
248
+ body["description"] = args.description
249
+ ws = api(ctx, "POST", "/api/workspaces", body=body)
250
+ created = True
251
+ note(f"created workspace {args.slug} ({ws['id']})")
252
+ except ApiError as e:
253
+ if e.status != 409:
254
+ raise
255
+ workspaces = api(ctx, "GET", "/api/workspaces")
256
+ hit = next((w for w in workspaces if w.get("slug") == args.slug), None)
257
+ if not hit:
258
+ fail(f'slug "{args.slug}" is taken by a workspace you are not a member of')
259
+ ws = api(ctx, "GET", f"/api/workspaces/{hit['id']}")
260
+ note(f"reusing existing workspace {args.slug} ({hit['id']})")
261
+ ws_id = ws["id"]
262
+ repo_urls: List[str] = args.repo or []
263
+ if repo_urls:
264
+ existing = ws.get("repos") if isinstance(ws.get("repos"), list) else []
265
+ merged = list(existing)
266
+ for url in repo_urls:
267
+ if not any(r.get("url") == url for r in merged if isinstance(r, dict)):
268
+ merged.append({"url": url})
269
+ added = len(merged) - len(existing)
270
+ if added > 0:
271
+ ws = api(ctx, "PUT", f"/api/workspaces/{ws_id}", body={"repos": merged})
272
+ note(f"registered {added} new repo(s)")
273
+ else:
274
+ note("all repos already registered")
275
+ if args.complete_onboarding:
276
+ api(ctx, "POST", "/api/me/onboarding/complete", body={"workspace_id": ws_id})
277
+ note("onboarding completed")
278
+ out({"created": created, "workspace": ws})
279
+
280
+
281
+ def cmd_workspace_repos(ctx: Ctx, args: argparse.Namespace) -> None:
282
+ ws_id = resolve_workspace_id(ctx)
283
+ ws = api(ctx, "GET", f"/api/workspaces/{ws_id}")
284
+ if not args.add:
285
+ out({"workspace_id": ws_id, "repos": ws.get("repos") or []})
286
+ return
287
+ repos = ws.get("repos") if isinstance(ws.get("repos"), list) else []
288
+ if any(isinstance(r, dict) and r.get("url") == args.add for r in repos):
289
+ note("repo already registered")
290
+ out({"workspace_id": ws_id, "repos": repos})
291
+ return
292
+ entry: Dict[str, Any] = {"url": args.add}
293
+ if args.description:
294
+ entry["description"] = args.description
295
+ updated = api(ctx, "PUT", f"/api/workspaces/{ws_id}", body={"repos": repos + [entry]})
296
+ out({"workspace_id": ws_id, "repos": updated.get("repos") or []})
297
+
298
+
299
+ def cmd_runtime_list(ctx: Ctx, args: argparse.Namespace) -> None:
300
+ out(api(ctx, "GET", "/api/runtimes", ws_header=True))
301
+
302
+
303
+ def cmd_runtime_templates(ctx: Ctx, args: argparse.Namespace) -> None:
304
+ out(api(ctx, "GET", "/api/runtimes/fc-e2b/templates", ws_header=True))
305
+
306
+
307
+ def cmd_runtime_create_fc(ctx: Ctx, args: argparse.Namespace) -> None:
308
+ template_id = args.template_id
309
+ if not template_id:
310
+ templates = api(ctx, "GET", "/api/runtimes/fc-e2b/templates", ws_header=True)
311
+ if not isinstance(templates, list) or not templates:
312
+ fail("no FC-E2B templates available")
313
+ # The server resolves template_id against id, template, or name;
314
+ # id is best-effort and may be absent.
315
+ ref = lambda t: t.get("id") or t.get("template") or t.get("name") # noqa: E731
316
+ if len(templates) > 1:
317
+ fail(
318
+ "multiple templates available, pass --template-id: "
319
+ + ", ".join(f"{ref(t)} ({t.get('name') or t.get('template')})" for t in templates)
320
+ )
321
+ template_id = ref(templates[0])
322
+ if not template_id:
323
+ fail("template list entry has no usable identifier")
324
+ note(f"using template {template_id} ({templates[0].get('name') or templates[0].get('template')})")
325
+ body: Dict[str, Any] = {"template_id": template_id}
326
+ for flag in ("name", "provider", "visibility"):
327
+ value = getattr(args, flag, None)
328
+ if value:
329
+ body[flag] = value
330
+ out(api(ctx, "POST", "/api/runtimes/fc-e2b", body=body, ws_header=True))
331
+
332
+
333
+ def cmd_agent_list(ctx: Ctx, args: argparse.Namespace) -> None:
334
+ query = "?include_archived=true" if args.include_archived else ""
335
+ out(api(ctx, "GET", f"/api/agents{query}", ws_header=True))
336
+
337
+
338
+ def cmd_agent_get(ctx: Ctx, args: argparse.Namespace) -> None:
339
+ out(api(ctx, "GET", f"/api/agents/{args.agent}", ws_header=True))
340
+
341
+
342
+ def cmd_agent_create(ctx: Ctx, args: argparse.Namespace) -> None:
343
+ runtime_id = args.runtime_id
344
+ if not runtime_id:
345
+ runtimes = api(ctx, "GET", "/api/runtimes", ws_header=True)
346
+ if not isinstance(runtimes, list) or not runtimes:
347
+ fail("no runtimes in workspace; create one first (runtime-create-fc) or start a local daemon")
348
+ if len(runtimes) > 1:
349
+ fail(
350
+ "multiple runtimes, pass --runtime-id: "
351
+ + ", ".join(f"{r.get('id')} ({r.get('name')}, {r.get('status')})" for r in runtimes)
352
+ )
353
+ runtime_id = runtimes[0].get("id")
354
+ if not runtime_id:
355
+ fail("runtime list entry has no id")
356
+ note(f"using runtime {runtime_id} ({runtimes[0].get('name')})")
357
+ body: Dict[str, Any] = {"name": args.name, "runtime_id": runtime_id}
358
+ for flag, field in (
359
+ ("description", "description"),
360
+ ("instructions", "instructions"),
361
+ ("model", "model"),
362
+ ("thinking_level", "thinking_level"),
363
+ ):
364
+ value = getattr(args, flag, None)
365
+ if value:
366
+ body[field] = value
367
+ out(api(ctx, "POST", "/api/agents", body=body, ws_header=True))
368
+
369
+
370
+ def cmd_agent_source(ctx: Ctx, args: argparse.Namespace) -> None:
371
+ try:
372
+ out(api(ctx, "GET", f"/api/agents/{args.agent}/source", ws_header=True))
373
+ except ApiError as e:
374
+ # 404 "agent source not found" = agent exists but is not git-backed.
375
+ # 404 "agent not found" = bad agent id; must stay an error.
376
+ if e.status == 404 and "source" in e.message:
377
+ out({"agent_id": args.agent, "source": None})
378
+ return
379
+ raise
380
+
381
+
382
+ def cmd_agent_source_sync(ctx: Ctx, args: argparse.Namespace) -> None:
383
+ out(api(ctx, "POST", f"/api/agents/{args.agent}/source/sync", ws_header=True))
384
+
385
+
386
+ def cmd_dingtalk_list(ctx: Ctx, args: argparse.Namespace) -> None:
387
+ ws_id = resolve_workspace_id(ctx)
388
+ out(api(ctx, "GET", f"/api/workspaces/{ws_id}/dingtalk/installations"))
389
+
390
+
391
+ def cmd_dingtalk_bind(ctx: Ctx, args: argparse.Namespace) -> None:
392
+ ws_id = resolve_workspace_id(ctx)
393
+ body = {
394
+ "agent_id": args.agent,
395
+ "client_id": args.client_id,
396
+ "client_secret": read_secret(args),
397
+ "robot_code": args.robot_code or args.client_id,
398
+ "allow_unbound": args.allow_unbound,
399
+ }
400
+ out(api(ctx, "POST", f"/api/workspaces/{ws_id}/dingtalk/install/manual", body=body))
401
+
402
+
403
+ def cmd_dingtalk_begin(ctx: Ctx, args: argparse.Namespace) -> None:
404
+ ws_id = resolve_workspace_id(ctx)
405
+ allow = "true" if args.allow_unbound else "false"
406
+ session = api(
407
+ ctx,
408
+ "POST",
409
+ f"/api/workspaces/{ws_id}/dingtalk/install/begin?agent_id={args.agent}&allow_unbound={allow}",
410
+ )
411
+ note(f"scan to install: {session.get('qr_code_url')}")
412
+ if not args.wait:
413
+ out(session)
414
+ return
415
+ interval = max(1, session.get("poll_interval_seconds") or 3)
416
+ deadline = time.monotonic() + (session.get("expires_in_seconds") or 300)
417
+ while time.monotonic() < deadline:
418
+ time.sleep(interval)
419
+ try:
420
+ status = api(
421
+ ctx,
422
+ "GET",
423
+ f"/api/workspaces/{ws_id}/dingtalk/install/{session.get('session_id')}/status",
424
+ timeout=30,
425
+ )
426
+ except ApiError as e:
427
+ if e.status == 404:
428
+ fail("install session no longer exists on the server")
429
+ note(f"poll failed ({'API ' + str(e.status) if e.status else 'network'}), retrying...")
430
+ continue
431
+ note(f"status: {status.get('status')}")
432
+ if status.get("status") == "success":
433
+ out(status)
434
+ return
435
+ if status.get("status") == "error":
436
+ out(status)
437
+ sys.exit(1)
438
+ fail("install session expired before completion")
439
+
440
+
441
+ def cmd_dingtalk_revoke(ctx: Ctx, args: argparse.Namespace) -> None:
442
+ ws_id = resolve_workspace_id(ctx)
443
+ api(ctx, "DELETE", f"/api/workspaces/{ws_id}/dingtalk/installations/{args.installation}")
444
+ out({"revoked": True})
445
+
446
+
447
+ def cmd_dingtalk_account_begin(ctx: Ctx, args: argparse.Namespace) -> None:
448
+ ws_id = resolve_workspace_id(ctx)
449
+ res = api(
450
+ ctx,
451
+ "POST",
452
+ f"/api/workspaces/{ws_id}/dingtalk/account-bindings/begin",
453
+ body={"agent_id": args.agent},
454
+ )
455
+ note(f"scan to bind account: {res.get('qr_code_url')}")
456
+ out(res)
457
+
458
+
459
+ def cmd_dingtalk_account_list(ctx: Ctx, args: argparse.Namespace) -> None:
460
+ ws_id = resolve_workspace_id(ctx)
461
+ out(api(ctx, "GET", f"/api/workspaces/{ws_id}/dingtalk/account-bindings"))
462
+
463
+
464
+ def cmd_dingtalk_account_unbind(ctx: Ctx, args: argparse.Namespace) -> None:
465
+ ws_id = resolve_workspace_id(ctx)
466
+ api(ctx, "DELETE", f"/api/workspaces/{ws_id}/dingtalk/account-bindings/{args.installation}")
467
+ out({"unbound": True})
468
+
469
+
470
+ def cmd_agent_tasks(ctx: Ctx, args: argparse.Namespace) -> None:
471
+ tasks = api(ctx, "GET", f"/api/agents/{args.agent}/tasks", ws_header=True)
472
+ if isinstance(tasks, list) and args.limit:
473
+ tasks = tasks[: args.limit]
474
+ out(tasks)
475
+
476
+
477
+ def _trace_line(m: Dict[str, Any]) -> str:
478
+ kind = m.get("type") or "?"
479
+ tool = f" {m.get('tool')}" if m.get("tool") else ""
480
+ body = m.get("content") or m.get("output") or ""
481
+ if kind == "tool_use" and m.get("input") is not None:
482
+ body = json.dumps(m.get("input"), ensure_ascii=False)
483
+ body = " ".join(str(body).split())
484
+ if len(body) > 200:
485
+ body = body[:200] + "…"
486
+ return f"[{m.get('seq')}] {kind}{tool}: {body}"
487
+
488
+
489
+ def cmd_task_trace(ctx: Ctx, args: argparse.Namespace) -> None:
490
+ """Observe an agent's step-by-step execution trace (text / tool_use / tool_result)."""
491
+ since = args.since or 0
492
+ emit = note if args.text else None
493
+
494
+ def fetch(cursor: int) -> List[Dict[str, Any]]:
495
+ msgs = api(ctx, "GET", f"/api/tasks/{args.task}/messages?since={cursor}", ws_header=True)
496
+ return msgs if isinstance(msgs, list) else []
497
+
498
+ if not args.follow:
499
+ msgs = fetch(since)
500
+ if args.text:
501
+ for m in msgs:
502
+ print(_trace_line(m))
503
+ else:
504
+ out(msgs)
505
+ return
506
+ idle = 0
507
+ cursor = since
508
+ while True:
509
+ msgs = fetch(cursor)
510
+ for m in msgs:
511
+ cursor = max(cursor, m.get("seq") or 0)
512
+ print(_trace_line(m) if args.text else json.dumps(m, ensure_ascii=False), flush=True)
513
+ if msgs:
514
+ idle = 0
515
+ else:
516
+ idle += 1
517
+ if idle >= 5:
518
+ snapshot = api(ctx, "GET", "/api/agent-task-snapshot", ws_header=True)
519
+ active = {
520
+ t.get("id")
521
+ for t in (snapshot if isinstance(snapshot, list) else [])
522
+ if t.get("status") in ("queued", "dispatched", "running", "waiting_local_directory")
523
+ }
524
+ if args.task not in active:
525
+ note("task is no longer active; trace complete")
526
+ return
527
+ idle = 0
528
+ time.sleep(2)
529
+
530
+
531
+ def cmd_issue_tasks(ctx: Ctx, args: argparse.Namespace) -> None:
532
+ out(api(ctx, "GET", f"/api/issues/{args.issue}/task-runs", ws_header=True))
533
+
534
+
535
+ def cmd_issue_timeline(ctx: Ctx, args: argparse.Namespace) -> None:
536
+ out(api(ctx, "GET", f"/api/issues/{args.issue}/timeline", ws_header=True))
537
+
538
+
539
+ def cmd_issue_usage(ctx: Ctx, args: argparse.Namespace) -> None:
540
+ out(api(ctx, "GET", f"/api/issues/{args.issue}/usage", ws_header=True))
541
+
542
+
543
+ def cmd_task_snapshot(ctx: Ctx, args: argparse.Namespace) -> None:
544
+ out(api(ctx, "GET", "/api/agent-task-snapshot", ws_header=True))
545
+
546
+
547
+ def cmd_autopilot_list(ctx: Ctx, args: argparse.Namespace) -> None:
548
+ query = f"?status={args.status}" if args.status else ""
549
+ out(api(ctx, "GET", f"/api/autopilots{query}", ws_header=True))
550
+
551
+
552
+ def cmd_autopilot_get(ctx: Ctx, args: argparse.Namespace) -> None:
553
+ out(api(ctx, "GET", f"/api/autopilots/{args.autopilot}", ws_header=True))
554
+
555
+
556
+ def cmd_autopilot_create(ctx: Ctx, args: argparse.Namespace) -> None:
557
+ body: Dict[str, Any] = {
558
+ "title": args.title,
559
+ "assignee_id": args.agent,
560
+ "execution_mode": args.mode,
561
+ }
562
+ if args.description:
563
+ body["description"] = args.description
564
+ if args.issue_title_template:
565
+ body["issue_title_template"] = args.issue_title_template
566
+ autopilot = api(ctx, "POST", "/api/autopilots", body=body, ws_header=True)
567
+ result: Dict[str, Any] = {"autopilot": autopilot}
568
+ if args.cron:
569
+ trigger_body = {"kind": "schedule", "cron_expression": args.cron, "timezone": args.timezone}
570
+ trigger = api(
571
+ ctx, "POST", f"/api/autopilots/{autopilot['id']}/triggers", body=trigger_body, ws_header=True
572
+ )
573
+ result["trigger"] = trigger
574
+ note(f"scheduled: {args.cron} ({args.timezone}), next run {trigger.get('next_run_at')}")
575
+ out(result)
576
+
577
+
578
+ def cmd_autopilot_trigger(ctx: Ctx, args: argparse.Namespace) -> None:
579
+ out(api(ctx, "POST", f"/api/autopilots/{args.autopilot}/trigger", ws_header=True))
580
+
581
+
582
+ def cmd_autopilot_runs(ctx: Ctx, args: argparse.Namespace) -> None:
583
+ out(api(ctx, "GET", f"/api/autopilots/{args.autopilot}/runs?limit={args.limit}", ws_header=True))
584
+
585
+
586
+ def cmd_autopilot_set_status(ctx: Ctx, args: argparse.Namespace) -> None:
587
+ out(api(ctx, "PATCH", f"/api/autopilots/{args.autopilot}", body={"status": args.status}, ws_header=True))
588
+
589
+
590
+ def _parse_rfc3339(value: Optional[str]) -> Optional[float]:
591
+ if not value:
592
+ return None
593
+ try:
594
+ import datetime
595
+
596
+ return datetime.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp()
597
+ except ValueError:
598
+ return None
599
+
600
+
601
+ def cmd_agent_check(ctx: Ctx, args: argparse.Namespace) -> None:
602
+ """Doctor-style composite report: agent + runtime + tasks + DingTalk bindings."""
603
+ checks: List[Dict[str, str]] = []
604
+
605
+ def check(name: str, status: str, detail: str) -> None:
606
+ checks.append({"check": name, "status": status, "detail": detail})
607
+
608
+ agent = api(ctx, "GET", f"/api/agents/{args.agent}", ws_header=True)
609
+ if agent.get("archived_at"):
610
+ check("agent", "fail", f"archived at {agent['archived_at']}")
611
+ else:
612
+ check("agent", "ok", f"status={agent.get('status')}")
613
+
614
+ runtimes = api(ctx, "GET", "/api/runtimes", ws_header=True)
615
+ runtime = next(
616
+ (r for r in runtimes if r.get("id") == agent.get("runtime_id")),
617
+ None,
618
+ ) if isinstance(runtimes, list) else None
619
+ runtime_health = "missing"
620
+ if runtime:
621
+ last_seen = _parse_rfc3339(runtime.get("last_seen_at"))
622
+ if runtime.get("status") == "online":
623
+ runtime_health = "online"
624
+ elif last_seen and time.time() - last_seen < 300:
625
+ runtime_health = "unstable"
626
+ else:
627
+ runtime_health = "offline"
628
+ check(
629
+ "runtime",
630
+ {"online": "ok", "unstable": "warn"}.get(runtime_health, "fail"),
631
+ f"{runtime_health}"
632
+ + (f" (last_seen_at={runtime.get('last_seen_at')}, provider={runtime.get('provider')})" if runtime else ""),
633
+ )
634
+
635
+ snapshot = api(ctx, "GET", "/api/agent-task-snapshot", ws_header=True)
636
+ mine = [t for t in (snapshot if isinstance(snapshot, list) else []) if t.get("agent_id") == args.agent]
637
+ active = [t for t in mine if t.get("status") in ("queued", "dispatched", "running", "waiting_local_directory")]
638
+ outcomes = [t for t in mine if t.get("status") in ("completed", "failed")]
639
+ last_outcome = outcomes[0] if outcomes else None
640
+ if last_outcome and last_outcome.get("status") == "failed":
641
+ check(
642
+ "last_task",
643
+ "warn",
644
+ f"failed ({last_outcome.get('failure_reason')}): {str(last_outcome.get('error'))[:120]}",
645
+ )
646
+ elif last_outcome:
647
+ check("last_task", "ok", "completed")
648
+ else:
649
+ check("last_task", "warn", "no terminal task yet")
650
+
651
+ tasks = api(ctx, "GET", f"/api/agents/{args.agent}/tasks", ws_header=True)
652
+ recent = tasks[:10] if isinstance(tasks, list) else []
653
+ failed_recent = [t for t in recent if t.get("status") == "failed"]
654
+ check(
655
+ "recent_tasks",
656
+ "warn" if len(failed_recent) >= 3 else "ok",
657
+ f"{len(recent)} recent, {len(failed_recent)} failed",
658
+ )
659
+
660
+ ws_id = resolve_workspace_id(ctx)
661
+ robots = api(ctx, "GET", f"/api/workspaces/{ws_id}/dingtalk/installations")
662
+ robot = next(
663
+ (i for i in (robots.get("installations") or []) if i.get("agent_id") == args.agent and i.get("status") != "revoked"),
664
+ None,
665
+ ) if isinstance(robots, dict) else None
666
+ check("dingtalk_robot", "ok" if robot else "warn", f"client_id={robot.get('client_id')}" if robot else "no robot bound")
667
+ accounts = api(ctx, "GET", f"/api/workspaces/{ws_id}/dingtalk/account-bindings")
668
+ account = next(
669
+ (b for b in (accounts.get("bindings") or []) if b.get("agent_id") == args.agent),
670
+ None,
671
+ ) if isinstance(accounts, dict) else None
672
+ check(
673
+ "dingtalk_account",
674
+ "ok" if account and (account.get("dws_identity") or {}).get("status") == "bound" else "warn",
675
+ json.dumps(account.get("dws_identity"), ensure_ascii=False) if account else "no account binding",
676
+ )
677
+
678
+ out({
679
+ "agent": {k: agent.get(k) for k in ("id", "name", "status", "model", "runtime_id", "archived_at")},
680
+ "runtime": {
681
+ "health": runtime_health,
682
+ **({k: runtime.get(k) for k in ("id", "name", "provider", "status", "last_seen_at")} if runtime else {}),
683
+ },
684
+ "tasks": {"active": len(active), "last_outcome_status": (last_outcome or {}).get("status")},
685
+ "checks": checks,
686
+ "healthy": all(c["status"] != "fail" for c in checks),
687
+ "warnings": sum(1 for c in checks if c["status"] == "warn"),
688
+ })
689
+
690
+
691
+ def cmd_issue_create(ctx: Ctx, args: argparse.Namespace) -> None:
692
+ body: Dict[str, Any] = {"title": args.title}
693
+ if args.description:
694
+ body["description"] = args.description
695
+ if args.status:
696
+ body["status"] = args.status
697
+ if args.priority:
698
+ body["priority"] = args.priority
699
+ if args.assignee_agent:
700
+ body["assignee_type"] = "agent"
701
+ body["assignee_id"] = args.assignee_agent
702
+ out(api(ctx, "POST", "/api/issues", body=body, ws_header=True))
703
+
704
+
705
+ def cmd_issue_get(ctx: Ctx, args: argparse.Namespace) -> None:
706
+ out(api(ctx, "GET", f"/api/issues/{args.issue}", ws_header=True))
707
+
708
+
709
+ def cmd_issue_list(ctx: Ctx, args: argparse.Namespace) -> None:
710
+ params = []
711
+ if args.assignee:
712
+ params.append(f"assignee_id={args.assignee}")
713
+ if args.creator:
714
+ params.append(f"creator_id={args.creator}")
715
+ if args.project:
716
+ params.append(f"project_id={args.project}")
717
+ if args.priority:
718
+ params.append(f"priority={args.priority}")
719
+ if args.open_only:
720
+ params.append("open_only=true")
721
+ query = ("?" + "&".join(params)) if params else ""
722
+ issues = api(ctx, "GET", f"/api/issues{query}", ws_header=True)
723
+ if isinstance(issues, list) and args.limit:
724
+ issues = issues[: args.limit]
725
+ out(issues)
726
+
727
+
728
+ def cmd_issue_update(ctx: Ctx, args: argparse.Namespace) -> None:
729
+ body: Dict[str, Any] = {}
730
+ for flag in ("title", "description", "status", "priority"):
731
+ value = getattr(args, flag, None)
732
+ if value:
733
+ body[flag] = value
734
+ if args.assignee_agent:
735
+ body["assignee_type"] = "agent"
736
+ body["assignee_id"] = args.assignee_agent
737
+ if not body:
738
+ fail("nothing to update (pass --title/--description/--status/--priority/--assignee-agent)")
739
+ out(api(ctx, "PUT", f"/api/issues/{args.issue}", body=body, ws_header=True))
740
+
741
+
742
+ def cmd_comment_add(ctx: Ctx, args: argparse.Namespace) -> None:
743
+ body: Dict[str, Any] = {"content": args.content}
744
+ if args.parent:
745
+ body["parent_id"] = args.parent
746
+ out(api(ctx, "POST", f"/api/issues/{args.issue}/comments", body=body, ws_header=True))
747
+
748
+
749
+ def cmd_comment_list(ctx: Ctx, args: argparse.Namespace) -> None:
750
+ query = f"?recent={args.recent}" if args.recent else ""
751
+ out(api(ctx, "GET", f"/api/issues/{args.issue}/comments{query}", ws_header=True))
752
+
753
+
754
+ def cmd_chat_send(ctx: Ctx, args: argparse.Namespace) -> None:
755
+ """Talk to an agent directly — the no-DingTalk end-to-end test path."""
756
+ session_id = args.session
757
+ if not session_id:
758
+ if not args.agent:
759
+ fail("--agent is required when no --session is given")
760
+ title = " ".join(args.content.split())[:30] or "external chat"
761
+ session = api(
762
+ ctx, "POST", "/api/chat/sessions", body={"agent_id": args.agent, "title": title}, ws_header=True
763
+ )
764
+ session_id = session["id"]
765
+ note(f"created chat session {session_id}")
766
+ sent = api(ctx, "POST", f"/api/chat/sessions/{session_id}/messages", body={"content": args.content}, ws_header=True)
767
+ if not args.wait:
768
+ out({"session_id": session_id, "message_id": sent.get("message_id"), "task_id": sent.get("task_id")})
769
+ return
770
+ deadline = time.monotonic() + args.timeout
771
+ while time.monotonic() < deadline:
772
+ time.sleep(2)
773
+ pending = api(ctx, "GET", f"/api/chat/sessions/{session_id}/pending-task", ws_header=True)
774
+ if not isinstance(pending, dict) or not pending.get("task_id"):
775
+ break
776
+ note(f"agent working... ({pending.get('status')})")
777
+ else:
778
+ note("timed out waiting; fetching whatever arrived")
779
+ messages = api(ctx, "GET", f"/api/chat/sessions/{session_id}/messages", ws_header=True)
780
+ reply = next(
781
+ (m for m in reversed(messages) if m.get("role") == "assistant"),
782
+ None,
783
+ ) if isinstance(messages, list) else None
784
+ out({"session_id": session_id, "task_id": sent.get("task_id"), "reply": reply})
785
+
786
+
787
+ def cmd_member_list(ctx: Ctx, args: argparse.Namespace) -> None:
788
+ ws_id = resolve_workspace_id(ctx)
789
+ out(api(ctx, "GET", f"/api/workspaces/{ws_id}/members"))
790
+
791
+
792
+ def cmd_member_invite(ctx: Ctx, args: argparse.Namespace) -> None:
793
+ ws_id = resolve_workspace_id(ctx)
794
+ out(api(ctx, "POST", f"/api/workspaces/{ws_id}/members", body={"email": args.email, "role": args.role}))
795
+
796
+
797
+ def cmd_token_create(ctx: Ctx, args: argparse.Namespace) -> None:
798
+ body: Dict[str, Any] = {"name": args.name}
799
+ if args.expires_days:
800
+ body["expires_in_days"] = args.expires_days
801
+ result = api(ctx, "POST", "/api/tokens", body=body)
802
+ note("the plaintext token below is shown ONCE — store it now")
803
+ out(result)
804
+
805
+
806
+ def cmd_skill_list(ctx: Ctx, args: argparse.Namespace) -> None:
807
+ out(api(ctx, "GET", "/api/skills", ws_header=True))
808
+
809
+
810
+ def cmd_skill_get(ctx: Ctx, args: argparse.Namespace) -> None:
811
+ skill_id = resolve_skill_id(ctx, args.skill)
812
+ out(api(ctx, "GET", f"/api/skills/{skill_id}", ws_header=True))
813
+
814
+
815
+ def cmd_skill_pull(ctx: Ctx, args: argparse.Namespace) -> None:
816
+ """Downlink: materialize a workspace skill into a local directory."""
817
+ skill_id = resolve_skill_id(ctx, args.skill)
818
+ skill = api(ctx, "GET", f"/api/skills/{skill_id}", ws_header=True)
819
+ dest = Path(args.dest or skill.get("name") or skill_id)
820
+ if dest.exists() and any(dest.iterdir()) and not args.force:
821
+ fail(f"{dest} exists and is not empty (use --force to overwrite files in place)")
822
+ dest.mkdir(parents=True, exist_ok=True)
823
+ written: List[str] = []
824
+ content = skill.get("content") or ""
825
+ (dest / "SKILL.md").write_text(content, encoding="utf-8")
826
+ written.append("SKILL.md")
827
+ for f in skill.get("files") or []:
828
+ rel = f.get("path") or ""
829
+ if not safe_rel_path(rel) or rel == "SKILL.md":
830
+ note(f"skipping unsafe or reserved path: {rel!r}")
831
+ continue
832
+ target = dest / rel
833
+ target.parent.mkdir(parents=True, exist_ok=True)
834
+ target.write_text(f.get("content") or "", encoding="utf-8")
835
+ written.append(rel)
836
+ out({
837
+ "id": skill.get("id"),
838
+ "name": skill.get("name"),
839
+ "description": skill.get("description"),
840
+ "dest": str(dest),
841
+ "files_written": written,
842
+ })
843
+
844
+
845
+ def cmd_skill_push(ctx: Ctx, args: argparse.Namespace) -> None:
846
+ """Uplink: create or update a workspace skill from a local directory."""
847
+ src = Path(args.dir)
848
+ skill_md = src / "SKILL.md"
849
+ if not skill_md.is_file():
850
+ fail(f"{skill_md} not found (a skill directory must contain SKILL.md)")
851
+ content = skill_md.read_text(encoding="utf-8")
852
+ meta = parse_frontmatter(content)
853
+ name = args.name or meta.get("name") or src.resolve().name
854
+ description = args.description or meta.get("description") or ""
855
+ files: List[Dict[str, str]] = []
856
+ for path in sorted(src.rglob("*")):
857
+ if not path.is_file():
858
+ continue
859
+ rel = path.relative_to(src).as_posix()
860
+ if rel == "SKILL.md" or any(part.startswith(".") for part in Path(rel).parts):
861
+ continue
862
+ try:
863
+ files.append({"path": rel, "content": path.read_text(encoding="utf-8")})
864
+ except (UnicodeDecodeError, OSError):
865
+ note(f"skipping non-text file: {rel}")
866
+ existing = api(ctx, "GET", "/api/skills", ws_header=True)
867
+ hit = next(
868
+ (s for s in existing if isinstance(s, dict) and s.get("name") == name),
869
+ None,
870
+ ) if isinstance(existing, list) else None
871
+ if hit:
872
+ if args.on_conflict == "fail":
873
+ fail(f'skill "{name}" already exists ({hit.get("id")}); use --on-conflict overwrite')
874
+ body = {"description": description, "content": content, "files": files}
875
+ result = api(ctx, "PUT", f"/api/skills/{hit['id']}", body=body, ws_header=True)
876
+ out({"action": "updated", "skill": result})
877
+ else:
878
+ body = {"name": name, "description": description, "content": content, "files": files}
879
+ result = api(ctx, "POST", "/api/skills", body=body, ws_header=True)
880
+ out({"action": "created", "skill": result})
881
+
882
+
883
+ # ---------- parser ----------
884
+
885
+
886
+ def build_parser() -> argparse.ArgumentParser:
887
+ def add_globals(p: argparse.ArgumentParser, suppress: bool) -> None:
888
+ default = argparse.SUPPRESS if suppress else None
889
+ p.add_argument("--profile", default=default, help="use ~/.multica/profiles/<name>/config.json")
890
+ p.add_argument("--server-url", dest="server_url", default=default, help="override server URL (env MULTICA_SERVER_URL)")
891
+ p.add_argument("--token", default=default, help="override token (env MULTICA_TOKEN)")
892
+ p.add_argument("--workspace", default=default, help="workspace slug or uuid (env MULTICA_WORKSPACE_ID)")
893
+
894
+ parser = argparse.ArgumentParser(
895
+ prog="multica_ext.py",
896
+ description="External wrapper for Multica APIs not covered by the Go CLI",
897
+ )
898
+ add_globals(parser, suppress=False)
899
+ common = argparse.ArgumentParser(add_help=False)
900
+ add_globals(common, suppress=True)
901
+
902
+ sub = parser.add_subparsers(dest="command", metavar="<command>")
903
+
904
+ def cmd(name: str, func: Any, help_text: str) -> argparse.ArgumentParser:
905
+ p = sub.add_parser(name, parents=[common], help=help_text, description=help_text)
906
+ p.set_defaults(func=func)
907
+ return p
908
+
909
+ cmd("whoami", cmd_whoami, "Show the authenticated user (GET /api/me)")
910
+ cmd("workspace-list", cmd_workspace_list, "List workspaces you belong to")
911
+
912
+ p = cmd("workspace-create", cmd_workspace_create, "Create a workspace (POST /api/workspaces)")
913
+ p.add_argument("--name", required=True)
914
+ p.add_argument("--slug", required=True)
915
+ p.add_argument("--description")
916
+ p.add_argument("--issue-prefix", dest="issue_prefix")
917
+
918
+ p = cmd(
919
+ "workspace-init",
920
+ cmd_workspace_init,
921
+ "Idempotent workspace bootstrap: create (or reuse by slug), register git repos (owner/admin), optionally complete onboarding",
922
+ )
923
+ p.add_argument("--name", required=True)
924
+ p.add_argument("--slug", required=True)
925
+ p.add_argument("--description")
926
+ p.add_argument("--repo", action="append", help="git repo URL to register (repeatable)")
927
+ p.add_argument("--complete-onboarding", dest="complete_onboarding", action="store_true")
928
+
929
+ p = cmd("workspace-repos", cmd_workspace_repos, "List workspace git repos, or add one (owner/admin)")
930
+ p.add_argument("--add", help="git repo URL to add")
931
+ p.add_argument("--description")
932
+
933
+ cmd("runtime-list", cmd_runtime_list, "List agent runtimes in the workspace (GET /api/runtimes)")
934
+ cmd("runtime-templates", cmd_runtime_templates, "List FC-E2B cloud runtime templates (owner/admin)")
935
+
936
+ p = cmd(
937
+ "runtime-create-fc",
938
+ cmd_runtime_create_fc,
939
+ "Create an FC-E2B cloud runtime; picks the sole available template when --template-id is omitted",
940
+ )
941
+ p.add_argument("--template-id", dest="template_id")
942
+ p.add_argument("--name")
943
+ p.add_argument("--provider")
944
+ p.add_argument("--visibility", choices=["private", "public"])
945
+
946
+ p = cmd("agent-list", cmd_agent_list, "List agents in the workspace")
947
+ p.add_argument("--include-archived", dest="include_archived", action="store_true")
948
+
949
+ p = cmd("agent-get", cmd_agent_get, "Get one agent")
950
+ p.add_argument("--agent", required=True, help="agent uuid")
951
+
952
+ p = cmd(
953
+ "agent-create",
954
+ cmd_agent_create,
955
+ "Create an agent; picks the sole workspace runtime when --runtime-id is omitted",
956
+ )
957
+ p.add_argument("--name", required=True)
958
+ p.add_argument("--runtime-id", dest="runtime_id")
959
+ p.add_argument("--description")
960
+ p.add_argument("--instructions")
961
+ p.add_argument("--model")
962
+ p.add_argument("--thinking-level", dest="thinking_level")
963
+
964
+ p = cmd(
965
+ "agent-source",
966
+ cmd_agent_source,
967
+ "Show which git repo an agent is bound to (null = not git-backed)",
968
+ )
969
+ p.add_argument("--agent", required=True, help="agent uuid")
970
+
971
+ p = cmd("agent-source-sync", cmd_agent_source_sync, "Re-sync an agent from its git source (managed_git refuses with 409)")
972
+ p.add_argument("--agent", required=True, help="agent uuid")
973
+
974
+ cmd("dingtalk-list", cmd_dingtalk_list, "List DingTalk robot installations in the workspace")
975
+
976
+ p = cmd(
977
+ "dingtalk-bind",
978
+ cmd_dingtalk_bind,
979
+ "Bind a DingTalk robot to an agent by injecting its AppKey/AppSecret (owner/admin)",
980
+ )
981
+ p.add_argument("--agent", required=True, help="agent uuid")
982
+ p.add_argument("--client-id", dest="client_id", required=True, help="DingTalk AppKey")
983
+ p.add_argument("--robot-code", dest="robot_code", help="DingTalk robotCode; defaults to --client-id")
984
+ p.add_argument("--client-secret", dest="client_secret", help="DingTalk AppSecret (prefer stdin/env)")
985
+ p.add_argument("--client-secret-stdin", dest="client_secret_stdin", action="store_true")
986
+ p.add_argument("--client-secret-env", dest="client_secret_env", metavar="VAR")
987
+ p.add_argument("--allow-unbound", dest="allow_unbound", action="store_true")
988
+
989
+ p = cmd(
990
+ "dingtalk-begin",
991
+ cmd_dingtalk_begin,
992
+ "Start the scan-to-create robot install flow; prints the QR URL, --wait polls to completion",
993
+ )
994
+ p.add_argument("--agent", required=True, help="agent uuid")
995
+ p.add_argument("--allow-unbound", dest="allow_unbound", action="store_true")
996
+ p.add_argument("--wait", action="store_true")
997
+
998
+ p = cmd("dingtalk-revoke", cmd_dingtalk_revoke, "Revoke a robot installation (owner/admin)")
999
+ p.add_argument("--installation", required=True, help="installation uuid")
1000
+
1001
+ p = cmd("dingtalk-account-begin", cmd_dingtalk_account_begin, "Start binding a personal DingTalk account to an agent")
1002
+ p.add_argument("--agent", required=True, help="agent uuid")
1003
+
1004
+ cmd("dingtalk-account-list", cmd_dingtalk_account_list, "List personal DingTalk account bindings")
1005
+
1006
+ p = cmd(
1007
+ "dingtalk-account-unbind",
1008
+ cmd_dingtalk_account_unbind,
1009
+ "Remove a personal DingTalk account binding (row kept as revoked for audit)",
1010
+ )
1011
+ p.add_argument("--installation", required=True, help="installation uuid")
1012
+
1013
+ p = cmd("agent-tasks", cmd_agent_tasks, "List an agent's task run history")
1014
+ p.add_argument("--agent", required=True, help="agent uuid")
1015
+ p.add_argument("--limit", type=int, default=20, help="max rows (0 = all)")
1016
+
1017
+ p = cmd(
1018
+ "task-trace",
1019
+ cmd_task_trace,
1020
+ "Show a task's step-by-step execution trace (reasoning text, tool calls, results); --follow polls live",
1021
+ )
1022
+ p.add_argument("--task", required=True, help="task uuid")
1023
+ p.add_argument("--since", type=int, help="only messages with seq > N (incremental)")
1024
+ p.add_argument("--follow", action="store_true", help="poll for new messages until the task ends")
1025
+ p.add_argument("--text", action="store_true", help="human-readable lines instead of JSON")
1026
+
1027
+ p = cmd("issue-tasks", cmd_issue_tasks, "List all task runs for an issue")
1028
+ p.add_argument("--issue", required=True, help="issue uuid or identifier (e.g. FDE-1)")
1029
+
1030
+ p = cmd("issue-timeline", cmd_issue_timeline, "Issue timeline: comments + activity log merged")
1031
+ p.add_argument("--issue", required=True, help="issue uuid or identifier")
1032
+
1033
+ p = cmd("issue-usage", cmd_issue_usage, "Aggregated token usage for an issue")
1034
+ p.add_argument("--issue", required=True, help="issue uuid or identifier")
1035
+
1036
+ cmd("task-snapshot", cmd_task_snapshot, "Workspace-wide active tasks + each agent's latest outcome")
1037
+
1038
+ p = cmd("autopilot-list", cmd_autopilot_list, "List autopilots (scheduled/triggered automations)")
1039
+ p.add_argument("--status", choices=["active", "paused", "archived"])
1040
+
1041
+ p = cmd("autopilot-get", cmd_autopilot_get, "Get one autopilot incl. triggers")
1042
+ p.add_argument("--autopilot", required=True, help="autopilot uuid")
1043
+
1044
+ p = cmd(
1045
+ "autopilot-create",
1046
+ cmd_autopilot_create,
1047
+ "Create a scheduled agent automation; --cron adds the schedule trigger in one step",
1048
+ )
1049
+ p.add_argument("--title", required=True)
1050
+ p.add_argument("--agent", required=True, help="assignee agent uuid")
1051
+ p.add_argument("--description", help="the prompt / issue body for each run")
1052
+ p.add_argument("--mode", choices=["create_issue", "run_only"], default="create_issue")
1053
+ p.add_argument("--cron", help='5-field cron, e.g. "0 9 * * 1-5" (no seconds, no @daily)')
1054
+ p.add_argument("--timezone", default="Asia/Shanghai", help="IANA timezone for --cron")
1055
+ p.add_argument("--issue-title-template", dest="issue_title_template", help="only {{date}} token supported")
1056
+
1057
+ p = cmd("autopilot-trigger", cmd_autopilot_trigger, "Run an autopilot once, now")
1058
+ p.add_argument("--autopilot", required=True, help="autopilot uuid")
1059
+
1060
+ p = cmd("autopilot-runs", cmd_autopilot_runs, "List an autopilot's run history")
1061
+ p.add_argument("--autopilot", required=True, help="autopilot uuid")
1062
+ p.add_argument("--limit", type=int, default=20)
1063
+
1064
+ p = cmd("autopilot-set-status", cmd_autopilot_set_status, "Pause or resume an autopilot")
1065
+ p.add_argument("--autopilot", required=True, help="autopilot uuid")
1066
+ p.add_argument("--status", required=True, choices=["active", "paused"])
1067
+
1068
+ p = cmd(
1069
+ "agent-check",
1070
+ cmd_agent_check,
1071
+ "Doctor report: agent status + runtime health + recent tasks + DingTalk bindings",
1072
+ )
1073
+ p.add_argument("--agent", required=True, help="agent uuid")
1074
+
1075
+ p = cmd("issue-create", cmd_issue_create, "Create an issue (optionally assigned to an agent)")
1076
+ p.add_argument("--title", required=True)
1077
+ p.add_argument("--description")
1078
+ p.add_argument("--status", choices=["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"])
1079
+ p.add_argument("--priority", choices=["urgent", "high", "medium", "low", "none"])
1080
+ p.add_argument("--assignee-agent", dest="assignee_agent", help="agent uuid to assign (triggers a run)")
1081
+
1082
+ p = cmd("issue-get", cmd_issue_get, "Get one issue")
1083
+ p.add_argument("--issue", required=True, help="issue uuid or identifier (e.g. FDE-1)")
1084
+
1085
+ p = cmd("issue-list", cmd_issue_list, "List issues with filters")
1086
+ p.add_argument("--assignee", help="assignee uuid (agent or member)")
1087
+ p.add_argument("--creator", help="creator uuid")
1088
+ p.add_argument("--project", help="project uuid")
1089
+ p.add_argument("--priority", choices=["urgent", "high", "medium", "low", "none"])
1090
+ p.add_argument("--open-only", dest="open_only", action="store_true")
1091
+ p.add_argument("--limit", type=int, default=50, help="max rows (0 = all)")
1092
+
1093
+ p = cmd("issue-update", cmd_issue_update, "Update an issue (status/title/description/priority/assignee)")
1094
+ p.add_argument("--issue", required=True, help="issue uuid or identifier")
1095
+ p.add_argument("--title")
1096
+ p.add_argument("--description")
1097
+ p.add_argument("--status", choices=["backlog", "todo", "in_progress", "in_review", "done", "blocked", "cancelled"])
1098
+ p.add_argument("--priority", choices=["urgent", "high", "medium", "low", "none"])
1099
+ p.add_argument("--assignee-agent", dest="assignee_agent", help="agent uuid")
1100
+
1101
+ p = cmd("comment-add", cmd_comment_add, "Add a comment to an issue")
1102
+ p.add_argument("--issue", required=True, help="issue uuid or identifier")
1103
+ p.add_argument("--content", required=True)
1104
+ p.add_argument("--parent", help="parent comment uuid (threaded reply)")
1105
+
1106
+ p = cmd("comment-list", cmd_comment_list, "List comments on an issue")
1107
+ p.add_argument("--issue", required=True, help="issue uuid or identifier")
1108
+ p.add_argument("--recent", type=int, help="only the N most recent")
1109
+
1110
+ p = cmd(
1111
+ "chat-send",
1112
+ cmd_chat_send,
1113
+ "Send a chat message to an agent (creates a session if needed); --wait polls for the reply",
1114
+ )
1115
+ p.add_argument("--agent", help="agent uuid (required for a new session)")
1116
+ p.add_argument("--session", help="existing chat session uuid")
1117
+ p.add_argument("--content", required=True)
1118
+ p.add_argument("--wait", action="store_true", help="wait for the assistant reply")
1119
+ p.add_argument("--timeout", type=int, default=180, help="seconds to wait with --wait")
1120
+
1121
+ cmd("member-list", cmd_member_list, "List workspace members")
1122
+
1123
+ p = cmd("member-invite", cmd_member_invite, "Invite a member by email (owner/admin; creates an invitation)")
1124
+ p.add_argument("--email", required=True)
1125
+ p.add_argument("--role", choices=["owner", "admin", "member"], default="member")
1126
+
1127
+ p = cmd("token-create", cmd_token_create, "Mint a personal access token (plaintext shown once)")
1128
+ p.add_argument("--name", required=True)
1129
+ p.add_argument("--expires-days", dest="expires_days", type=int)
1130
+
1131
+ cmd("skill-list", cmd_skill_list, "List skills in the workspace")
1132
+
1133
+ p = cmd("skill-get", cmd_skill_get, "Get one skill incl. content and files")
1134
+ p.add_argument("--skill", required=True, help="skill uuid or exact name")
1135
+
1136
+ p = cmd(
1137
+ "skill-pull",
1138
+ cmd_skill_pull,
1139
+ "Download a workspace skill into a local directory (SKILL.md from content + supporting files)",
1140
+ )
1141
+ p.add_argument("--skill", required=True, help="skill uuid or exact name")
1142
+ p.add_argument("--dest", help="target directory (default: ./<skill name>)")
1143
+ p.add_argument("--force", action="store_true", help="write into a non-empty directory")
1144
+
1145
+ p = cmd(
1146
+ "skill-push",
1147
+ cmd_skill_push,
1148
+ "Create or update a workspace skill from a local directory (SKILL.md + supporting files)",
1149
+ )
1150
+ p.add_argument("--dir", required=True, help="local skill directory containing SKILL.md")
1151
+ p.add_argument("--name", help="override skill name (default: frontmatter name, then directory name)")
1152
+ p.add_argument("--description", help="override description (default: frontmatter description)")
1153
+ p.add_argument(
1154
+ "--on-conflict",
1155
+ dest="on_conflict",
1156
+ choices=["fail", "overwrite"],
1157
+ default="fail",
1158
+ help="what to do when a skill with the same name exists (overwrite replaces content AND files wholesale)",
1159
+ )
1160
+
1161
+ return parser
1162
+
1163
+
1164
+ def main() -> None:
1165
+ parser = build_parser()
1166
+ args = parser.parse_args()
1167
+ if not getattr(args, "command", None):
1168
+ parser.print_help(sys.stderr)
1169
+ sys.exit(0 if len(sys.argv) == 1 else 1)
1170
+ ctx = build_ctx(args)
1171
+ try:
1172
+ args.func(ctx, args)
1173
+ except ApiError as e:
1174
+ detail = f" {json.dumps(e.body, ensure_ascii=False)}" if isinstance(e.body, dict) else ""
1175
+ prefix = f"API {e.status}" if e.status else "network"
1176
+ fail(f"{prefix}: {e.message}{detail}")
1177
+
1178
+
1179
+ if __name__ == "__main__":
1180
+ main()