@xdxer/dingtalk-agent 0.1.4-beta.9 → 0.1.4
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.
- package/CHANGELOG.md +198 -0
- package/README.en.md +98 -326
- package/README.md +95 -673
- package/dist/bin/dingtalk-agent.js +200 -18
- package/dist/bin/dingtalk-agent.js.map +1 -1
- package/dist/src/agent-audit.js +1014 -89
- package/dist/src/agent-audit.js.map +1 -1
- package/dist/src/agent-enhance.js +41 -8
- package/dist/src/agent-enhance.js.map +1 -1
- package/dist/src/agent-platform.js +299 -0
- package/dist/src/agent-platform.js.map +1 -0
- package/dist/src/config.js +1 -7
- package/dist/src/config.js.map +1 -1
- package/dist/src/development-workspace.js +31 -5
- package/dist/src/development-workspace.js.map +1 -1
- package/dist/src/doctor.js +74 -15
- package/dist/src/doctor.js.map +1 -1
- package/dist/src/host-detect.js +146 -0
- package/dist/src/host-detect.js.map +1 -0
- package/dist/src/instruction-path.js +270 -0
- package/dist/src/instruction-path.js.map +1 -0
- package/dist/src/map.js +157 -0
- package/dist/src/map.js.map +1 -0
- package/dist/src/multica-deploy.js +78 -20
- package/dist/src/multica-deploy.js.map +1 -1
- package/dist/src/multica-provider.js +1 -1
- package/dist/src/multica-provider.js.map +1 -1
- package/dist/src/opencode-evals.js +710 -225
- package/dist/src/opencode-evals.js.map +1 -1
- package/dist/src/opencode-isolation.js +124 -0
- package/dist/src/opencode-isolation.js.map +1 -0
- package/dist/src/opencode-provider.js +1 -0
- package/dist/src/opencode-provider.js.map +1 -1
- package/dist/src/opencode-workspace.js +21 -10
- package/dist/src/opencode-workspace.js.map +1 -1
- package/dist/src/remote-state-evals.js +2 -1
- package/dist/src/remote-state-evals.js.map +1 -1
- package/dist/src/robot-evals.js +2 -1
- package/dist/src/robot-evals.js.map +1 -1
- package/dist/src/setup.js +6 -5
- package/dist/src/setup.js.map +1 -1
- package/dist/src/skill-manager.js +141 -12
- package/dist/src/skill-manager.js.map +1 -1
- package/dist/src/skills.js +2 -1
- package/dist/src/skills.js.map +1 -1
- package/dist/src/types.js.map +1 -1
- package/dist/src/upgrade.js +23 -27
- package/dist/src/upgrade.js.map +1 -1
- package/dist/src/version.js +73 -0
- package/dist/src/version.js.map +1 -0
- package/docs/INSTALLATION.md +3 -3
- package/docs/assets/agent-delivery-lifecycle.svg +103 -0
- package/docs/schemas/agent-platform.schema.json +13 -0
- package/docs/schemas/project.schema.json +3 -0
- package/docs/schemas/release-readiness.schema.json +2 -1
- package/evals/README.md +17 -0
- package/lab/README.md +3 -3
- package/lab/agent-eval/catalog.json +5 -5
- package/lab/agent-eval/classic-failures.json +9 -9
- package/lab/agent-eval/completion-gate-regression.json +6 -6
- package/lab/agent-eval/remote-state-workspace/opencode.json +1 -1
- package/lab/agent-eval/workspace/AGENTS.md +1 -1
- package/lab/project-workspace/fake-multica-provider.mjs +17 -6
- package/lab/project-workspace/opencode-provider-suite.json +1 -1
- package/lab/robot-eval/suite.json +1 -1
- package/lab/robot-eval/workspace/AGENTS.md +1 -1
- package/lab/schemas/agent-eval-catalog.schema.json +1 -1
- package/package.json +13 -12
- package/skills/README.md +23 -0
- package/skills/core/dingtalk-agent-compose/SKILL.md +151 -0
- package/skills/core/dingtalk-agent-compose/assets/AGENTS.template.md +35 -0
- package/skills/core/dingtalk-agent-compose/assets/role-skill.template.md +32 -0
- package/skills/core/dingtalk-agent-compose/evals/evals.json +129 -0
- package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/agent-definition-contract.md +3 -3
- package/skills/core/dingtalk-agent-compose/references/host-loading-contract.md +58 -0
- package/skills/core/dingtalk-agent-compose/references/hosts/claude-code.md +48 -0
- package/skills/core/dingtalk-agent-compose/references/hosts/opencode.md +77 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/SKILL.md +31 -7
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/assets/eval-catalog.template.json +1 -1
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/evals/evals.json +22 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/eval-topology.md +14 -0
- package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/evidence-contract.md +21 -0
- package/skills/core/dingtalk-agent-eval/references/failure-to-case.md +35 -0
- package/skills/core/dingtalk-agent-eval/references/interactive-debug-channels.md +93 -0
- package/skills/core/dingtalk-agent-eval/references/local-connector-smoke.md +75 -0
- package/skills/core/dingtalk-basic-behavior/SKILL.md +87 -0
- package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/memory-and-evolution.md +12 -0
- package/skills/core/dingtalk-basic-behavior/references/risk-authority-and-privacy.md +62 -0
- package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/task-lifecycle.md +15 -3
- package/skills/core/dingtalk-basic-behavior/references/truth-and-recovery.md +65 -0
- package/skills/platforms/deap/PLATFORM.md +3 -0
- package/skills/platforms/deap/README.md +3 -0
- package/skills/platforms/multica-dingtalk/PLATFORM.md +40 -0
- package/skills/{dingtalk-agent-boot-multica → platforms/multica-dingtalk/dingtalk-agent-boot-multica}/SKILL.md +4 -4
- package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/SKILL.md +1 -1
- package/skills/platforms/multica-dingtalk/multica-external/SKILL.md +282 -0
- package/skills/platforms/multica-dingtalk/multica-external/scripts/bootstrap.sh +78 -0
- package/skills/platforms/multica-dingtalk/multica-external/scripts/multica_ext.py +1180 -0
- package/skills/dingtalk-agent-compose/SKILL.md +0 -110
- package/skills/dingtalk-agent-compose/assets/AGENTS.template.md +0 -26
- package/skills/dingtalk-agent-compose/assets/role-skill.template.md +0 -24
- package/skills/dingtalk-agent-compose/evals/evals.json +0 -94
- package/skills/dingtalk-agent-compose/references/opencode-host-contract.md +0 -65
- package/skills/dingtalk-basic-behavior/SKILL.md +0 -146
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.dingtalk-doc.template.json +0 -0
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/assets/agent.bindings.local.template.json +0 -0
- /package/skills/{dingtalk-agent-compose/assets → core/dingtalk-agent-compose/assets/hosts/opencode}/opencode.template.json +0 -0
- /package/skills/{dingtalk-agent-compose → core/dingtalk-agent-compose}/references/storage-routing.md +0 -0
- /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/scenario-taxonomy.md +0 -0
- /package/skills/{dingtalk-agent-eval → core/dingtalk-agent-eval}/references/storage-modes.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/memory-candidate-proposal.json +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/assets/task-checkpoint.json +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/action-contract.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/event-to-behavior.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/perception-and-gates.md +0 -0
- /package/skills/{dingtalk-basic-behavior → core/dingtalk-basic-behavior}/references/runtime-modes.md +0 -0
- /package/skills/{dingtalk-agent-deploy → platforms/multica-dingtalk/dingtalk-agent-deploy-multica}/references/multica-deployment-contract.md +0 -0
- /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()
|