@xgspz/cli-skill 0.0.0-stage → 0.1.2
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/LICENSE +21 -0
- package/README.md +150 -2
- package/bin/cli-skill.js +86 -0
- package/package.json +17 -4
- package/skills/cli-task-center/SKILL.md +69 -0
- package/src/client.d.ts +52 -0
- package/src/client.js +243 -0
- package/src/install.js +38 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 spzxg
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,151 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @xgspz/cli-skill
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
中枢 CLI Task Center 的独立 API 客户端、命令行和可安装 Skill。业务系统通过它把工作交给已配置的远端 CLI Agent,支持一次性多任务拆解、依赖关系、派发即返回、等待结果和节点 ego 浏览器开关。只调用现有 HTTP API,不修改中枢服务、调度器或 Operator。
|
|
4
|
+
|
|
5
|
+
**0.1.1 已发布到 npm**,包含普通对话的执行配置、能力读取和按修订号保存。当前源码为 **0.1.2 本地候选**,仅修正文档与版本标识,功能与 0.1.1 相同;不会覆盖已发布版本。执行配置需要配套中枢迁移和节点升级,安装本包不会完成这些服务端步骤。可与已发布的 `@xgspz/cli-ui@0.6.9` 协同使用,各包版本独立。
|
|
6
|
+
|
|
7
|
+
## 安装 Skill
|
|
8
|
+
|
|
9
|
+
要求 Node.js 22+。以下安装示例固定使用已核验发布的 0.1.1,支持本文全部命令:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
npx -y @xgspz/cli-skill@0.1.1 install --agent codex
|
|
13
|
+
npx -y @xgspz/cli-skill@0.1.1 install --agent claude
|
|
14
|
+
# 或同时安装到两者
|
|
15
|
+
npx -y @xgspz/cli-skill@0.1.1 install --agent all
|
|
16
|
+
```
|
|
17
|
+
|
|
18
|
+
验收 0.1.2 时安装本地候选 tarball,并用同版本命令安装 Skill:
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
npm install -g /path/to/spzxg-cli-skill-0.1.2.tgz
|
|
22
|
+
cli-skill install --agent codex
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
0.1.2 尚未发布,当前公开 npm 命令继续使用 0.1.1。历史版本 0.1.0 不支持执行配置命令或 `execution_config` 字段。
|
|
26
|
+
|
|
27
|
+
默认安装到 `~/.codex/skills/cli-task-center` 或 `~/.claude/skills/cli-task-center`;Codex 尊重 `CODEX_HOME`。自定义宿主用 `install --dir /path/to/skills`。已有目录时明确报错;确认替换本 Skill 时加 `--force`。无 npm 安装后脚本,不自动修改任何宿主配置。安装后按宿主的技能刷新方式加载,或重开会话。
|
|
28
|
+
|
|
29
|
+
## 配置 API
|
|
30
|
+
|
|
31
|
+
以下变量用于本机 CLI、后端 SDK 或 MCP 服务进程,不放进前端:
|
|
32
|
+
|
|
33
|
+
```bash
|
|
34
|
+
export CLI_CENTER_BASE_URL='https://cli.xgjktech.com.cn'
|
|
35
|
+
export CLI_CENTER_TOKEN='<产品线令牌>'
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Base URL 是中枢根地址;也支持业务代理前缀,例如 `https://business.example/api/center`,客户端会追加 `/api/jobs`。调用方必须拥有该产品线令牌。业务系统还需自行验证用户与任务的访问权。
|
|
39
|
+
|
|
40
|
+
## 两种业务调用
|
|
41
|
+
|
|
42
|
+
`job.json`:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"title": "生成周报",
|
|
47
|
+
"tasks": [{"key": "report", "title": "生成周报", "prompt": "根据提供的数据生成 outputs/report.md,说明来源和限制。", "browser": false}]
|
|
48
|
+
}
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
```bash
|
|
52
|
+
# 接受后立即返回 IDs,不后台轮询
|
|
53
|
+
npx -y @xgspz/cli-skill@0.1.1 dispatch --input job.json
|
|
54
|
+
# 接受后等待,最多 60 秒
|
|
55
|
+
npx -y @xgspz/cli-skill@0.1.1 dispatch --input job.json --wait --timeout-seconds 60
|
|
56
|
+
# 延续已有任务的等待,不新建任务
|
|
57
|
+
npx -y @xgspz/cli-skill@0.1.1 wait <job_id> --timeout-seconds 60
|
|
58
|
+
npx -y @xgspz/cli-skill@0.1.1 get <job_id>
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
也支持 `--input -` 从 stdin 读取 JSON。`--browser true|false` 覆盖整个提交中的子任务;省略时保留每个 `tasks[].browser`,缺省 false。true 由现有服务在执行节点准备 ego 浏览器,本机不会启动浏览器。
|
|
62
|
+
|
|
63
|
+
多步骤共享一个工作区时,用 `tasks[].key` 与 `depends_on` 表达依赖;不同任务需要独立工作区时分别提交 Job。保留业务传入的 `business_id` 方便结果对应;新建 Job 接口不因此具备幂等性。`max_concurrent_tasks` 可选 1–20,实际调度与资源隔离继续由服务控制。
|
|
64
|
+
|
|
65
|
+
## 等待结果的含义
|
|
66
|
+
|
|
67
|
+
默认一次性 Job:等待 Job 终态,返回 `job.tasks` 中的状态、Token、错误及 `workspace` 产物位置。查询接口没有最终正文,也不自动下载文件;`succeeded` 不保证 `workspace.merged`,合并状态以该字段为准。
|
|
68
|
+
|
|
69
|
+
单个 Agent 需要返回正文时,使用对话模式:
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"title": "整理网页要点",
|
|
74
|
+
"mode": "conversation",
|
|
75
|
+
"tasks": [{"title": "整理网页要点", "prompt": "使用 ego 浏览器访问 https://example.com,返回主要内容和来源链接。", "browser": true}]
|
|
76
|
+
}
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
`dispatch --wait` 自动等待创建响应中的初始 `turn.turn_id`,返回 `reply`。已有对话用 `wait <job_id> --turn-id <turn_id>`。对话轮次完成后 Job 保持打开,客户端不会自动关闭。对话模式只支持一个任务,不能带 `depends_on`、`done_when`、`browser_learn`。
|
|
80
|
+
|
|
81
|
+
结果结构:
|
|
82
|
+
|
|
83
|
+
```json
|
|
84
|
+
{"job_id":"job_...","status":"running","outcome":"timeout","completed":false,"timed_out":true,"result_kind":"job","job":{"id":"job_...","status":"running","tasks":[]}}
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
`outcome` 为 `succeeded`、`failed`、`cancelled`、`needs_input` 或 `timeout`。`status` 保留被等待对象的实际状态。`needs_input` 表示 API 观察到等待人工输入或本轮以问题结束;需要在现有中枢/cli-ui 界面继续,不提供浏览器接管或安全输入接口。旧服务若不提供输入状态,只能在等待预算内继续查询,不能保证提前发现所有阻塞。
|
|
88
|
+
|
|
89
|
+
默认等待预算 60 秒,最大 3600 秒;`--poll-interval-seconds` 默认 3 秒、最大 60 秒。等待时间包括排队,与子任务 `timeout_seconds` 的运行超时独立。到期仅停止本地等待,远端任务继续。MCP 客户端自身超时也可能更短,长任务建议先派发再按 ID 等待,或用现有 API 的业务回调。
|
|
90
|
+
|
|
91
|
+
CLI stdout 是 JSON;请求错误在 stderr。退出码:0 接受/成功,1 请求错误/任务失败,2 等待超时,3 取消,4 需要输入。
|
|
92
|
+
|
|
93
|
+
## 对话执行配置(0.1.1 起)
|
|
94
|
+
|
|
95
|
+
先读取服务端能力目录,选择其中可用的智能体及其模型、推理和权限选项。具体 ID 由中枢配置,不能把显示名称或未经支持的模型硬编码进请求。配置读取与保存各发起一个短 HTTP 请求,不后台轮询。
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
# 新对话可选能力与默认值
|
|
99
|
+
cli-skill execution-config
|
|
100
|
+
# 已有 Job 中的指定对话;省略 task-id 时定位最早的对话
|
|
101
|
+
cli-skill execution-config job_123 --task-id task_456
|
|
102
|
+
# settings.json 只放 agent/model/reasoning/permission_mode;revision 使用刚刚读取的值
|
|
103
|
+
cli-skill set-execution-config job_123 --task-id task_456 --input settings.json --expected-revision 3
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
响应包含 `revision`、`agent`、`model`、`reasoning`、`permission_mode` 和 `agents` 能力目录。模型、推理、权限字段传 `null` 表示恢复默认;省略表示保存时不修改。切换智能体时,未显式提供的三个选项会恢复新智能体的默认值。`execution_config.agent` 是目录中的具体 ID,不接受 `system` 或 `null`。
|
|
107
|
+
|
|
108
|
+
创建对话可在首条任务中附带配置,无需先创建再保存;只适用于 `mode: "conversation"`,一次性任务仍按原有产品线配置运行。显式传入配置时,客户端先读取并验证能力接口,再提交一次创建请求;旧服务缺少能力接口或返回无效配置时不创建任务,避免旧服务忽略字段后按错误模式执行。不传配置时仍仅提交一次创建请求:
|
|
109
|
+
|
|
110
|
+
```json
|
|
111
|
+
{
|
|
112
|
+
"title": "检查报告",
|
|
113
|
+
"mode": "conversation",
|
|
114
|
+
"tasks": [{
|
|
115
|
+
"title": "检查报告",
|
|
116
|
+
"prompt": "阅读报告,先提出修改方案。",
|
|
117
|
+
"execution_config": {"agent": "codex", "model": null, "reasoning": null, "permission_mode": "plan"}
|
|
118
|
+
}]
|
|
119
|
+
}
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
示例中的 `codex`、`plan` 必须以实际目录确认支持后使用;若同时给 `tasks[].agent`,应与配置里的智能体一致,或使用既有 `system` 继承别名。保存设置不会发送消息或启动 AI,待后续轮次执行时应用。只允许空闲且没有排队消息的对话保存。
|
|
123
|
+
|
|
124
|
+
收到 `409 execution_config_conflict` 时重新读取并核对用户意图,不能覆盖别处的新设置;`409 execution_config_busy` 时待当前轮次和队列结束,再读取。网络错误、5xx 或非法成功响应可能返回 `uncertain: true`,表示保存结果未确认;先对同一 `job_id`/`task_id` 重新读取,禁止自动重发 PATCH。旧服务缺少接口或能力时明确失败,不伪造保存成功,也不自动改用另一种执行模式。
|
|
125
|
+
|
|
126
|
+
## 后端 JavaScript / TypeScript
|
|
127
|
+
|
|
128
|
+
```js
|
|
129
|
+
import { createClientFromEnv } from '@xgspz/cli-skill';
|
|
130
|
+
const center = createClientFromEnv();
|
|
131
|
+
const job = { title: '业务任务', tasks: [{ title: '执行', prompt: '完成工作并保存 outputs/result.md' }] };
|
|
132
|
+
const receipt = await center.dispatch(job, { browser: false });
|
|
133
|
+
const result = await center.waitForJob(receipt.job_id, { timeout_seconds: 60 });
|
|
134
|
+
// 或 await center.dispatchAndWait(job, { browser: true, timeout_seconds: 60 });
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
0.1.1 SDK 也提供同一配置链路:
|
|
138
|
+
|
|
139
|
+
```js
|
|
140
|
+
const defaults = await center.getExecutionConfig();
|
|
141
|
+
const current = await center.getExecutionConfig('job_123', { task_id: 'task_456' });
|
|
142
|
+
const saved = await center.setExecutionConfig('job_123', { model: null }, {
|
|
143
|
+
task_id: 'task_456', expected_revision: current.revision,
|
|
144
|
+
});
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
导出 `createClient({baseUrl, token, request_timeout_seconds?})`、`createClientFromEnv()`、`validateJob()`、`CenterApiError`,附 TypeScript 声明。支持 `AbortSignal`,请求默认 30 秒超时、不跟随重定向、不自动重试 POST/PATCH。等待查询失败和配置保存失败携带已知 `job_id`;写入响应不确定时 `uncertain: true`,须先在中枢对账,不能直接重建任务或重复保存。错误仅保留状态和已知错误码,不回显任意服务端错误正文。客户端校验常用字段、依赖图和会被服务忽略的错误模式,细节校验仍以现有服务为准。
|
|
148
|
+
|
|
149
|
+
需要回调时原样传 `callback: {url, code}`。业务消费方应核对 Bearer code,并按 API 的事件标识/版本去重。令牌和 callback code 仅由业务配置提供;不要提交到代码仓库或打印请求体。
|
|
150
|
+
|
|
151
|
+
本包未提供任务调度、Agent 执行、浏览器安装、界面组件、自动重试新任务或产物下载。前端组件使用 `@xgspz/cli-ui`;MCP 接入使用 `@xgspz/cli-mcp`。
|
package/bin/cli-skill.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { parseArgs } from 'node:util';
|
|
3
|
+
import { readFile } from 'node:fs/promises';
|
|
4
|
+
import { createClientFromEnv } from '../src/client.js';
|
|
5
|
+
import { installSkill } from '../src/install.js';
|
|
6
|
+
|
|
7
|
+
const help = `CLI Task Center · @xgspz/cli-skill 0.1.2
|
|
8
|
+
|
|
9
|
+
Install an agent skill:
|
|
10
|
+
cli-skill install [--agent codex|claude|all] [--dir <skills-directory>] [--force]
|
|
11
|
+
|
|
12
|
+
Use the existing Center HTTP API:
|
|
13
|
+
cli-skill dispatch --input <job.json|-> [--browser true|false] [--wait]
|
|
14
|
+
cli-skill get <job_id>
|
|
15
|
+
cli-skill wait <job_id> [--turn-id <turn_id>]
|
|
16
|
+
Wait options: --timeout-seconds <60> --poll-interval-seconds <3>
|
|
17
|
+
cli-skill execution-config [job_id] [--task-id <task_id>]
|
|
18
|
+
cli-skill set-execution-config <job_id> --input <settings.json|-> --expected-revision <revision> [--task-id <task_id>]
|
|
19
|
+
|
|
20
|
+
API commands require CLI_CENTER_BASE_URL and CLI_CENTER_TOKEN (product-line token).
|
|
21
|
+
Dispatch returns the API receipt. --wait returns a bounded wait result as JSON.
|
|
22
|
+
Browser true requests ego on the execution node. No local browser is launched.
|
|
23
|
+
Timeout leaves remote work running; use wait with the same IDs. Never resubmit blindly.
|
|
24
|
+
Execution settings require the upgraded Center/node. Read first; a failed save is not automatically retried.
|
|
25
|
+
Exit: 0 success/accepted, 1 error/failed, 2 waiting timeout, 3 cancelled, 4 needs input.
|
|
26
|
+
`;
|
|
27
|
+
const write = value => process.stdout.write(`${JSON.stringify(value, null, 2)}\n`);
|
|
28
|
+
async function readInput(path) {
|
|
29
|
+
if (path !== '-') return JSON.parse(await readFile(path, 'utf8'));
|
|
30
|
+
let raw = ''; for await (const chunk of process.stdin) raw += chunk;
|
|
31
|
+
return JSON.parse(raw);
|
|
32
|
+
}
|
|
33
|
+
try {
|
|
34
|
+
const { values, positionals } = parseArgs({ allowPositionals: true, strict: true, options: {
|
|
35
|
+
input: { type: 'string' }, browser: { type: 'string' }, wait: { type: 'boolean' },
|
|
36
|
+
agent: { type: 'string' }, dir: { type: 'string' }, force: { type: 'boolean' },
|
|
37
|
+
'timeout-seconds': { type: 'string' }, 'poll-interval-seconds': { type: 'string' },
|
|
38
|
+
'turn-id': { type: 'string' }, help: { type: 'boolean', short: 'h' }, version: { type: 'boolean', short: 'v' },
|
|
39
|
+
'task-id': { type: 'string' }, 'expected-revision': { type: 'string' },
|
|
40
|
+
} });
|
|
41
|
+
if (values.version) { process.stdout.write('0.1.2\n'); }
|
|
42
|
+
else if (values.help || !positionals.length) { process.stdout.write(help); }
|
|
43
|
+
else {
|
|
44
|
+
const [command, id, ...extra] = positionals;
|
|
45
|
+
if (extra.length || !['install', 'dispatch', 'get', 'wait', 'execution-config', 'set-execution-config'].includes(command)) throw new TypeError('Unknown command or unexpected positional argument; use --help');
|
|
46
|
+
const allowed = {
|
|
47
|
+
install: ['agent', 'dir', 'force'], dispatch: ['input', 'browser', 'wait', 'timeout-seconds', 'poll-interval-seconds'],
|
|
48
|
+
get: [], wait: ['turn-id', 'timeout-seconds', 'poll-interval-seconds'],
|
|
49
|
+
'execution-config': ['task-id'], 'set-execution-config': ['input', 'expected-revision', 'task-id'],
|
|
50
|
+
}[command];
|
|
51
|
+
for (const key of Object.keys(values)) if (!allowed.includes(key)) throw new TypeError(`--${key} is not valid for ${command}`);
|
|
52
|
+
if (command === 'install') {
|
|
53
|
+
if (id) throw new TypeError('install does not accept a positional argument');
|
|
54
|
+
write(await installSkill({ agent: values.agent, dir: values.dir, force: values.force }));
|
|
55
|
+
} else {
|
|
56
|
+
const opts = {};
|
|
57
|
+
for (const [flag, key] of [['timeout-seconds', 'timeout_seconds'], ['poll-interval-seconds', 'poll_interval_seconds']]) if (values[flag] !== undefined) opts[key] = Number(values[flag]);
|
|
58
|
+
if (values['turn-id'] !== undefined) opts.turn_id = values['turn-id'];
|
|
59
|
+
if (values.browser !== undefined) {
|
|
60
|
+
if (!['true', 'false'].includes(values.browser)) throw new TypeError('--browser must be true or false');
|
|
61
|
+
opts.browser = values.browser === 'true';
|
|
62
|
+
}
|
|
63
|
+
const client = createClientFromEnv();
|
|
64
|
+
let result;
|
|
65
|
+
if (command === 'dispatch') {
|
|
66
|
+
if (id || !values.input) throw new TypeError('dispatch requires --input <job.json|->');
|
|
67
|
+
if (!values.wait && (values['timeout-seconds'] !== undefined || values['poll-interval-seconds'] !== undefined)) throw new TypeError('Wait options require --wait');
|
|
68
|
+
const job = await readInput(values.input);
|
|
69
|
+
result = await (values.wait ? client.dispatchAndWait(job, opts) : client.dispatch(job, opts));
|
|
70
|
+
} else if (command === 'execution-config') {
|
|
71
|
+
result = await client.getExecutionConfig(id, { task_id: values['task-id'] });
|
|
72
|
+
} else if (command === 'set-execution-config') {
|
|
73
|
+
if (!id || !values.input || !/^\d+$/.test(values['expected-revision'] ?? '')) throw new TypeError('set-execution-config requires job_id, --input and --expected-revision from the last read');
|
|
74
|
+
result = await client.setExecutionConfig(id, await readInput(values.input), { task_id: values['task-id'], expected_revision: Number(values['expected-revision']) });
|
|
75
|
+
} else {
|
|
76
|
+
if (!id) throw new TypeError(`${command} requires a job_id`);
|
|
77
|
+
result = await (command === 'get' ? client.getJob(id) : client.waitForJob(id, opts));
|
|
78
|
+
}
|
|
79
|
+
write(result);
|
|
80
|
+
if (result.outcome) process.exitCode = ({ succeeded: 0, failed: 1, timeout: 2, cancelled: 3, needs_input: 4 })[result.outcome];
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
} catch (error) {
|
|
84
|
+
process.stderr.write(`${JSON.stringify({ error: error.name, message: error.message, ...(error.status ? { status: error.status } : {}), ...(error.code ? { code: error.code } : {}), ...(error.job_id ? { job_id: error.job_id } : {}), ...(error.receipt ? { receipt: error.receipt } : {}), ...(error.uncertain ? { uncertain: true } : {}) })}\n`);
|
|
85
|
+
process.exitCode = 1;
|
|
86
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xgspz/cli-skill",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
}
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "CLI Task Center API client and installable agent skill: dispatch background tasks, wait for results, split dependent work, and enable execution-node ego browsers.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"engines": { "node": ">=22" },
|
|
7
|
+
"bin": { "cli-skill": "./bin/cli-skill.js" },
|
|
8
|
+
"exports": { ".": { "types": "./src/client.d.ts", "default": "./src/client.js" } },
|
|
9
|
+
"types": "./src/client.d.ts",
|
|
10
|
+
"files": ["bin", "src", "skills", "README.md", "LICENSE"],
|
|
11
|
+
"scripts": {
|
|
12
|
+
"test": "node --test test/*.test.js",
|
|
13
|
+
"check": "node --check bin/cli-skill.js && node --check src/client.js && node --check src/install.js",
|
|
14
|
+
"prepublishOnly": "npm run check && npm test"
|
|
15
|
+
},
|
|
16
|
+
"publishConfig": { "access": "public", "registry": "https://registry.npmjs.org/" },
|
|
17
|
+
"keywords": ["cli-task-center", "skill", "codex", "claude-code", "ego-browser", "task-orchestration"],
|
|
18
|
+
"license": "MIT"
|
|
19
|
+
}
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: cli-task-center
|
|
3
|
+
description: Delegate work to an existing CLI Task Center through its HTTP API. Use to submit jobs, split dependent tasks, wait for results, request the execution node's ego browser, or inspect and change conversation execution settings. Requires a configured Center URL and product-line token.
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# CLI Task Center
|
|
7
|
+
|
|
8
|
+
Use this skill when the user wants work executed by their configured Center and its remote CLI agents. It wraps the existing API; it does not install a Center, modify scheduling, or run the task locally.
|
|
9
|
+
|
|
10
|
+
## Configure once
|
|
11
|
+
|
|
12
|
+
Commands require Node.js 22+ and two environment variables: `CLI_CENTER_BASE_URL` (Center root or business backend proxy URL, without a trailing `/api`) and `CLI_CENTER_TOKEN` (product-line token). The credential stays in the environment; never place it in prompts, JSON job files, skill files, or published artifacts. Do not read an admin token to supply missing product-line configuration.
|
|
13
|
+
|
|
14
|
+
This Skill accompanies the `@xgspz/cli-skill@0.1.2` documentation/version correction candidate; its commands are unchanged from the published 0.1.1. For the published package, use `npx -y @xgspz/cli-skill@0.1.1` followed by a command below, or an installed 0.1.1 `cli-skill`. To validate the 0.1.2 candidate, use the matching installed command from its local tarball; 0.1.2 is not yet published. Version 0.1.0 lacks the execution-settings commands. Settings still require the matching Center migration and node upgrade; installing the client does not deploy services or migrate a database.
|
|
15
|
+
|
|
16
|
+
## Choose the user's completion contract
|
|
17
|
+
|
|
18
|
+
- **Dispatch and leave:** `cli-skill dispatch --input job.json`. Return the API's `job_id`, `task_ids`, and initial `status`, then stop. Submission means accepted/queued, not completed. Do not poll or come back later unless the user asked for a callback or follow-up.
|
|
19
|
+
- **Dispatch and wait:** `cli-skill dispatch --input job.json --wait --timeout-seconds 60`. Return the wait result. The client wait budget is separate from each task's `timeout_seconds` and includes queue time.
|
|
20
|
+
- **Resume waiting for an existing job:** `cli-skill wait <job_id> --timeout-seconds 60`. For a conversation, also provide `--turn-id <turn_id>` from the creation receipt. Use `cli-skill get <job_id>` for a snapshot.
|
|
21
|
+
|
|
22
|
+
For normal CLI work, omit `mode` and use one or more `tasks`. The API reports final job/task state, usage, and workspace references; its Job query does not include a final reply or download artifact files. Do not invent a report body from `status: succeeded`. `workspace.merged` separately indicates whether a task's output branch merged.
|
|
23
|
+
|
|
24
|
+
If the user needs a text reply from a single remote agent, use `mode: "conversation"` and exactly one task. `dispatch --wait` waits for the **initial turn**, whose ID is returned in `receipt.turn.turn_id`, and returns its `reply`. A conversation Job remains open/idle after a turn completes. Do not wait for the conversation Job to become terminal or close it automatically. Conversation creation cannot include `depends_on`, `done_when`, or `browser_learn`.
|
|
25
|
+
|
|
26
|
+
## Plan useful task boundaries
|
|
27
|
+
|
|
28
|
+
Translate the request into concrete prompts with inputs, intended outputs, acceptance criteria, and output paths. Use a single task for a single work unit; do not split work mechanically. For related steps sharing one workspace, submit one Job with stable task `key` values and `depends_on` for real dependencies. The Center resolves their execution. Independent jobs needing separate workspaces require separate submissions.
|
|
29
|
+
|
|
30
|
+
```json
|
|
31
|
+
{
|
|
32
|
+
"title": "生成业务数据分析报告",
|
|
33
|
+
"workspace": { "context": "输入数据在业务提供的来源中;报告保存到 outputs。" },
|
|
34
|
+
"tasks": [
|
|
35
|
+
{ "key": "analyze", "title": "分析数据", "prompt": "核对输入并分析,保存 outputs/analysis.json。", "browser": false },
|
|
36
|
+
{ "key": "report", "title": "形成报告", "depends_on": ["analyze"], "prompt": "读取 outputs/analysis.json,生成 outputs/report.md,说明证据和限制。", "browser": false }
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Preserve supplied `business_id` for business correlation; initial Job creation is **not idempotent**. Use supported `done_when` rules only when the request needs them; otherwise the Center's `agent_confirm` default applies. `manual` may wait indefinitely for human confirmation, so do not use it for unattended automation. Agent names and effective agent selection are configured by the Center; omit `agent` or use `system` to inherit its configured product-line plan.
|
|
42
|
+
|
|
43
|
+
## Conversation execution settings
|
|
44
|
+
|
|
45
|
+
When the user requests a particular agent, model, reasoning level or execution mode, read `cli-skill execution-config` for new-conversation capabilities. Use available IDs from the returned `agents` catalog, not display labels or a hardcoded model list. Without a requested override, preserve the normal product-line defaults.
|
|
46
|
+
|
|
47
|
+
- **First turn:** only for `mode: "conversation"`, put the selected values in `tasks[0].execution_config`, for example `{"agent":"codex","permission_mode":"plan"}` after confirming those IDs are available. An explicit `tasks[0].agent` must match, or be `system`. One-shot jobs do not accept execution settings. When settings are supplied, the client checks the capability endpoint before creating work; an unavailable or invalid response stops creation. Do not remove the requested settings just to bypass that failure.
|
|
48
|
+
- **Existing conversation:** read `cli-skill execution-config <job_id> --task-id <task_id>`, then save with `cli-skill set-execution-config <job_id> --task-id <task_id> --input settings.json --expected-revision <returned_revision>`. Keep the same task ID throughout; omitting it targets the earliest conversation in the Job. The settings file contains only `agent`, `model`, `reasoning`, `permission_mode`; stdin via `--input -` also works. Saving does not send a message or start AI work.
|
|
49
|
+
|
|
50
|
+
Model, reasoning and permission accept `null` to restore defaults; omitted fields stay unchanged during a save. Switching agent resets omitted options to the new agent's defaults. `execution_config.agent` must be a concrete catalog ID, not `system` or `null`. The service permits saving only while the conversation and queue are idle.
|
|
51
|
+
|
|
52
|
+
Reads and saves are single short HTTP calls, without background polling or automatic write retries. On `execution_config_conflict`, read again and reconcile the new settings with the user's intent; do not overwrite a concurrent change blindly. On `execution_config_busy`, wait for the active/queued work to finish before reading again. If saving reports `uncertain: true`, first read the same conversation to establish whether it was applied. An unavailable endpoint or capability is not a successful save and must not silently change the requested mode.
|
|
53
|
+
|
|
54
|
+
## Browser capability
|
|
55
|
+
|
|
56
|
+
Set `tasks[].browser: true` for tasks that need the execution node's ego browser, and include the starting URL and site work in the prompt. Default is false. `--browser true` or `--browser false` overrides **all** tasks in that submission; omit the flag for a mix. This requests a task browser on the remote node, not on the caller's machine. Availability depends on the node's existing configuration.
|
|
57
|
+
|
|
58
|
+
When a website requires login, verification, or user input, report `needs_input` and the returned job/task references so the user can continue in the existing Center/cli-ui interface. This package does not implement browser viewing, browser control, or secure credential input. Do not ask for passwords in task prompts.
|
|
59
|
+
|
|
60
|
+
## Interpret the response without resubmitting
|
|
61
|
+
|
|
62
|
+
- `outcome: succeeded|failed|cancelled` and `completed: true`: the awaited object reached a terminal state. Preserve failure details and artifact references from the API.
|
|
63
|
+
- `outcome: needs_input`: the API reports a question or observed user-input wait. Return it to the user; do not count it as successful delivery of the requested work.
|
|
64
|
+
- `outcome: timeout`, `timed_out: true`: only local waiting ended. Remote work continues. Return the IDs and resume with `wait` if requested. Do not cancel, close, or create a replacement job.
|
|
65
|
+
- HTTP/network errors: CLI writes structured JSON to stderr. After write uncertainty, do not automatically retry POST/PATCH; reconcile the original request in the Center. After a query error with a known `job_id`, query/wait that same ID. Never replace an existing job just to recover a wait.
|
|
66
|
+
|
|
67
|
+
CLI exit codes: 0 accepted/succeeded, 1 request error/failed, 2 wait timeout, 3 cancelled, 4 needs input. Parse stdout JSON for the actual API status and outcome.
|
|
68
|
+
|
|
69
|
+
For unattended dispatch with notification, the existing API supports `callback: { "url": "https://business.example/callback", "code": "..." }`. Obtain callback configuration from the business system; do not invent a destination. The service sends Bearer-authenticated notifications and may retry delivery, so the business consumer must handle duplicates. Callback codes belong only in protected request input, not in published examples or logs.
|
package/src/client.d.ts
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export interface Callback { url: string; code: string }
|
|
2
|
+
export type DoneWhen = { kind: 'http'; url: string; json_path: string; done?: unknown[]; failed?: unknown[] } | { kind: 'git_diff' | 'agent_confirm' | 'manual' } | { kind: 'all'; rules: DoneWhen[] };
|
|
3
|
+
export interface ExecutionOptions { agent?: string; model?: string | null; reasoning?: string | null; permission_mode?: string | null }
|
|
4
|
+
export interface ExecutionOption { value: string; label: string; sub?: string; group?: string }
|
|
5
|
+
export interface ExecutionConfiguration {
|
|
6
|
+
revision: number; agent: string; model: string | null; reasoning: string | null; permission_mode: string | null;
|
|
7
|
+
agents: Array<{ id: string; label: string; disabled: boolean; capabilities: { models: ExecutionOption[]; reasoningOptions: ExecutionOption[]; permissionModes: ExecutionOption[]; supportsCustomModels: boolean }; defaults: Omit<ExecutionOptions, 'agent'> }>;
|
|
8
|
+
}
|
|
9
|
+
export interface ExecutionTarget { task_id?: string; signal?: AbortSignal }
|
|
10
|
+
export interface TaskInput {
|
|
11
|
+
title: string; prompt: string; key?: string; business_id?: string; agent?: string;
|
|
12
|
+
/** Only supported when JobInput.mode is conversation. */
|
|
13
|
+
execution_config?: ExecutionOptions;
|
|
14
|
+
depends_on?: string[]; done_when?: DoneWhen; timeout_seconds?: number; browser?: boolean;
|
|
15
|
+
browser_playbook?: string; browser_pacing?: unknown; browser_learn?: { callback: Callback };
|
|
16
|
+
callback?: Callback; workspace?: Record<string, unknown>;
|
|
17
|
+
}
|
|
18
|
+
export interface JobInput {
|
|
19
|
+
title: string; tasks: TaskInput[]; mode?: 'conversation'; max_concurrent_tasks?: number;
|
|
20
|
+
workspace?: { version?: 1; name?: string; context?: string; branch?: string }; callback?: Callback;
|
|
21
|
+
}
|
|
22
|
+
export interface DispatchReceipt {
|
|
23
|
+
id?: string; job_id: string; status: string; task_ids?: string[]; tasks?: string[];
|
|
24
|
+
task_refs?: Array<{task_id: string; task_key: string | null; business_id: string | null; seq: number}>;
|
|
25
|
+
turn?: TurnView; [key: string]: unknown;
|
|
26
|
+
}
|
|
27
|
+
export interface TurnView { turn_id: string; task_id: string; status: string; reply?: string | null; asked?: boolean; merged_into_turn_id?: string | null; [key: string]: unknown }
|
|
28
|
+
export interface JobView {
|
|
29
|
+
id: string; status: string; mode?: 'conversation'; tasks: Array<{id: string; status: string; awaiting_input?: boolean | null; [key: string]: unknown}>;
|
|
30
|
+
conversation?: { conversations?: Array<{task_id: string; awaiting_input?: boolean | null; [key: string]: unknown}>; [key: string]: unknown };
|
|
31
|
+
[key: string]: unknown;
|
|
32
|
+
}
|
|
33
|
+
export interface WaitOptions { timeout_seconds?: number; poll_interval_seconds?: number; turn_id?: string; signal?: AbortSignal }
|
|
34
|
+
export interface DispatchOptions { browser?: boolean; signal?: AbortSignal }
|
|
35
|
+
export interface WaitResult {
|
|
36
|
+
job_id: string; status: string; outcome: 'succeeded' | 'failed' | 'cancelled' | 'timeout' | 'needs_input';
|
|
37
|
+
completed: boolean; timed_out: boolean; result_kind: 'job' | 'turn'; job: JobView | null;
|
|
38
|
+
turn?: TurnView; reply?: string | null; receipt?: DispatchReceipt;
|
|
39
|
+
}
|
|
40
|
+
export interface CenterClient {
|
|
41
|
+
dispatch(job: JobInput, options?: DispatchOptions): Promise<DispatchReceipt>;
|
|
42
|
+
dispatchAndWait(job: JobInput, options?: Omit<WaitOptions, 'turn_id'> & DispatchOptions): Promise<WaitResult>;
|
|
43
|
+
getJob(id: string, options?: {signal?: AbortSignal}): Promise<JobView>;
|
|
44
|
+
getTurn(jobId: string, turnId: string, options?: {signal?: AbortSignal}): Promise<TurnView>;
|
|
45
|
+
waitForJob(id: string, options?: WaitOptions): Promise<WaitResult>;
|
|
46
|
+
getExecutionConfig(jobId?: string, options?: ExecutionTarget): Promise<ExecutionConfiguration>;
|
|
47
|
+
setExecutionConfig(jobId: string, changes: ExecutionOptions, options: ExecutionTarget & { expected_revision: number }): Promise<ExecutionConfiguration>;
|
|
48
|
+
}
|
|
49
|
+
export class CenterApiError extends Error { status?: number; code?: string; job_id?: string; uncertain: boolean; receipt?: DispatchReceipt }
|
|
50
|
+
export function validateJob(input: JobInput, options?: DispatchOptions): JobInput;
|
|
51
|
+
export function createClient(config: {baseUrl: string; token: string; request_timeout_seconds?: number; fetch?: typeof fetch}): CenterClient;
|
|
52
|
+
export function createClientFromEnv(env?: Record<string, string | undefined>): CenterClient;
|
package/src/client.js
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
2
|
+
|
|
3
|
+
const terminal = new Set(['succeeded', 'failed', 'cancelled']);
|
|
4
|
+
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
5
|
+
const nonempty = value => typeof value === 'string' && value.trim().length > 0;
|
|
6
|
+
const executionErrors = new Set(['invalid_execution_config', 'execution_config_conflict', 'execution_config_busy', 'execution_config_unavailable', 'execution_config_not_supported', 'execution_agent_conflict', 'execution_agent_unavailable', 'execution_account_unavailable', 'conversation_closed', 'conversation_not_found', 'not_found']);
|
|
7
|
+
function requireValue(condition, message) { if (!condition) throw new TypeError(message); }
|
|
8
|
+
function range(value, min, max, name) {
|
|
9
|
+
requireValue(typeof value === 'number' && Number.isFinite(value) && value >= min && value <= max, `${name} must be between ${min} and ${max}`);
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
function callback(value) {
|
|
13
|
+
requireValue(object(value) && nonempty(value.code), 'callback requires url and code');
|
|
14
|
+
let url;
|
|
15
|
+
try { url = new URL(value.url); } catch { throw new TypeError('callback.url must be an HTTP(S) URL'); }
|
|
16
|
+
requireValue(['https:', 'http:'].includes(url.protocol), 'callback.url must use HTTP(S)');
|
|
17
|
+
}
|
|
18
|
+
function judge(value) {
|
|
19
|
+
requireValue(object(value) && ['http', 'git_diff', 'agent_confirm', 'all', 'manual'].includes(value.kind), 'Unsupported done_when.kind');
|
|
20
|
+
if (value.kind === 'all') {
|
|
21
|
+
requireValue(Array.isArray(value.rules) && value.rules.length > 0, 'done_when.all requires rules');
|
|
22
|
+
value.rules.forEach(judge);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
function executionConfig(value) {
|
|
26
|
+
requireValue(object(value), 'execution_config must be an object');
|
|
27
|
+
const result = {};
|
|
28
|
+
for (const [key, item] of Object.entries(value)) {
|
|
29
|
+
requireValue(['agent', 'model', 'reasoning', 'permission_mode'].includes(key), `Unsupported execution_config field: ${key}`);
|
|
30
|
+
if (item === undefined) continue;
|
|
31
|
+
if (key === 'agent') {
|
|
32
|
+
requireValue(nonempty(item) && item !== 'system', 'execution_config.agent must be a concrete agent from the capability catalog');
|
|
33
|
+
result[key] = item;
|
|
34
|
+
} else {
|
|
35
|
+
requireValue(item === null || (nonempty(item) && item.trim().length <= 2048 && !/[\x00-\x1f\x7f]/.test(item.trim())), `execution_config.${key} must be null or a nonempty value of at most 2048 characters`);
|
|
36
|
+
result[key] = item === null ? null : item.trim();
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return result;
|
|
40
|
+
}
|
|
41
|
+
function executionReply(value, uncertain = false) {
|
|
42
|
+
if (!object(value) || !Number.isSafeInteger(value.revision) || value.revision < 0 || !nonempty(value.agent) || !Array.isArray(value.agents)
|
|
43
|
+
|| ['model', 'reasoning', 'permission_mode'].some(key => value[key] !== null && typeof value[key] !== 'string')) {
|
|
44
|
+
throw new CenterApiError('Center returned invalid execution settings; read the settings again before continuing.', { code: 'invalid_execution_config_response', uncertain });
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Validate wrapper-sensitive fields; the Center remains the API authority. */
|
|
50
|
+
export function validateJob(input, options = {}) {
|
|
51
|
+
requireValue(object(input) && nonempty(input.title), 'job.title is required');
|
|
52
|
+
const allowed = new Set(['title', 'mode', 'tasks', 'workspace', 'callback', 'max_concurrent_tasks']);
|
|
53
|
+
for (const key of Object.keys(input)) requireValue(allowed.has(key), `Unsupported job field: ${key}`);
|
|
54
|
+
requireValue(input.mode === undefined || input.mode === 'conversation', 'Omit mode for a one-shot job, or use conversation');
|
|
55
|
+
requireValue(Array.isArray(input.tasks) && input.tasks.length > 0, 'job.tasks must be a nonempty array');
|
|
56
|
+
if (options.browser !== undefined) requireValue(typeof options.browser === 'boolean', 'browser must be a boolean');
|
|
57
|
+
if (input.callback !== undefined) callback(input.callback);
|
|
58
|
+
if (input.max_concurrent_tasks !== undefined) requireValue(Number.isInteger(input.max_concurrent_tasks) && input.max_concurrent_tasks >= 1 && input.max_concurrent_tasks <= 20, 'max_concurrent_tasks must be an integer from 1 to 20');
|
|
59
|
+
if (input.workspace !== undefined) {
|
|
60
|
+
requireValue(object(input.workspace), 'workspace must be an object');
|
|
61
|
+
for (const key of Object.keys(input.workspace)) requireValue(['version', 'name', 'context', 'branch'].includes(key), `Unsupported workspace field: ${key}`);
|
|
62
|
+
}
|
|
63
|
+
const keys = new Map();
|
|
64
|
+
const tasks = input.tasks.map((task, index) => {
|
|
65
|
+
requireValue(object(task) && nonempty(task.title) && typeof task.prompt === 'string', `tasks[${index}] requires title and prompt`);
|
|
66
|
+
const fields = new Set(['key', 'business_id', 'title', 'prompt', 'agent', 'execution_config', 'depends_on', 'done_when', 'timeout_seconds', 'browser', 'browser_playbook', 'browser_pacing', 'browser_learn', 'callback', 'workspace']);
|
|
67
|
+
for (const key of Object.keys(task)) requireValue(fields.has(key), `Unsupported task field: ${key}`);
|
|
68
|
+
if (task.key !== undefined) requireValue(nonempty(task.key), 'task.key must be nonempty');
|
|
69
|
+
const key = task.key ?? `__seq_${index}`;
|
|
70
|
+
requireValue(!keys.has(key), `Duplicate task key: ${key}`);
|
|
71
|
+
keys.set(key, task.depends_on ?? []);
|
|
72
|
+
if (task.depends_on !== undefined) requireValue(Array.isArray(task.depends_on) && task.depends_on.every(nonempty), 'depends_on must contain task keys');
|
|
73
|
+
if (task.done_when !== undefined) judge(task.done_when);
|
|
74
|
+
if (task.timeout_seconds !== undefined) requireValue(Number.isInteger(task.timeout_seconds) && task.timeout_seconds > 0, 'task.timeout_seconds must be a positive integer');
|
|
75
|
+
if (task.agent !== undefined) requireValue(nonempty(task.agent), 'task.agent must be nonempty');
|
|
76
|
+
let execution;
|
|
77
|
+
if (task.execution_config !== undefined) {
|
|
78
|
+
requireValue(input.mode === 'conversation', 'execution_config requires conversation mode');
|
|
79
|
+
execution = executionConfig(task.execution_config);
|
|
80
|
+
requireValue(!execution.agent || !task.agent || task.agent === 'system' || task.agent === execution.agent, 'task.agent and execution_config.agent must match');
|
|
81
|
+
}
|
|
82
|
+
if (task.business_id !== undefined) requireValue(nonempty(task.business_id), 'task.business_id must be nonempty');
|
|
83
|
+
if (task.callback !== undefined) callback(task.callback);
|
|
84
|
+
const browser = options.browser ?? task.browser ?? false;
|
|
85
|
+
requireValue(typeof browser === 'boolean', 'task.browser must be a boolean');
|
|
86
|
+
if (task.browser_playbook !== undefined || task.browser_pacing !== undefined || task.browser_learn !== undefined) requireValue(browser, 'Browser options require browser: true');
|
|
87
|
+
if (input.mode === 'conversation') {
|
|
88
|
+
requireValue(input.tasks.length === 1, 'conversation requires exactly one task');
|
|
89
|
+
requireValue(task.done_when === undefined && task.depends_on === undefined, 'conversation does not accept done_when or depends_on');
|
|
90
|
+
requireValue(task.browser_learn === undefined, 'conversation does not accept browser_learn');
|
|
91
|
+
}
|
|
92
|
+
return { ...task, browser, ...(execution ? { execution_config: execution } : {}) };
|
|
93
|
+
});
|
|
94
|
+
const visiting = new Set();
|
|
95
|
+
const visited = new Set();
|
|
96
|
+
function visit(key) {
|
|
97
|
+
requireValue(!visiting.has(key), 'depends_on contains a cycle');
|
|
98
|
+
if (visited.has(key)) return;
|
|
99
|
+
requireValue(keys.has(key), `Unknown task dependency: ${key}`);
|
|
100
|
+
visiting.add(key);
|
|
101
|
+
for (const dep of keys.get(key)) visit(dep);
|
|
102
|
+
visiting.delete(key); visited.add(key);
|
|
103
|
+
}
|
|
104
|
+
for (const key of keys.keys()) visit(key);
|
|
105
|
+
return { ...input, tasks };
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export class CenterApiError extends Error {
|
|
109
|
+
constructor(message, { status, code, job_id, uncertain = false } = {}) {
|
|
110
|
+
super(message);
|
|
111
|
+
this.name = 'CenterApiError'; this.status = status; this.code = code;
|
|
112
|
+
this.job_id = job_id; this.uncertain = uncertain;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function createClient({ baseUrl, token, request_timeout_seconds = 30, fetch: fetchImpl = globalThis.fetch } = {}) {
|
|
117
|
+
requireValue(nonempty(baseUrl), 'CLI_CENTER_BASE_URL is required');
|
|
118
|
+
const base = new URL(baseUrl);
|
|
119
|
+
requireValue(['http:', 'https:'].includes(base.protocol) && !base.username && !base.password && !base.search && !base.hash, 'baseUrl must be an HTTP(S) root or proxy URL without credentials/query/fragment');
|
|
120
|
+
requireValue(nonempty(token) && !/[\r\n]/.test(token), 'CLI_CENTER_TOKEN is required and must be a single line');
|
|
121
|
+
range(request_timeout_seconds, 0.001, 300, 'request_timeout_seconds');
|
|
122
|
+
const prefix = base.href.replace(/\/+$/, '');
|
|
123
|
+
async function request(path, { method = 'GET', body, signal } = {}) {
|
|
124
|
+
signal?.throwIfAborted();
|
|
125
|
+
const mutation = method === 'POST' || method === 'PATCH';
|
|
126
|
+
const requestSignal = AbortSignal.any([AbortSignal.timeout(Math.ceil(request_timeout_seconds * 1000)), ...(signal ? [signal] : [])]);
|
|
127
|
+
let response;
|
|
128
|
+
try {
|
|
129
|
+
response = await fetchImpl(`${prefix}${path}`, {
|
|
130
|
+
method, redirect: 'error', signal: requestSignal,
|
|
131
|
+
headers: { authorization: `Bearer ${token}`, accept: 'application/json', ...(body ? { 'content-type': 'application/json' } : {}) },
|
|
132
|
+
...(body ? { body: JSON.stringify(body) } : {}),
|
|
133
|
+
});
|
|
134
|
+
if (!response.ok) {
|
|
135
|
+
const failure = await response.json().catch(() => null);
|
|
136
|
+
const code = executionErrors.has(failure?.error) ? failure.error : 'http_error';
|
|
137
|
+
throw new CenterApiError(`Center API returned HTTP ${response.status}`, { status: response.status, code, uncertain: mutation && response.status >= 500 });
|
|
138
|
+
}
|
|
139
|
+
return await response.json();
|
|
140
|
+
} catch (error) {
|
|
141
|
+
if (error instanceof CenterApiError) throw error;
|
|
142
|
+
if (signal?.aborted && !mutation) throw signal.reason;
|
|
143
|
+
throw new CenterApiError(mutation ? 'Write outcome is unknown; do not automatically resubmit. Read the current state from the Center before retrying.' : 'Center API request failed; retry the same query.', { code: 'request_failed', uncertain: mutation });
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
const idPath = id => { requireValue(nonempty(id) && id.length <= 128, 'A valid id is required'); return encodeURIComponent(id); };
|
|
147
|
+
function waitOptions(options) {
|
|
148
|
+
const seconds = range(options.timeout_seconds ?? 60, 0.001, 3600, 'timeout_seconds');
|
|
149
|
+
const poll = range(options.poll_interval_seconds ?? 3, 0.001, 60, 'poll_interval_seconds');
|
|
150
|
+
if (options.turn_id !== undefined) idPath(options.turn_id);
|
|
151
|
+
return { seconds, poll };
|
|
152
|
+
}
|
|
153
|
+
async function getJob(id, options = {}) { return request(`/api/jobs/${idPath(id)}`, options); }
|
|
154
|
+
async function getTurn(id, turnId, options = {}) { return request(`/api/jobs/${idPath(id)}/turns/${idPath(turnId)}`, options); }
|
|
155
|
+
function executionPath(jobId, taskId) {
|
|
156
|
+
if (jobId === undefined) {
|
|
157
|
+
requireValue(taskId === undefined, 'task_id requires job_id');
|
|
158
|
+
return '/api/execution-config';
|
|
159
|
+
}
|
|
160
|
+
let path = `/api/jobs/${idPath(jobId)}/execution-config`;
|
|
161
|
+
if (taskId !== undefined) {
|
|
162
|
+
requireValue(nonempty(taskId) && taskId.length <= 64, 'task_id must be a nonempty string of at most 64 characters');
|
|
163
|
+
path += `?task_id=${encodeURIComponent(taskId)}`;
|
|
164
|
+
}
|
|
165
|
+
return path;
|
|
166
|
+
}
|
|
167
|
+
async function getExecutionConfig(jobId, options = {}) {
|
|
168
|
+
return executionReply(await request(executionPath(jobId, options.task_id), { signal: options.signal }));
|
|
169
|
+
}
|
|
170
|
+
async function setExecutionConfig(jobId, changes, options = {}) {
|
|
171
|
+
idPath(jobId);
|
|
172
|
+
requireValue(Number.isSafeInteger(options.expected_revision) && options.expected_revision >= 0, 'expected_revision must be a nonnegative safe integer from the last read');
|
|
173
|
+
const body = { ...executionConfig(changes), expected_revision: options.expected_revision };
|
|
174
|
+
try {
|
|
175
|
+
return executionReply(await request(executionPath(jobId, options.task_id), { method: 'PATCH', body, signal: options.signal }), true);
|
|
176
|
+
} catch (error) {
|
|
177
|
+
if (error instanceof CenterApiError) error.job_id = jobId;
|
|
178
|
+
throw error;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
async function dispatch(input, options = {}) {
|
|
182
|
+
const body = validateJob(input, options);
|
|
183
|
+
// Older Centers can silently discard unknown task fields. Check support before creating work with explicit settings.
|
|
184
|
+
if (body.tasks.some(task => task.execution_config !== undefined)) await getExecutionConfig(undefined, { signal: options.signal });
|
|
185
|
+
const receipt = await request('/api/jobs', { method: 'POST', body, signal: options.signal });
|
|
186
|
+
if (!object(receipt) || !nonempty(receipt.job_id ?? receipt.id)) throw new CenterApiError('Center returned no job_id; reconcile submission before retrying', {code:'invalid_receipt', uncertain:true});
|
|
187
|
+
return receipt;
|
|
188
|
+
}
|
|
189
|
+
async function waitForJob(job_id, options = {}) {
|
|
190
|
+
idPath(job_id);
|
|
191
|
+
const { seconds, poll } = waitOptions(options);
|
|
192
|
+
options.signal?.throwIfAborted();
|
|
193
|
+
const deadline = AbortSignal.timeout(Math.ceil(seconds * 1000));
|
|
194
|
+
const signal = AbortSignal.any([deadline, ...(options.signal ? [options.signal] : [])]);
|
|
195
|
+
let job = null, turn, turnId = options.turn_id;
|
|
196
|
+
const mergedIds = new Set();
|
|
197
|
+
const result = outcome => ({ job_id, status: turn?.status ?? job?.status ?? 'unknown', outcome, completed: terminal.has(outcome), timed_out: outcome === 'timeout', result_kind: turnId ? 'turn' : 'job', job, ...(turn ? { turn, reply: turn.reply ?? null } : {}) });
|
|
198
|
+
try {
|
|
199
|
+
while (true) {
|
|
200
|
+
job = await getJob(job_id, { signal });
|
|
201
|
+
if (job.mode === 'conversation') requireValue(turnId, 'Conversation waits require turn_id from the dispatch receipt');
|
|
202
|
+
if (turnId) {
|
|
203
|
+
turn = await getTurn(job_id, turnId, { signal });
|
|
204
|
+
if (turn.status === 'merged') {
|
|
205
|
+
requireValue(nonempty(turn.merged_into_turn_id) && !mergedIds.has(turn.merged_into_turn_id), 'Invalid merged turn chain');
|
|
206
|
+
mergedIds.add(turnId); turnId = turn.merged_into_turn_id; continue;
|
|
207
|
+
}
|
|
208
|
+
if (turn.asked === true) return result('needs_input');
|
|
209
|
+
if (terminal.has(turn.status)) return result(turn.status);
|
|
210
|
+
const conversation = job.conversation?.conversations?.find(item => item.task_id === turn.task_id);
|
|
211
|
+
if (conversation?.awaiting_input === true) return result('needs_input');
|
|
212
|
+
if (terminal.has(job.status)) return result(job.status === 'succeeded' ? 'cancelled' : job.status);
|
|
213
|
+
} else {
|
|
214
|
+
if (terminal.has(job.status)) return result(job.status);
|
|
215
|
+
if (job.tasks?.some(task => task.awaiting_input === true)) return result('needs_input');
|
|
216
|
+
}
|
|
217
|
+
await delay(Math.ceil(poll * 1000), undefined, { signal });
|
|
218
|
+
}
|
|
219
|
+
} catch (error) {
|
|
220
|
+
if (options.signal?.aborted) throw options.signal.reason;
|
|
221
|
+
if (deadline.aborted) return result('timeout');
|
|
222
|
+
if (error instanceof CenterApiError) error.job_id = job_id;
|
|
223
|
+
throw error;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
async function dispatchAndWait(input, options = {}) {
|
|
227
|
+
waitOptions(options); // Reject bad wait arguments before creating a remote job.
|
|
228
|
+
const receipt = await dispatch(input, options);
|
|
229
|
+
const jobId = receipt.job_id ?? receipt.id;
|
|
230
|
+
try {
|
|
231
|
+
const result = await waitForJob(jobId, { ...options, turn_id: receipt.turn?.turn_id });
|
|
232
|
+
return { ...result, receipt };
|
|
233
|
+
} catch (error) {
|
|
234
|
+
if (error && typeof error === 'object') { error.job_id = jobId; error.receipt = receipt; }
|
|
235
|
+
throw error;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return { dispatch, dispatchAndWait, getJob, getTurn, waitForJob, getExecutionConfig, setExecutionConfig };
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
export function createClientFromEnv(env = process.env) {
|
|
242
|
+
return createClient({ baseUrl: env.CLI_CENTER_BASE_URL, token: env.CLI_CENTER_TOKEN });
|
|
243
|
+
}
|
package/src/install.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { cp, mkdir, lstat, rename, rm, mkdtemp } from 'node:fs/promises';
|
|
2
|
+
import { homedir } from 'node:os';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
const source = fileURLToPath(new URL('../skills/cli-task-center/', import.meta.url));
|
|
7
|
+
async function exists(path) { try { await lstat(path); return true; } catch (e) { if (e.code === 'ENOENT') return false; throw e; } }
|
|
8
|
+
|
|
9
|
+
/** Explicit install only; never runs in npm postinstall. */
|
|
10
|
+
export async function installSkill({ agent = 'codex', dir, force = false, home = homedir(), codexHome = process.env.CODEX_HOME } = {}) {
|
|
11
|
+
if (!['codex', 'claude', 'all'].includes(agent)) throw new TypeError('--agent must be codex, claude or all');
|
|
12
|
+
if (dir && agent === 'all') throw new TypeError('--dir cannot be combined with --agent all');
|
|
13
|
+
const roots = dir ? [resolve(dir)] : [
|
|
14
|
+
...(agent !== 'claude' ? [join(codexHome || join(home, '.codex'), 'skills')] : []),
|
|
15
|
+
...(agent !== 'codex' ? [join(home, '.claude', 'skills')] : []),
|
|
16
|
+
];
|
|
17
|
+
const targets = roots.map(root => join(root, 'cli-task-center'));
|
|
18
|
+
// Check all destinations before writing any of them.
|
|
19
|
+
for (const target of targets) {
|
|
20
|
+
if (!(await exists(target))) continue;
|
|
21
|
+
const stat = await lstat(target);
|
|
22
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing to replace a non-directory skill destination: ${target}`);
|
|
23
|
+
if (!force) throw new Error(`Skill already exists: ${target}; use --force to replace this skill only`);
|
|
24
|
+
}
|
|
25
|
+
for (const target of targets) {
|
|
26
|
+
await mkdir(dirname(target), { recursive: true });
|
|
27
|
+
const staging = await mkdtemp(join(dirname(target), '.cli-task-center-'));
|
|
28
|
+
const staged = join(staging, 'skill');
|
|
29
|
+
const backup = join(staging, 'previous');
|
|
30
|
+
let backedUp = false;
|
|
31
|
+
try {
|
|
32
|
+
await cp(source, staged, { recursive: true });
|
|
33
|
+
if (await exists(target)) { await rename(target, backup); backedUp = true; }
|
|
34
|
+
try { await rename(staged, target); } catch (e) { if (backedUp) await rename(backup, target); throw e; }
|
|
35
|
+
} finally { await rm(staging, { recursive: true, force: true }); }
|
|
36
|
+
}
|
|
37
|
+
return { installed: targets };
|
|
38
|
+
}
|