@stelfield/cli 0.0.0-stage → 0.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,44 @@
1
+ // 原子写公共函数:先写临时文件、再 rename 到目标路径。
2
+ //
3
+ // 为什么需要它:直接 fs.writeFileSync(目标路径, ...) 在写入过程中如果进程被杀
4
+ //(cron 超时 / 宿主机断电),会留下"写了一半"的损坏文件——下次读取时 JSON.parse
5
+ // 失败,凭证/游标彻底损坏且无法自愈。
6
+ //
7
+ // rename() 在同一文件系统内是原子操作(要么完全生效、要么完全不生效),
8
+ // 所以"先写临时文件、写完再 rename 覆盖目标"就不会有半写状态。
9
+ //
10
+ // credentials.js(凭证文件)与 poll-state.js(游标文件)共用这个函数。
11
+
12
+ import fs from 'node:fs';
13
+ import path from 'node:path';
14
+
15
+ /**
16
+ * 原子写文件:写到同目录下的临时文件,再 rename 覆盖到目标路径。
17
+ *
18
+ * @param {string} filePath 目标文件完整路径
19
+ * @param {string} content 要写入的内容
20
+ * @param {number} [mode] 文件权限(如 0o600),不传则用系统默认
21
+ */
22
+ export function atomicWriteFile(filePath, content, mode) {
23
+ const dir = path.dirname(filePath);
24
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
25
+
26
+ // 临时文件名带 pid + 随机数,避免同目录并发写互相冲突
27
+ const tmpFile = path.join(
28
+ dir,
29
+ `.${path.basename(filePath)}.tmp.${process.pid}.${Math.random().toString(36).slice(2)}`
30
+ );
31
+
32
+ const writeOpts = mode != null ? { mode } : undefined;
33
+ fs.writeFileSync(tmpFile, content, writeOpts);
34
+ if (mode != null) {
35
+ try {
36
+ fs.chmodSync(tmpFile, mode);
37
+ } catch {
38
+ // 某些文件系统(如 Windows / 网络盘)不支持 chmod —— 不致命,忽略
39
+ }
40
+ }
41
+
42
+ // rename 在同一文件系统下是原子操作:目标文件要么是旧内容、要么是新内容,不会半写
43
+ fs.renameSync(tmpFile, filePath);
44
+ }
@@ -0,0 +1,74 @@
1
+ // stelfield poll --auto-accept: confirm "you were selected" tasks without waking the model.
2
+ //
3
+ // Scope is deliberately small (v0.19.0): only the deterministic step after the agent already
4
+ // applied. The agent applied under its owner's rules; being selected just needs a timely
5
+ // accept + start (platform gives ~30 min, then the task goes back to the pool).
6
+ // It never decides whether a task suits you. Optional --exclude words are a safety net:
7
+ // if the title/description contains one, the task is NOT accepted and is left for a human.
8
+
9
+ /** Selected events: task_status_changed with to=matched and a task id. */
10
+ export function selectedTaskIds(events, pendingConfirms) {
11
+ const out = [];
12
+ // v0.23.32: tasks still waiting for your confirmation come from the poll snapshot, so a lost event does not lose the order
13
+ for (const pc of pendingConfirms || []) {
14
+ if (pc && pc.task_id && !out.includes(pc.task_id)) out.push(pc.task_id);
15
+ }
16
+ for (const e of events || []) {
17
+ const p = e && e.payload;
18
+ if (e && e.event_type === 'task_status_changed' && p && p.to === 'matched' && p.task_id) {
19
+ if (!out.includes(p.task_id)) out.push(p.task_id);
20
+ }
21
+ }
22
+ return out;
23
+ }
24
+
25
+ /** First exclude word found in text, or null. Case-insensitive. */
26
+ export function findExcluded(text, words) {
27
+ const t = String(text || '').toLowerCase();
28
+ for (const w of words || []) {
29
+ if (w && t.includes(String(w).toLowerCase())) return w;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ /**
35
+ * @param {string[]} taskIds
36
+ * @param {object} deps { getTask(id), accept(id), start(id), exclude?: string[] }
37
+ * @returns {Promise<Array<{taskId, status: 'accepted'|'excluded'|'failed', detail?: string}>>}
38
+ * Never throws: a failure on one task must not stop the others or hide the events.
39
+ */
40
+ export async function autoAcceptSelected(taskIds, deps) {
41
+ const results = [];
42
+ for (const taskId of taskIds) {
43
+ try {
44
+ if (deps.exclude && deps.exclude.length) {
45
+ const t = await deps.getTask(taskId);
46
+ const hit = findExcluded(`${t.title || ''}\n${t.description || ''}`, deps.exclude);
47
+ if (hit) {
48
+ results.push({ taskId, status: 'excluded', detail: hit });
49
+ continue;
50
+ }
51
+ }
52
+ await deps.accept(taskId);
53
+ try {
54
+ await deps.start(taskId);
55
+ } catch (e) {
56
+ results.push({ taskId, status: 'failed', detail: `已接单但开工失败:${e.message},请运行 stelfield task start ${taskId}` });
57
+ continue;
58
+ }
59
+ results.push({ taskId, status: 'accepted' });
60
+ } catch (e) {
61
+ results.push({ taskId, status: 'failed', detail: e && e.message ? e.message : String(e) });
62
+ }
63
+ }
64
+ return results;
65
+ }
66
+
67
+ /** Human lines (stdout) so the woken agent knows what already happened. */
68
+ export function formatAutoAccept(results) {
69
+ return results.map((r) => {
70
+ if (r.status === 'accepted') return `🎯 已自动接单并开工:任务 ${r.taskId}(无需再 accept / start,直接开始做)`;
71
+ if (r.status === 'excluded') return `⚠ 任务 ${r.taskId} 命中排除词「${r.detail}」,未自动接单。请告诉用户由他决定;确认要接再运行 stelfield task accept-start ${r.taskId}(限时,超时回池)`;
72
+ return `⚠ 任务 ${r.taskId} 自动接单失败:${r.detail}。若不是「申请不属于你」之类,请尽快手动运行 stelfield task accept-start ${r.taskId}`;
73
+ });
74
+ }
package/src/brand.js ADDED
@@ -0,0 +1,20 @@
1
+ // AUTO-GENERATED by scripts/brand-apply.mjs from brand.json - do not edit by hand.
2
+ // CLI side of the brand. Change brand.json and rerun the script instead.
3
+ export const BRAND = {
4
+ "name": "Stelfield",
5
+ "siteUrl": "https://a2a.linger.chimap.cn",
6
+ "docsUrl": "https://a2a.linger.chimap.cn/docs/",
7
+ "npmScope": "@stelfield",
8
+ "cliPackage": "@stelfield/cli",
9
+ "cliCommand": "stelfield",
10
+ "configDirName": ".stelfield",
11
+ "envPrefix": "STELFIELD"
12
+ };
13
+
14
+ /** Environment variable for a setting: envName("CONFIG_DIR") -> "STELFIELD_CONFIG_DIR". */
15
+ export function envName(key) {
16
+ return BRAND.envPrefix + "_" + key;
17
+ }
18
+
19
+ /** Default API base URL (override with the env var from envName("BASE_URL")). */
20
+ export const DEFAULT_BASE_URL = BRAND.siteUrl + "/api/v1";
@@ -0,0 +1,175 @@
1
+ import { BRAND } from "./brand.js";
2
+ // -*- stelfield auth login 本地 loopback 回调页(三状态 HTML)-*-
3
+ //
4
+ // 设计:对齐 A2A 平台网页设计规范(a2a-frontend/src/shared/tokens.css)——
5
+ // 白底(#F5F6F8 / #FFFFFF)+ 近黑文字(#0F1419 / #4B5563)+ 黑白为主、克制。
6
+ // 不用桌面端 Aria 琥珀金(那是另一条产品线的品牌色 · 2026-06-20 产品负责人纠正)。
7
+ // 参考飞书/主流 OAuth 成功页:居中、一屏、纯内联零外部资源(localhost 离线可渲染)、
8
+ // 图标用内联 SVG、动画包 prefers-reduced-motion 降级、文案极简(每页 1 句 caption)。
9
+ //
10
+ // 三个常量对应 oauth.js loopback handler 的三个 res.end:
11
+ // SUCCESS — 拿到 code(绑定成功 · 带「去我的主页」按钮·指向 user-profile 旗下 Agent·非站点根/任务大厅)
12
+ // ERROR — error(授权未完成 · 回命令行重试)
13
+ // MISSING — 缺授权码(回调异常)
14
+
15
+ const CALLBACK_HTML_SUCCESS = `<!DOCTYPE html>
16
+ <html lang="zh-CN">
17
+ <head>
18
+ <meta charset="utf-8">
19
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
20
+ <title>绑定成功 · ${BRAND.name}</title>
21
+ <style>
22
+ *{margin:0;padding:0;box-sizing:border-box}
23
+ html,body{height:100%}
24
+ body{
25
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
26
+ background:#F5F6F8;color:#0F1419;
27
+ display:flex;align-items:center;justify-content:center;
28
+ min-height:100vh;min-height:100dvh;
29
+ padding:24px;line-height:1.6;-webkit-font-smoothing:antialiased;
30
+ }
31
+ .card{
32
+ width:100%;max-width:380px;text-align:center;
33
+ padding:8px;opacity:0;transform:translateY(10px);
34
+ animation:rise .6s cubic-bezier(.16,1,.3,1) .05s forwards;
35
+ }
36
+ .badge{
37
+ width:72px;height:72px;border-radius:999px;margin:0 auto 32px;
38
+ display:flex;align-items:center;justify-content:center;
39
+ background:rgba(15,20,25,.06);
40
+ }
41
+ .badge svg{width:34px;height:34px}
42
+ .check{
43
+ fill:none;stroke:#0F1419;stroke-width:2.5;
44
+ stroke-linecap:round;stroke-linejoin:round;
45
+ stroke-dasharray:32;stroke-dashoffset:32;
46
+ animation:draw .5s cubic-bezier(.65,0,.45,1) .35s forwards;
47
+ }
48
+ h1{font-size:21px;font-weight:500;letter-spacing:.02em;margin-bottom:12px}
49
+ p{font-size:14px;color:#4B5563;letter-spacing:.02em;margin-bottom:36px}
50
+ .btn{
51
+ display:inline-block;padding:13px 32px;border-radius:8px;
52
+ background:#0F1419;color:#fff;
53
+ font-size:14px;font-weight:500;letter-spacing:.02em;text-decoration:none;
54
+ transition:transform .2s ease-out,background .2s ease-out;
55
+ }
56
+ .btn:hover{background:#2C3340}
57
+ .btn:active{transform:scale(.97)}
58
+ @keyframes rise{to{opacity:1;transform:translateY(0)}}
59
+ @keyframes draw{to{stroke-dashoffset:0}}
60
+ @media (prefers-reduced-motion:reduce){
61
+ .card{animation:none;opacity:1;transform:none}
62
+ .check{animation:none;stroke-dashoffset:0}
63
+ .btn{transition:none}
64
+ }
65
+ </style>
66
+ </head>
67
+ <body>
68
+ <main class="card">
69
+ <div class="badge" aria-hidden="true">
70
+ <svg viewBox="0 0 24 24"><path class="check" d="M5 13l4 4L19 7"/></svg>
71
+ </div>
72
+ <h1>绑定成功 · 已连接 ${BRAND.name}</h1>
73
+ <p>可以关闭此页面,回到命令行</p>
74
+ <a class="btn" href="${BRAND.siteUrl}/src/pages/user-profile/index.html#tab=agents">去我的主页</a>
75
+ </main>
76
+ </body>
77
+ </html>`;
78
+
79
+ const CALLBACK_HTML_ERROR = `<!DOCTYPE html>
80
+ <html lang="zh-CN">
81
+ <head>
82
+ <meta charset="utf-8">
83
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
84
+ <title>授权未完成 · ${BRAND.name}</title>
85
+ <style>
86
+ *{margin:0;padding:0;box-sizing:border-box}
87
+ html,body{height:100%}
88
+ body{
89
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
90
+ background:#F5F6F8;color:#0F1419;
91
+ display:flex;align-items:center;justify-content:center;
92
+ min-height:100vh;min-height:100dvh;
93
+ padding:24px;line-height:1.6;-webkit-font-smoothing:antialiased;
94
+ }
95
+ .card{
96
+ width:100%;max-width:380px;text-align:center;padding:8px;
97
+ opacity:0;transform:translateY(10px);
98
+ animation:rise .6s cubic-bezier(.16,1,.3,1) .05s forwards;
99
+ }
100
+ .badge{
101
+ width:72px;height:72px;border-radius:999px;margin:0 auto 32px;
102
+ display:flex;align-items:center;justify-content:center;
103
+ background:rgba(15,20,25,.05);
104
+ }
105
+ .badge svg{width:32px;height:32px}
106
+ .mark{fill:none;stroke:#0F1419;stroke-width:2.2;stroke-linecap:round;opacity:.55}
107
+ h1{font-size:21px;font-weight:500;letter-spacing:.02em;margin-bottom:12px}
108
+ p{font-size:14px;color:#4B5563;letter-spacing:.02em}
109
+ @keyframes rise{to{opacity:1;transform:translateY(0)}}
110
+ @media (prefers-reduced-motion:reduce){.card{animation:none;opacity:1;transform:none}}
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <main class="card">
115
+ <div class="badge" aria-hidden="true">
116
+ <svg viewBox="0 0 24 24">
117
+ <line class="mark" x1="12" y1="7" x2="12" y2="13"/>
118
+ <line class="mark" x1="12" y1="17" x2="12" y2="17"/>
119
+ </svg>
120
+ </div>
121
+ <h1>授权未完成</h1>
122
+ <p>可以关闭此页面,回到命令行重试</p>
123
+ </main>
124
+ </body>
125
+ </html>`;
126
+
127
+ const CALLBACK_HTML_MISSING = `<!DOCTYPE html>
128
+ <html lang="zh-CN">
129
+ <head>
130
+ <meta charset="utf-8">
131
+ <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
132
+ <title>回调异常 · ${BRAND.name}</title>
133
+ <style>
134
+ *{margin:0;padding:0;box-sizing:border-box}
135
+ html,body{height:100%}
136
+ body{
137
+ font-family:-apple-system,BlinkMacSystemFont,"SF Pro Display","SF Pro Text","Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",system-ui,sans-serif;
138
+ background:#F5F6F8;color:#0F1419;
139
+ display:flex;align-items:center;justify-content:center;
140
+ min-height:100vh;min-height:100dvh;
141
+ padding:24px;line-height:1.6;-webkit-font-smoothing:antialiased;
142
+ }
143
+ .card{
144
+ width:100%;max-width:380px;text-align:center;padding:8px;
145
+ opacity:0;transform:translateY(10px);
146
+ animation:rise .6s cubic-bezier(.16,1,.3,1) .05s forwards;
147
+ }
148
+ .badge{
149
+ width:72px;height:72px;border-radius:999px;margin:0 auto 32px;
150
+ display:flex;align-items:center;justify-content:center;
151
+ background:rgba(15,20,25,.05);
152
+ }
153
+ .badge svg{width:30px;height:30px}
154
+ .mark{fill:none;stroke:#0F1419;stroke-width:2.2;stroke-linecap:round;stroke-linejoin:round;opacity:.55}
155
+ h1{font-size:21px;font-weight:500;letter-spacing:.02em;margin-bottom:12px}
156
+ p{font-size:14px;color:#4B5563;letter-spacing:.02em}
157
+ @keyframes rise{to{opacity:1;transform:translateY(0)}}
158
+ @media (prefers-reduced-motion:reduce){.card{animation:none;opacity:1;transform:none}}
159
+ </style>
160
+ </head>
161
+ <body>
162
+ <main class="card">
163
+ <div class="badge" aria-hidden="true">
164
+ <svg viewBox="0 0 24 24">
165
+ <path class="mark" d="M9.2 9.2a2.8 2.8 0 1 1 4.3 2.6c-.9.6-1.5 1.1-1.5 2.2"/>
166
+ <line class="mark" x1="12" y1="17.5" x2="12" y2="17.5"/>
167
+ </svg>
168
+ </div>
169
+ <h1>回调异常,缺少授权码</h1>
170
+ <p>可以关闭此页面</p>
171
+ </main>
172
+ </body>
173
+ </html>`;
174
+
175
+ export { CALLBACK_HTML_SUCCESS, CALLBACK_HTML_ERROR, CALLBACK_HTML_MISSING };