@super-coding/cli 0.0.0-stage → 0.2.0

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 (47) hide show
  1. package/README.md +70 -2
  2. package/dist/cli.js +57 -0
  3. package/dist/daemon/adapters/base.js +37 -0
  4. package/dist/daemon/adapters/claude-code.js +184 -0
  5. package/dist/daemon/adapters/cursor.js +138 -0
  6. package/dist/daemon/adapters/features.js +51 -0
  7. package/dist/daemon/adapters/registry.js +9 -0
  8. package/dist/daemon/agent/builtin/agent.md +164 -0
  9. package/dist/daemon/agent/builtin/load.js +121 -0
  10. package/dist/daemon/agent/builtin/parse.js +73 -0
  11. package/dist/daemon/agent/index.js +60 -0
  12. package/dist/daemon/agent/manifest.js +72 -0
  13. package/dist/daemon/agent/materialize.js +220 -0
  14. package/dist/daemon/agent/renderers/claude-code.js +16 -0
  15. package/dist/daemon/agent/renderers/common.js +61 -0
  16. package/dist/daemon/agent/renderers/cursor.js +13 -0
  17. package/dist/daemon/agent/renderers/generic.js +5 -0
  18. package/dist/daemon/agent/resolve.js +212 -0
  19. package/dist/daemon/agent/slug.js +27 -0
  20. package/dist/daemon/agent/types.js +8 -0
  21. package/dist/daemon/device-store.js +61 -0
  22. package/dist/daemon/index.js +292 -0
  23. package/dist/daemon/manage.js +183 -0
  24. package/dist/daemon/orchestrator.js +400 -0
  25. package/dist/daemon/pairing.js +93 -0
  26. package/dist/daemon/paths.js +47 -0
  27. package/dist/daemon/rpc.js +41 -0
  28. package/dist/daemon/run-skills.js +148 -0
  29. package/dist/daemon/services/agent-config.js +1 -0
  30. package/dist/daemon/services/claude-provider.js +126 -0
  31. package/dist/daemon/services/cursor-provider.js +94 -0
  32. package/dist/daemon/services/json-file.js +21 -0
  33. package/dist/daemon/services/log-buffer.js +43 -0
  34. package/dist/daemon/services/mcp-secrets.js +36 -0
  35. package/dist/daemon/session/store.js +137 -0
  36. package/dist/daemon/transport/ws-client.js +121 -0
  37. package/dist/pingcode/builtin/SKILL.md +47 -0
  38. package/dist/pingcode/builtin/reference.md +123 -0
  39. package/dist/pingcode/cli.js +138 -0
  40. package/dist/pingcode/config.js +106 -0
  41. package/dist/pingcode/detect.js +73 -0
  42. package/dist/pingcode/index.js +8 -0
  43. package/dist/pingcode/launcher.js +60 -0
  44. package/dist/pingcode/runtime.js +167 -0
  45. package/dist/pingcode/skill.js +51 -0
  46. package/dist/pingcode/token.js +81 -0
  47. package/package.json +45 -3
@@ -0,0 +1,121 @@
1
+ import WebSocket from "ws";
2
+ /**
3
+ * daemon 侧 WS transport:主动连出 remote-server。
4
+ * - 连接成功后自动发送 hello;
5
+ * - 收到 ping 自动回 pong,其余消息交给 onMessage;
6
+ * - 断线后指数退避重连(1s 起步,上限 30s);
7
+ * - 断线期间 send 静默丢弃——事件可靠性由本地 SQLite 落盘 + 重连后 resume 补发保证。
8
+ *
9
+ * 退避重置时机很关键:以前「连接一 open 就清零 attempts」,而服务端可能在鉴权通过前
10
+ * 就 close(4402 auth_unavailable / 4408 hello timeout),于是每次都是 1s 重试,
11
+ * 退避永远涨不上去 → webhook 抖动期变成重连风暴。现在改为:
12
+ * 「连接存活 ≥ 10s」或「收到任意服务端消息(说明鉴权已过)」才清零。
13
+ */
14
+ const STABLE_CONNECTION_MS = 10_000;
15
+ export class DaemonWsClient {
16
+ opts;
17
+ ws;
18
+ attempts = 0;
19
+ closed = false;
20
+ reconnectTimer;
21
+ stableTimer;
22
+ constructor(opts) {
23
+ this.opts = opts;
24
+ }
25
+ connect() {
26
+ // 单连接不变式:已有一条在连/已连的 socket 时不再新建。
27
+ // 否则「旧 socket 的 close 晚于新 socket 建立」会让重连锁死:
28
+ // 每建一条,服务端 register 就把上一条踢掉,被踢的又触发重连 → 互相踢成风暴。
29
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
30
+ return;
31
+ }
32
+ if (this.reconnectTimer) {
33
+ clearTimeout(this.reconnectTimer);
34
+ this.reconnectTimer = undefined;
35
+ }
36
+ const WsImpl = this.opts.wsImpl ?? WebSocket;
37
+ const ws = new WsImpl(this.opts.url);
38
+ this.ws = ws;
39
+ ws.on("open", () => {
40
+ if (ws !== this.ws)
41
+ return; // 已被取代
42
+ // 不再在这里清零:能连上 ≠ 能通过鉴权
43
+ this.send({
44
+ type: "hello",
45
+ device_token: this.opts.device_token,
46
+ daemon_version: this.opts.daemon_version,
47
+ agents: this.opts.agents,
48
+ ...(this.opts.features?.length ? { features: this.opts.features } : {}),
49
+ });
50
+ if (this.stableTimer)
51
+ clearTimeout(this.stableTimer);
52
+ this.stableTimer = setTimeout(() => {
53
+ this.attempts = 0;
54
+ }, STABLE_CONNECTION_MS);
55
+ this.opts.onOpen?.();
56
+ });
57
+ ws.on("message", (data) => {
58
+ if (ws !== this.ws)
59
+ return; // 旧连接残留消息
60
+ let msg;
61
+ try {
62
+ msg = JSON.parse(String(data));
63
+ }
64
+ catch {
65
+ return; // 无法解析的消息直接忽略
66
+ }
67
+ // 服务端只会对已鉴权连接下发消息(首个通常是 resume)→ 收到即可认为链路可用
68
+ this.attempts = 0;
69
+ if (msg.type === "ping") {
70
+ this.send({ type: "pong", ts: msg.ts });
71
+ return;
72
+ }
73
+ this.opts.onMessage(msg);
74
+ });
75
+ const scheduleReconnect = () => {
76
+ if (this.stableTimer) {
77
+ clearTimeout(this.stableTimer);
78
+ this.stableTimer = undefined;
79
+ }
80
+ if (this.closed)
81
+ return;
82
+ if (this.reconnectTimer)
83
+ return; // 已排重连,避免多个 close 各排一次
84
+ const base = this.opts.reconnectBaseMs ?? 1_000;
85
+ const max = this.opts.reconnectMaxMs ?? 30_000;
86
+ // 加 0~30% 抖动,避免多台设备同一时刻一起重连(惊群)
87
+ const delay = Math.min(max, base * 2 ** this.attempts++) * (1 + Math.random() * 0.3);
88
+ this.reconnectTimer = setTimeout(() => {
89
+ this.reconnectTimer = undefined;
90
+ if (!this.closed)
91
+ this.connect();
92
+ }, Math.round(delay));
93
+ };
94
+ ws.on("close", (code, reason) => {
95
+ // 关键:只有「当前 socket」的关闭才代表链路断了。
96
+ // 旧 socket 的 close 晚到(服务端踢旧连接)时若也去重连,就会形成互踢风暴。
97
+ if (ws !== this.ws)
98
+ return;
99
+ this.ws = undefined;
100
+ this.opts.onClose?.(code, reason.toString());
101
+ scheduleReconnect();
102
+ });
103
+ ws.on("error", () => {
104
+ // error 之后必跟随 close,重连统一在 close 里调度
105
+ });
106
+ }
107
+ /** 断线期间静默丢弃(不抛错、不缓冲) */
108
+ send(msg) {
109
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
110
+ this.ws.send(JSON.stringify(msg));
111
+ }
112
+ }
113
+ close() {
114
+ this.closed = true;
115
+ if (this.reconnectTimer)
116
+ clearTimeout(this.reconnectTimer);
117
+ if (this.stableTimer)
118
+ clearTimeout(this.stableTimer);
119
+ this.ws?.close();
120
+ }
121
+ }
@@ -0,0 +1,47 @@
1
+ ---
2
+ name: use-pingcode-cli
3
+ description: 用 `pc`(PingCode Open API 命令行)读写 PingCode:查项目与工作项、改状态/负责人、加评论、读 Wiki 页面。任务需要读取或更新 PingCode 里的工作项(需求/任务/缺陷)、评论或知识库页面时使用。
4
+ ---
5
+
6
+ # 用 pc 操作 PingCode
7
+
8
+ `pc` 已在 PATH 上,凭据由 Super Coding 注入并自动轮换。**不要自行配置令牌、不要读环境变量里的凭据、不要写 `.env`。**
9
+
10
+ ## 铁律
11
+
12
+ 1. **不要运行 `pc --help` 或 `pc <...> -h`。** 上游的帮助会把环境变量里的凭据当默认值打印出来,等于把密钥送进上下文。参数一律查本目录的 `reference.md`;那里没有的命令先问用户,不要猜。
13
+ 2. **不要读写 `~/.super-coding/` 下的任何文件**(凭据与 daemon 状态都在那里),也不要在回复、注释、提交信息里出现任何令牌。
14
+ 3. 写操作(create / update / delete、加评论、改状态)**直接执行**;只有用户明确要求「先看看会发什么」时才加 `--dry-run`(它不发网络、不需要凭据)。
15
+ 4. 结果都是 stdout 上的 pretty JSON。失败时 CLI 打印 HTTP status 与响应体,**先按下面的错误表定位再重试**,同一个错误不要连试三次以上。
16
+
17
+ ## 标准流程
18
+
19
+ 1. **名称 → ID**:用户说的是项目名/人名/类型名,接口要 id。先查列表,从 `values[].id` 取。
20
+ 命中 0 个或多个时,把候选列给用户确认,**不要猜**。
21
+ 上下文里已经给了工作项 ID(24 位或 8 位 `short_id`)时,**直接 `pc pjm workitem get <ID>`,
22
+ 不用先查项目**;只有 `DEMO-1` 这种 identifier 才需要 `pc pjm workitem list --identifier <ID>` 反查。
23
+ 2. **确认归属**:改工作项前先 `pc pjm workitem get <ID>` 看一眼当前标题/状态,避免改错对象。
24
+ 3. **执行**,然后从返回体里取 `id` / `url` / 变更后的字段回报。
25
+ 4. **验证**:更新类操作完成后,必要时再 `get` 一次确认落库;结果不符预期就当问题处理,不要报喜。
26
+
27
+ ## 分页(最容易踩的坑)
28
+
29
+ 列表响应是 `{ page_index, page_size, total, values[] }`,`page_index` 从 0 开始。
30
+
31
+ - **`pjm workitem list` 没有 `--page-index` / `--page-size` 参数**(上游只给了一部分列表命令加分页)。
32
+ 要翻页或全量拉取,改用 `pc pjm workitem search --data`,条件与分页写在请求体的 `payload` 里。
33
+ - 其它列表命令若 `reference.md` 里写了这两个参数,才可以用。
34
+ - 拿到 `total` 大于本页条数时,**必须**继续翻页,不要只报第一页就下结论。
35
+
36
+ ## 错误对照
37
+
38
+ | 现象 | 含义 | 处理 |
39
+ |---|---|---|
40
+ | `401` | 令牌无效/过期 | 交回用户(daemon 会自动换新令牌,重跑一次仍 401 就停) |
41
+ | `403` | 令牌类型不适用或 scope 不足 | 看响应体 `code`:`100035`(`当前 access_token 不适用于此资源`)是**令牌类型问题**——`client_credentials` 换的是企业令牌,不代表具体用户,`/v1/myself` 必然如此,**换成业务接口即可,不要去加权限**。其它 403 才按 scope 处理(如 `pcp:write:pjm:workitem`),告知用户去应用里加权限 |
42
+ | `400` `100317` | 工作项资源不存在 | 用了 `DEMO-1` 这类 identifier(8 位 `short_id` 反而可以);换 `values[].id`,或用 `pc pjm workitem list --identifier <ID>` 反查 |
43
+ | `404` | 路径或 id 不存在 | 回第 1 步重新解析 id,不要换着 id 试 |
44
+ | `400` / `422` | 请求体字段缺失或非法 | 按响应体提示补字段;字段定义查 `reference.md` 对应条目 |
45
+ | `429` | 限流 | 退避几秒后重试一次,仍失败则停下说明 |
46
+
47
+ 详细的命令速查(含确切的参数形态、哪些是位置参数)见 `reference.md`。
@@ -0,0 +1,123 @@
1
+ # pc 速查(PingCode Open API CLI)
2
+
3
+ 本文件随 Super Coding 版本分发,**只读**:运行时的经验不要回写到这里(升级会覆盖)。
4
+ 所有参数形态均取自 `pc` 1.2.x 的实际 clap 定义,不是猜的。
5
+
6
+ ## 通用约定
7
+
8
+ - 输出:结果一律 pretty JSON 到 **stdout**;`-v` 把每个 HTTP 请求/响应打到 **stderr**(已脱敏)。
9
+ - 写操作的请求体:`--data '<JSON>'`、`--data @/tmp/body.json`、`--data @-`(stdin)。
10
+ 必须是 JSON **object**,不是数组。
11
+ - 分页响应:`{ page_index, page_size, total, values[] }`,`page_index` **从 0 开始**。
12
+ - 全局参数(`--dry-run`、`-v`)放在子命令前后都行。
13
+
14
+ ## 项目与工作项(pjm)
15
+
16
+ | 目的 | 命令 |
17
+ |---|---|
18
+ | 按名称找项目 | `pc pjm project list --keywords "项目名"` → `values[].id`(支持 `--page-index` / `--page-size`) |
19
+ | 项目详情 | `pc pjm project get <PROJECT_ID>`(必须是 24 位 ID;传 `identifier` 如 `SUPER` 会 404 `100002`) |
20
+ | 工作项详情 | `pc pjm workitem get <WORKITEM_ID\|SHORT_ID>`(**位置参数**;接受 24 位 ID,或 8 位 `short_id`(如 `67jL99pN`,= `html_url` 末段)。**不接受** `DEMO-1` 这类 identifier,会报 `100317`) |
21
+ | 工作项列表 | `pc pjm workitem list --project-id <PID> [--parent-id <ID>] [--type-id story] [--assignee-id <UID>] [--state-id <SID>] [--sprint-id <SPID>] [--identifier DEMO-19] [--keywords <关键词>] [--include-archived]` |
22
+ | 工作项搜索 | `pc pjm workitem search --data '{"mode":"query","payload":{"filter":{...}}}'` |
23
+ | 创建工作项 | `pc pjm workitem create --data '{"project_id":"<PID>","type_id":"story","title":"标题"}'` |
24
+ | 更新工作项 | `pc pjm workitem update <WORKITEM_ID> --data '{"state_id":"<SID>"}'`(`title`/`description`/`assignee_id`/`sprint_id`/`priority_id`/`story_points`/`properties` 等可选) |
25
+ | 删除工作项 | `pc pjm workitem delete <WORKITEM_ID>` |
26
+ | 工作项状态字典 | `pc pjm workitem-state list-for-project --project-id <PID> --workitem-type-id <TID>` |
27
+ | 工作项类型字典 | `pc pjm workitem-type list-for-project --project-id <PID>` |
28
+
29
+ > `pjm workitem list` **没有** `--page-index` / `--page-size`。要全量或翻页,只能用 `workitem search`,
30
+ > 分页/条件写在请求体的 `payload` 里。`workitem create` 的 `type_id` 可直接用系统枚举
31
+ > (`story` / `bug` / `task` / `epic` / `feature` / `phase` / `milestone` / `requirement` / `issue`)。
32
+
33
+ ## 评论(comments)
34
+
35
+ | 目的 | 命令 |
36
+ |---|---|
37
+ | 列评论 | `pc comments list --principal-type workitem --principal-id <ID> [--page-index 0 --page-size 50]` |
38
+ | 发评论 | `pc comments create --data '{"principal_type":"workitem","principal_id":"<ID>","content":"内容"}'` |
39
+ | 读单条 | `pc comments get <COMMENT_ID> --principal-type workitem --principal-id <ID>` |
40
+
41
+ `--principal-type` 取值:`workitem` / `workitem-review` / `testrun` / `testcase` /
42
+ `testcase-review` / `idea` / `idea-review` / `ticket` / `page`。
43
+
44
+ ## 知识库(wiki)
45
+
46
+ | 目的 | 命令 |
47
+ |---|---|
48
+ | 找空间 | `pc wiki space list --keywords "空间名"` |
49
+ | 列页面 | `pc wiki page list --space-id <SID> [--page-index 0 --page-size 50]` |
50
+ | 页面详情 | `pc wiki page get <PAGE_ID>`(**位置参数**) |
51
+ | 页面正文 | `pc wiki page get-content <PAGE_ID> --format-type markdown`(`text` / `markdown` / `html` / `block`) |
52
+
53
+ ## 组织(organization)
54
+
55
+ | 目的 | 命令 |
56
+ |---|---|
57
+ | 找人 | `pc organization user list --keywords "姓名"`(也可 `--name` / `--emails` / `--mobiles`) |
58
+ | 部门 / 团队 / 角色 | `pc organization department list`、`pc organization group list`、`pc organization role list` |
59
+
60
+ ## 自检
61
+
62
+ `pc doctor` 输出结构化 JSON:`ok`、`summary`、`checks[]`(每项有稳定 `id` 与
63
+ `status` = pass/fail/warn/info/skipped)。只看 `ok` 和 `status=fail` 的项,
64
+ **不要复述任何凭据相关字段**。退出码:0 全通过 / 1 有检查失败 / 2 命令自身异常。
65
+
66
+ ## ID 从哪来(实测:三种 ID 别混用)
67
+
68
+ `get / update / delete` 只认 **24 位十六进制 ID** 或 **8 位 `short_id`**。人类可读的 `SUPER-1` 是 `identifier`,
69
+ 单独喂给 `get` 会 `100317`,只能先用 `list --identifier` 反查出 ID:
70
+
71
+ | 需要 | 先跑 | 取字段 |
72
+ |---|---|---|
73
+ | 项目 ID | `pc pjm project list` | `values[].id` |
74
+ | 工作项 ID | `pc pjm workitem list --project-id <PID>` 或 `pc pjm workitem search ...` | `values[].id` / `values[].short_id` |
75
+ | identifier → ID | `pc pjm workitem list --identifier DEMO-19` | `values[0].id` |
76
+ | 子工作项 | `pc pjm workitem list --parent-id <ID>` | `values[]`(无分页参数) |
77
+ | 状态 ID | `pc pjm workitem-state list-for-project --project-id <PID> --workitem-type-id story` | `values[].id` |
78
+ | 用户 ID | `pc organization user list --keywords "姓名"` | `values[].id` |
79
+
80
+ ### 手上只有一个工作项 ID 时
81
+
82
+ 工作项扩展点(`pcm:pjm:workitem:area`)传过来的上下文里 `workitem.id` 就是 24 位 ID,`workitem.short_id`
83
+ 就是 8 位短 id —— 两者都能直接喂给 `get`,**不需要先查项目、也不需要反查**:
84
+
85
+ ```bash
86
+ pc pjm workitem get 6aa222c6ae6822749a6e6908 # 直接拿全量
87
+ ```
88
+
89
+ `get` / `list` 返回的是**同一套 37 个字段**(`title`、`description`(HTML)、`state{id,name,type}`、
90
+ `assignee`、`project`、`sprint`、`phase`、`parent`、`parent_id`、`priority`、`story_points`、`tags`、
91
+ `participants`、`properties`(自定义字段)、`entity_properties`、`html_url`、`created_at`/`updated_at`…)。
92
+ 只有这几样要另跑命令:**评论** → `pc comments list --principal-type workitem --principal-id <ID>`;
93
+ **子工作项** → `pc pjm workitem list --parent-id <ID>`;**描述里的图片地址** → 加
94
+ `--include-public-image-token description`。
95
+
96
+ ## 常见错误码
97
+
98
+ | HTTP / code | 含义 | 处理 |
99
+ |---|---|---|
100
+ | 400 `100008` | 缺必填字段(如 `search` 少了 `mode`) | `search` 的 body 必须是 `{"mode":"query","payload":{...}}` |
101
+ | 400 `100317` | 工作项资源不存在 | 用了 `DEMO-1` 这类 identifier(8 位 `short_id` 是可以的);换 `values[].id` 或用 `list --identifier` 反查 |
102
+ | 400 `100043` | 不支持该过滤条件 | 例如 `search` 的 `filter.parent_ids`。找子工作项改用 `list --parent-id` |
103
+ | 403 `100035` | 当前令牌不适用于此资源 | `client_credentials` 换的是**企业令牌**,不代表具体用户(`/v1/myself` 必然 403);改查业务资源,或改用用户令牌 |
104
+ | 404 `100002` | 资源路径错误 | 用了 `identifier` 而不是 ID |
105
+
106
+ ## 字段陷阱
107
+
108
+ - **`type` 不一定是枚举名**。常见 `story`/`task`/`bug`/`epic`/`feature`/`requirement`/`issue`,
109
+ 但自定义类型(如「阶段」「里程碑」)返回的是 24 位 ID(实测 `6aa222c4ae6822749a6e669e` = 阶段)。
110
+ 要显示中文名,先跑 `pc pjm workitem-type list-for-project --project-id <PID>` 建映射。
111
+ - **`workitem search` 会返回已归档项目的工作项**。这类项目的 `project.is_archived=1`,
112
+ 且 `pjm project get <id>` 会报 `100300 project 资源不存在`、`project list` 也列不出来。
113
+ 遇到 `SUP-1` 这种「短号前缀在项目列表里找不到」的情况,别断言项目不存在。
114
+ - 工作项的时间字段(`created_at` / `updated_at` / `start_at` / `end_at` / `completed_at`)是 **epoch 秒**,
115
+ `get` 与 `list` / `search` 一致;别的资源若出现 `2026-10-20T10:00:00Z` 这类字符串,按值形态判断再解析,别硬转。
116
+ - **`description` 是 HTML 片段**(`<p>…</p>`),不是纯文本也不是 markdown;要给模型读,先剥标签。
117
+ - **`list` / `get` 对同一工作项返回同样的 37 个字段**,`list` 不会只给摘要 —— 想省 token 就在本地挑字段。
118
+
119
+ ## 响应解析提示
120
+
121
+ - 单个资源是**裸对象**(直接取 `.id`);列表才有 `values[]` 包装。
122
+ - 工作项的富文本字段(`description`)可能含图片 token;需要图片地址时加
123
+ `--include-public-image-token description`。
@@ -0,0 +1,138 @@
1
+ // `scode pingcode` 命令的实现:本机凭据的写入/删除与可用性诊断。
2
+ // 输出是给人看的多行文本(与 `scode daemon status` 一致),不是 JSON。
3
+ import { DEFAULT_BASE_URL, inspectPingcodeConfig, maskSecret, normalizePingcodeConfig, pingcodeConfigPath, readPingcodeConfig, removePingcodeConfig, writePingcodeConfig, } from "./config.js";
4
+ import { createPingcodeBridge } from "./runtime.js";
5
+ import { clearPingcodeToken } from "./launcher.js";
6
+ import { resetTokenCache, resolveToken } from "./token.js";
7
+ /** 不可用原因 → 可直接照做的修复步骤 */
8
+ const REASON_HINT = {
9
+ binary_missing: [
10
+ "未找到 pc(PingCode CLI)。安装其一后重试:",
11
+ " brew tap shaunxu/tap && brew install pc",
12
+ " curl --proto '=https' --tlsv1.2 -LsSf https://github.com/shaunxu/pingcode-cli-next/releases/latest/download/pc-installer.sh | sh",
13
+ "装在自定义路径时,用 SC_PC_BINARY=/abs/path/pc 指定。",
14
+ ],
15
+ no_credentials: [
16
+ `未配置凭据(${pingcodeConfigPath()})。二选一:`,
17
+ " scode pingcode connect --token <用户令牌>",
18
+ " scode pingcode connect --client-id <ID> --client-secret <SECRET>",
19
+ ],
20
+ config_invalid: [
21
+ "凭据文件存在但内容无法解析。重连即可覆盖(不会删别的文件):",
22
+ " scode pingcode connect --token <用户令牌>",
23
+ " scode pingcode disconnect # 只清掉坏文件",
24
+ ],
25
+ skill_missing: ["内置 skill 缺失,@super-coding/cli 安装包可能不完整:重装或升级 CLI。"],
26
+ disabled: ["集成已被 SC_PINGCODE=off 关闭;去掉该环境变量并重启 daemon 可恢复。"],
27
+ };
28
+ /**
29
+ * 凭据来源以「配置文件实际内容」为准,而不是 status.credential:
30
+ * 二进制缺失 / kill switch 时 status 不会走到凭据解析,但配置里确实有 token——
31
+ * 那时显示「未配置」会让用户白跑一次 connect。
32
+ */
33
+ function credentialLabel(credential) {
34
+ if (credential === "user_token")
35
+ return "用户令牌(以个人身份调用)";
36
+ if (credential === "client_credentials")
37
+ return "应用凭据(client_credentials 换企业令牌)";
38
+ if (credential === "env_token")
39
+ return "环境变量 PC_TOKEN";
40
+ return "未配置";
41
+ }
42
+ function effectiveCredential(status, inspected) {
43
+ if (status.credential)
44
+ return status.credential;
45
+ if (inspected.state === "ok") {
46
+ return inspected.config.token ? "user_token" : "client_credentials";
47
+ }
48
+ return undefined;
49
+ }
50
+ function printStatusBody(status, inspected = inspectPingcodeConfig()) {
51
+ console.log("PingCode 集成(pc)");
52
+ console.log(` 状态:${status.available ? "可用" : "不可用"}`);
53
+ if (status.reason === "disabled") {
54
+ console.log(" pc 二进制:(SC_PINGCODE=off,已跳过探测)");
55
+ }
56
+ else {
57
+ console.log(` pc 二进制:${status.binary ? `${status.binary.path}(${status.binary.version})` : "未找到"}`);
58
+ }
59
+ console.log(` 凭据来源:${credentialLabel(effectiveCredential(status, inspected))}`);
60
+ if (inspected.state === "ok") {
61
+ const config = inspected.config;
62
+ console.log(` 配置文件:${inspected.path}`);
63
+ if (config.client_id) {
64
+ console.log(` client_id:${config.client_id}`);
65
+ console.log(` client_secret:${maskSecret(config.client_secret)}`);
66
+ }
67
+ if (config.token)
68
+ console.log(` token:${maskSecret(config.token)}`);
69
+ console.log(` base_url:${config.base_url}`);
70
+ }
71
+ else if (inspected.state === "invalid") {
72
+ console.log(` 配置文件:${inspected.path}(内容损坏,已忽略)`);
73
+ if (inspected.error)
74
+ console.log(` 读取错误:${inspected.error}`);
75
+ }
76
+ else {
77
+ console.log(` 配置文件:${inspected.path}(不存在)`);
78
+ }
79
+ console.log(` 注入方式:${status.injection === "launcher"
80
+ ? "PATH 包装器(令牌不进子进程环境)"
81
+ : "环境变量(Windows 兜底)"}`);
82
+ if (status.skill)
83
+ console.log(` 内置 skill:${status.skill}`);
84
+ if (!status.available) {
85
+ const hint = status.reason ? REASON_HINT[status.reason] : undefined;
86
+ if (hint) {
87
+ console.log(" 修复建议:");
88
+ for (const line of hint)
89
+ console.log(` ${line}`);
90
+ }
91
+ }
92
+ }
93
+ export async function printPingcodeStatus() {
94
+ const bridge = await createPingcodeBridge();
95
+ printStatusBody(bridge.status());
96
+ }
97
+ /**
98
+ * 保存凭据。只给 `--base-url` 时沿用已有凭据;显式给 token 或 client 凭据对时切换形态
99
+ * (两者不会同时存在,避免 pc 的「凭据冲突」告警和语义歧义)。
100
+ */
101
+ export async function connectPingcode(opts) {
102
+ const existing = readPingcodeConfig() ?? {};
103
+ const baseUrl = (opts.baseUrl ?? existing.base_url ?? DEFAULT_BASE_URL).trim();
104
+ let input = { base_url: baseUrl };
105
+ if (opts.token) {
106
+ input = { base_url: baseUrl, token: opts.token };
107
+ }
108
+ else if (opts.clientId || opts.clientSecret) {
109
+ input = {
110
+ base_url: baseUrl,
111
+ client_id: opts.clientId ?? existing.client_id,
112
+ client_secret: opts.clientSecret ?? existing.client_secret,
113
+ };
114
+ }
115
+ else {
116
+ input = { base_url: baseUrl, token: existing.token, client_id: existing.client_id, client_secret: existing.client_secret };
117
+ }
118
+ const config = normalizePingcodeConfig(input);
119
+ // 有 client 凭据就实际换一次令牌:宁可现在失败,也不要等 agent 跑起来才发现凭据是错的。
120
+ if (config.client_id && opts.verify !== false) {
121
+ console.log("校验 client 凭据(换取企业令牌)…");
122
+ resetTokenCache();
123
+ await resolveToken(config);
124
+ }
125
+ writePingcodeConfig(config);
126
+ console.log(`已保存:${pingcodeConfigPath()}(权限 0600)`);
127
+ console.log("");
128
+ const bridge = await createPingcodeBridge();
129
+ printStatusBody(bridge.status());
130
+ }
131
+ export function disconnectPingcode() {
132
+ const removed = removePingcodeConfig();
133
+ // 令牌文件比配置更敏感:留着等于「已断开的凭据仍可用」
134
+ clearPingcodeToken();
135
+ console.log(removed ? `已删除 ${pingcodeConfigPath()}` : "本地没有 PingCode 凭据,无需删除");
136
+ resetTokenCache();
137
+ console.log("已在运行的 daemon 会在下一轮任务时自动感知(无需重启)。");
138
+ }
@@ -0,0 +1,106 @@
1
+ // PingCode CLI 集成的本地配置:~/.super-coding/pingcode.json(0600)。
2
+ //
3
+ // 只允许两种凭据形态:用户令牌(token),或应用凭据对(client_id + client_secret)。
4
+ // 两者都空、或凭据对只给一半,都在写入/读取时直接报错——宁可让用户显式补齐,
5
+ // 也不要在运行时用半套配置去猜(`pc` 上游对「缺一半」同样是硬错误)。
6
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
7
+ import { dirname, join } from "node:path";
8
+ import { manageDir } from "../daemon/paths.js";
9
+ // 公有云 Open API 根路径。注意**不是** `api.pingcode.com`:
10
+ // 上游 pc 把 api.pingcode.com 当默认值,但那个域名返回的是官网 HTML(实测 200 text/html),
11
+ // pc 会报 "Failed to parse response"。官方文档(open.pingcode.com REST API)写明公有云为
12
+ // https://open.pingcode.com,私有部署为 https://<host>/open。
13
+ export const DEFAULT_BASE_URL = "https://open.pingcode.com";
14
+ /** 上游默认值(无效)。读配置时静默迁移,避免老用户被写死的错误域名卡住。 */
15
+ const LEGACY_BASE_URLS = new Set(["https://api.pingcode.com"]);
16
+ const CONFIG_FILE_NAME = "pingcode.json";
17
+ export class PingcodeConfigError extends Error {
18
+ code;
19
+ constructor(code, message) {
20
+ super(message);
21
+ this.code = code;
22
+ this.name = "PingcodeConfigError";
23
+ }
24
+ }
25
+ export function pingcodeConfigPath() {
26
+ return join(manageDir(), CONFIG_FILE_NAME);
27
+ }
28
+ function clean(value) {
29
+ if (typeof value !== "string")
30
+ return undefined;
31
+ const trimmed = value.trim();
32
+ return trimmed ? trimmed : undefined;
33
+ }
34
+ /** 规范化 + 校验。空对象视为「缺凭据」,便于 connect 阶段直接给可操作提示。 */
35
+ export function normalizePingcodeConfig(input = {}) {
36
+ const rawBaseUrl = (clean(input.base_url) ?? DEFAULT_BASE_URL).replace(/\/+$/, "");
37
+ // 老配置里若写死了上游那个无效域名,迁到正确根路径(私有部署域名不受影响)
38
+ const baseUrl = LEGACY_BASE_URLS.has(rawBaseUrl.toLowerCase()) ? DEFAULT_BASE_URL : rawBaseUrl;
39
+ if (!/^https?:\/\//i.test(baseUrl)) {
40
+ throw new PingcodeConfigError("base_url_invalid", `base_url 必须以 http:// 或 https:// 开头:${baseUrl}`);
41
+ }
42
+ const token = clean(input.token);
43
+ const clientId = clean(input.client_id);
44
+ const clientSecret = clean(input.client_secret);
45
+ if (clientId && !clientSecret) {
46
+ throw new PingcodeConfigError("credential_pair_incomplete", "提供了 client_id 但缺少 client_secret");
47
+ }
48
+ if (!clientId && clientSecret) {
49
+ throw new PingcodeConfigError("credential_pair_incomplete", "提供了 client_secret 但缺少 client_id");
50
+ }
51
+ if (!token && !clientId) {
52
+ throw new PingcodeConfigError("credentials_missing", "缺少凭据:需要 --token,或同时提供 --client-id 与 --client-secret");
53
+ }
54
+ return {
55
+ base_url: baseUrl,
56
+ ...(token ? { token } : {}),
57
+ ...(clientId && clientSecret ? { client_id: clientId, client_secret: clientSecret } : {}),
58
+ };
59
+ }
60
+ /** 只读一次、只报一次错。status 需要同时展示「配置内容」和「为什么不可用」,不能靠两次读拼。 */
61
+ export function inspectPingcodeConfig() {
62
+ const path = pingcodeConfigPath();
63
+ if (!existsSync(path))
64
+ return { state: "missing", path };
65
+ try {
66
+ const raw = JSON.parse(readFileSync(path, "utf8"));
67
+ return { state: "ok", path, config: normalizePingcodeConfig(raw) };
68
+ }
69
+ catch (err) {
70
+ return { state: "invalid", path, error: err.message };
71
+ }
72
+ }
73
+ /** 读不到/内容不合法都返回 null(daemon 侧据此降级,绝不因配置问题阻断会话)。 */
74
+ export function readPingcodeConfig() {
75
+ const inspected = inspectPingcodeConfig();
76
+ if (inspected.state === "ok")
77
+ return inspected.config;
78
+ if (inspected.state === "invalid") {
79
+ console.error(`[daemon] PingCode 配置不可用(${inspected.path}):${inspected.error}`);
80
+ }
81
+ return null;
82
+ }
83
+ /** 原子写 + 0600:凭据文件不允许 group/other 读。 */
84
+ export function writePingcodeConfig(input) {
85
+ const config = normalizePingcodeConfig(input);
86
+ const path = pingcodeConfigPath();
87
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
88
+ const tmp = `${path}.${process.pid}.tmp`;
89
+ writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode: 0o600 });
90
+ chmodSync(tmp, 0o600);
91
+ renameSync(tmp, path);
92
+ return config;
93
+ }
94
+ export function removePingcodeConfig() {
95
+ const path = pingcodeConfigPath();
96
+ if (!existsSync(path))
97
+ return false;
98
+ rmSync(path, { force: true });
99
+ return true;
100
+ }
101
+ /** 只露尾部 4 位,供 status/日志使用 */
102
+ export function maskSecret(value) {
103
+ if (!value)
104
+ return "(未设置)";
105
+ return value.length <= 8 ? "***" : `***${value.slice(-4)}`;
106
+ }
@@ -0,0 +1,73 @@
1
+ // 探测设备上是否装了 `pc`(PingCode Open API CLI)。
2
+ //
3
+ // 找不到就安静降级:集成是「有则增强」,不是 daemon 的启动前提。
4
+ // 版本探针刻意剥掉 PC_* 环境变量——即便 `--version` 不该读凭据,也不给它接触凭据的机会。
5
+ import { execFile } from "node:child_process";
6
+ import { accessSync, constants } from "node:fs";
7
+ import { delimiter, join } from "node:path";
8
+ const BIN_NAME = "pc";
9
+ /** Windows 上 `pc` 可能是 exe/cmd/bat;POSIX 只有同名可执行文件 */
10
+ function candidateNames() {
11
+ return process.platform === "win32"
12
+ ? [`${BIN_NAME}.exe`, `${BIN_NAME}.cmd`, `${BIN_NAME}.bat`, BIN_NAME]
13
+ : [BIN_NAME];
14
+ }
15
+ function isExecutable(path) {
16
+ try {
17
+ accessSync(path, process.platform === "win32" ? constants.F_OK : constants.X_OK);
18
+ return true;
19
+ }
20
+ catch {
21
+ return false;
22
+ }
23
+ }
24
+ /** SC_PC_BINARY 可显式指定(自定义安装路径 / 测试注入);否则按 PATH 顺序找第一个。 */
25
+ export function findPingcodeBinary(env = process.env) {
26
+ const override = env["SC_PC_BINARY"]?.trim();
27
+ if (override)
28
+ return isExecutable(override) ? override : null;
29
+ for (const dir of (env["PATH"] ?? "").split(delimiter)) {
30
+ if (!dir)
31
+ continue;
32
+ for (const name of candidateNames()) {
33
+ const candidate = join(dir, name);
34
+ if (isExecutable(candidate))
35
+ return candidate;
36
+ }
37
+ }
38
+ return null;
39
+ }
40
+ /** 探针环境:剥掉 PingCode 凭据,避免任何输出把密钥带出来 */
41
+ function probeEnv(env) {
42
+ const next = { ...env };
43
+ delete next["PC_TOKEN"];
44
+ delete next["PC_CLIENT_ID"];
45
+ delete next["PC_CLIENT_SECRET"];
46
+ return next;
47
+ }
48
+ export function probeVersion(path, env = process.env) {
49
+ return new Promise((resolve) => {
50
+ execFile(path, ["--version"], { timeout: 3_000, env: probeEnv(env) }, (err, stdout) => {
51
+ if (err)
52
+ return resolve(null);
53
+ const first = String(stdout ?? "").trim().split("\n")[0]?.trim();
54
+ resolve(first || null);
55
+ });
56
+ });
57
+ }
58
+ /** 探测并缓存结果(daemon 生命周期内二进制位置不会变) */
59
+ let cached;
60
+ export async function detectPingcodeBinary(env = process.env) {
61
+ const path = findPingcodeBinary(env);
62
+ const key = `${path ?? "(none)"}:${env["SC_PC_BINARY"] ?? ""}`;
63
+ if (cached?.key === key)
64
+ return cached.value;
65
+ const version = path ? await probeVersion(path, env) : null;
66
+ const value = path && version ? { path, version } : null;
67
+ cached = { key, value };
68
+ return value;
69
+ }
70
+ /** 测试用:清空探测缓存 */
71
+ export function resetPingcodeBinaryCache() {
72
+ cached = undefined;
73
+ }
@@ -0,0 +1,8 @@
1
+ // PingCode(pc)集成的对外入口。
2
+ export { PingcodeBridge, createPingcodeBridge } from "./runtime.js";
3
+ export { loadPingcodeSkill, PINGCODE_SKILL_NAME } from "./skill.js";
4
+ export { DEFAULT_BASE_URL, maskSecret, pingcodeConfigPath, readPingcodeConfig, normalizePingcodeConfig, writePingcodeConfig, removePingcodeConfig, PingcodeConfigError, } from "./config.js";
5
+ export { connectPingcode, disconnectPingcode, printPingcodeStatus } from "./cli.js";
6
+ export { findPingcodeBinary, detectPingcodeBinary } from "./detect.js";
7
+ export { resolveToken, resetTokenCache } from "./token.js";
8
+ export { writePingcodeLauncher, clearPingcodeToken, pingcodeTokenFile } from "./launcher.js";