@lyra-ai/toolkit 0.2.5 → 0.2.6
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/bin/toolkit.mjs +4 -4
- package/package.json +1 -1
- package/src/flush.mjs +243 -221
- package/src/hook.mjs +35 -21
- package/src/install.mjs +69 -30
- package/src/shared/backlog.mjs +96 -0
- package/src/shared/config.mjs +17 -0
- package/src/shared/http.mjs +29 -0
- package/src/shared/lock.mjs +79 -0
- package/src/shared/settle.mjs +20 -0
- package/src/shared/zip.mjs +89 -0
- package/src/status.mjs +103 -36
- package/src/uninstall.mjs +26 -13
package/src/install.mjs
CHANGED
|
@@ -1,20 +1,33 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
|
-
* install.mjs — `toolkit install claudecode --registry <url>`
|
|
4
|
-
*
|
|
3
|
+
* install.mjs — `toolkit install --agent claudecode --registry <url>`
|
|
4
|
+
*
|
|
5
|
+
* 用 Claude Code 官方「Skills 目录 plugin」机制(@skills-dir)注册:
|
|
6
|
+
* ~/.claude/skills/toolkit/ ← 含 .claude-plugin/plugin.json + hooks/hooks.json
|
|
7
|
+
* + hook.mjs + flush.mjs + shared/
|
|
8
|
+
*
|
|
9
|
+
* 个人范围的 @skills-dir plugin 在每个项目自动加载、无需市场、无需安装步骤、
|
|
10
|
+
* 无需 trust 弹窗,且 defaultEnabled 默认 true —— 因此**不写 settings.json 的
|
|
11
|
+
* enabledPlugins**(那对 @skills-dir 既不需要、又会被用户切供应商时冲掉)。
|
|
12
|
+
*
|
|
13
|
+
* 迁移:若检测到旧版残留(裸目录 ~/.claude/plugins/toolkit 或 settings.json 里
|
|
14
|
+
* 失效的 toolkit@local 条目),一并清理。
|
|
15
|
+
*
|
|
16
|
+
* 配置走 ~/.toolkit/(config.json + backlog.json + logs/)。
|
|
5
17
|
*/
|
|
6
18
|
import fs from 'node:fs';
|
|
7
19
|
import path from 'node:path';
|
|
8
20
|
import os from 'node:os';
|
|
9
21
|
import { randomUUID } from 'node:crypto';
|
|
10
22
|
import { fileURLToPath } from 'node:url';
|
|
11
|
-
import { load as loadConfig, save as saveConfig,
|
|
23
|
+
import { load as loadConfig, save as saveConfig, LOG_DIR } from './shared/config.mjs';
|
|
12
24
|
|
|
13
25
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
26
|
const HOME = os.homedir();
|
|
15
|
-
const PLUGIN_DIR = path.join(HOME, '.claude', '
|
|
27
|
+
const PLUGIN_DIR = path.join(HOME, '.claude', 'skills', 'toolkit');
|
|
28
|
+
const OLD_PLUGIN_DIR = path.join(HOME, '.claude', 'plugins', 'toolkit');
|
|
16
29
|
const SETTINGS_PATH = path.join(HOME, '.claude', 'settings.json');
|
|
17
|
-
const
|
|
30
|
+
const OLD_PLUGIN_KEY = 'toolkit@local';
|
|
18
31
|
|
|
19
32
|
const PLUGIN_JSON = {
|
|
20
33
|
name: 'toolkit',
|
|
@@ -35,7 +48,34 @@ const HOOKS_JSON = {
|
|
|
35
48
|
},
|
|
36
49
|
};
|
|
37
50
|
|
|
38
|
-
|
|
51
|
+
function rmrf(dir) {
|
|
52
|
+
if (!fs.existsSync(dir)) return;
|
|
53
|
+
for (const entry of fs.readdirSync(dir)) {
|
|
54
|
+
const p = path.join(dir, entry);
|
|
55
|
+
try {
|
|
56
|
+
if (fs.statSync(p).isDirectory()) rmrf(p);
|
|
57
|
+
else fs.unlinkSync(p);
|
|
58
|
+
} catch {}
|
|
59
|
+
}
|
|
60
|
+
try { fs.rmdirSync(dir); } catch {}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** 清理旧版残留:裸目录 plugins/toolkit + settings.json 里失效的 toolkit@local。 */
|
|
64
|
+
function cleanupLegacy() {
|
|
65
|
+
let cleaned = [];
|
|
66
|
+
if (fs.existsSync(OLD_PLUGIN_DIR)) { rmrf(OLD_PLUGIN_DIR); cleaned.push(OLD_PLUGIN_DIR); }
|
|
67
|
+
try {
|
|
68
|
+
const settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8'));
|
|
69
|
+
if (settings.enabledPlugins && settings.enabledPlugins[OLD_PLUGIN_KEY] !== undefined) {
|
|
70
|
+
delete settings.enabledPlugins[OLD_PLUGIN_KEY];
|
|
71
|
+
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
72
|
+
cleaned.push(`${OLD_PLUGIN_KEY} (settings.json)`);
|
|
73
|
+
}
|
|
74
|
+
} catch {}
|
|
75
|
+
return cleaned;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function install(registry, opts = {}) {
|
|
39
79
|
const agent = opts.agent || 'claudecode';
|
|
40
80
|
// 1. 检测 Claude Code
|
|
41
81
|
// (后续 --agent qoder 时,检测 ~/.qoder)
|
|
@@ -45,11 +85,14 @@ export function install(registry, opts = {}) {
|
|
|
45
85
|
process.exit(1);
|
|
46
86
|
}
|
|
47
87
|
|
|
48
|
-
// 2.
|
|
88
|
+
// 2. 清理旧版残留(若有,静默)
|
|
89
|
+
cleanupLegacy();
|
|
90
|
+
|
|
91
|
+
// 3. 创建 @skills-dir 插件目录结构
|
|
49
92
|
fs.mkdirSync(path.join(PLUGIN_DIR, '.claude-plugin'), { recursive: true });
|
|
50
93
|
fs.mkdirSync(path.join(PLUGIN_DIR, 'hooks'), { recursive: true });
|
|
51
94
|
|
|
52
|
-
//
|
|
95
|
+
// 4. 写插件清单 + hooks
|
|
53
96
|
fs.writeFileSync(
|
|
54
97
|
path.join(PLUGIN_DIR, '.claude-plugin', 'plugin.json'),
|
|
55
98
|
JSON.stringify(PLUGIN_JSON, null, 2)
|
|
@@ -59,39 +102,35 @@ export function install(registry, opts = {}) {
|
|
|
59
102
|
JSON.stringify(HOOKS_JSON, null, 2)
|
|
60
103
|
);
|
|
61
104
|
|
|
62
|
-
//
|
|
105
|
+
// 5. 复制 hook.mjs + flush.mjs + shared/ 到插件目录(自包含)
|
|
63
106
|
const srcDir = __dirname;
|
|
64
107
|
fs.copyFileSync(path.join(srcDir, 'hook.mjs'), path.join(PLUGIN_DIR, 'hook.mjs'));
|
|
65
108
|
fs.copyFileSync(path.join(srcDir, 'flush.mjs'), path.join(PLUGIN_DIR, 'flush.mjs'));
|
|
109
|
+
const sharedSrc = path.join(srcDir, 'shared');
|
|
110
|
+
const sharedDst = path.join(PLUGIN_DIR, 'shared');
|
|
111
|
+
fs.mkdirSync(sharedDst, { recursive: true });
|
|
112
|
+
for (const f of fs.readdirSync(sharedSrc)) {
|
|
113
|
+
if (f.endsWith('.mjs')) fs.copyFileSync(path.join(sharedSrc, f), path.join(sharedDst, f));
|
|
114
|
+
}
|
|
66
115
|
|
|
67
|
-
//
|
|
116
|
+
// 6. 创建 ~/.toolkit/ 配置(走 shared/config.mjs)
|
|
68
117
|
fs.mkdirSync(LOG_DIR, { recursive: true });
|
|
69
118
|
const config = loadConfig();
|
|
70
119
|
if (!config.uid) config.uid = randomUUID();
|
|
71
120
|
config.registry = registry.replace(/\/$/, '');
|
|
72
121
|
config.contexts = { metrics: '/ai-metrics', skills: '/skillhub' };
|
|
73
|
-
config.installed_at = new Date().toISOString();
|
|
74
122
|
saveConfig(config);
|
|
75
123
|
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
124
|
+
// installed_at 的唯一来源是 backlog.json(下一步 ensureBacklog 写入),
|
|
125
|
+
// config.json 不再承载该字段(单一真相源)。
|
|
126
|
+
|
|
127
|
+
// 7. backlog.json 初始化(installed_at = now;若已存在则保留)
|
|
128
|
+
const { ensureBacklog } = await import('./shared/backlog.mjs');
|
|
129
|
+
ensureBacklog();
|
|
80
130
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
try { settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf-8')); } catch {}
|
|
84
|
-
if (!settings.enabledPlugins) settings.enabledPlugins = {};
|
|
85
|
-
settings.enabledPlugins[PLUGIN_KEY] = true;
|
|
86
|
-
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
|
|
131
|
+
// 注:不写 settings.json 的 enabledPlugins。@skills-dir plugin 默认启用,
|
|
132
|
+
// 写 enabledPlugins 反而会被用户切供应商时连累冲掉。
|
|
87
133
|
|
|
88
|
-
// 8. 输出
|
|
89
|
-
console.log('✅
|
|
90
|
-
console.log(` 插件目录: ${PLUGIN_DIR}`);
|
|
91
|
-
console.log(` 配置文件: ${path.join(TOOLKIT_DIR, 'config.json')}`);
|
|
92
|
-
console.log(` 身份 UID: ${config.uid.slice(0, 12)}...`);
|
|
93
|
-
console.log(` 平台地址: ${config.registry}${config.contexts.metrics}/`);
|
|
94
|
-
console.log(`\n Claude Code 会话结束后,数据自动上报到上述地址。`);
|
|
95
|
-
console.log(` 手动触发: toolkit flush`);
|
|
96
|
-
console.log(` 查看状态: toolkit status`);
|
|
134
|
+
// 8. 输出(极简:只报成败)
|
|
135
|
+
console.log('✅ 安装完成');
|
|
97
136
|
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* shared/backlog.mjs — 上传 backlog 状态模块
|
|
3
|
+
*
|
|
4
|
+
* 管理 toolkit 客户端唯一的上传状态文件:~/.toolkit/backlog.json
|
|
5
|
+
*
|
|
6
|
+
* 结构:
|
|
7
|
+
* {
|
|
8
|
+
* installed_at: string|null, // ISO,首次创建时间(安装边界 / drain 起点)
|
|
9
|
+
* last_sweep: string|null, // ISO,上次 sweep 时间(可选)
|
|
10
|
+
* cursors: { // 每个 project-dir 的 drain 游标
|
|
11
|
+
* [projectDir]: { mtime:number, name:string }
|
|
12
|
+
* }
|
|
13
|
+
* }
|
|
14
|
+
*
|
|
15
|
+
* 游标语义(逆时间戳 drain):
|
|
16
|
+
* - 每个项目维护一个「已处理到哪里」的边界 {mtime, name}
|
|
17
|
+
* - drain 按时间戳降序遍历,处理完一个文件就把游标推进到该文件的 {mtime, name}
|
|
18
|
+
* - compareKey 为升序(older = 更小);待处理 = 比游标更小
|
|
19
|
+
* - 初始游标 = topCursor(installedMs) = {mtime:installedMs, name:''}
|
|
20
|
+
* 令 backlog 内所有文件(mtime ≤ installed)都更小 = 都待处理
|
|
21
|
+
*/
|
|
22
|
+
import fs from 'node:fs';
|
|
23
|
+
import path from 'node:path';
|
|
24
|
+
import { TOOLKIT_DIR } from './config.mjs';
|
|
25
|
+
|
|
26
|
+
export const BACKLOG_PATH = path.join(TOOLKIT_DIR, 'backlog.json');
|
|
27
|
+
|
|
28
|
+
const DEFAULT_STATE = () => ({ installed_at: null, last_sweep: null, cursors: {} });
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* 读取整个 backlog 状态。文件缺失或坏 JSON 返回默认结构。
|
|
32
|
+
*/
|
|
33
|
+
export function loadBacklog() {
|
|
34
|
+
try {
|
|
35
|
+
const raw = JSON.parse(fs.readFileSync(BACKLOG_PATH, 'utf-8'));
|
|
36
|
+
return {
|
|
37
|
+
installed_at: raw.installed_at ?? null,
|
|
38
|
+
last_sweep: raw.last_sweep ?? null,
|
|
39
|
+
cursors: raw.cursors && typeof raw.cursors === 'object' ? raw.cursors : {},
|
|
40
|
+
};
|
|
41
|
+
} catch {
|
|
42
|
+
return DEFAULT_STATE();
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* 原子写入整个 backlog 状态(同盘 tmp + rename)。
|
|
48
|
+
* 失败静默:调用方内存游标仍推进,下次幂等重算。
|
|
49
|
+
*
|
|
50
|
+
* @param {object} state 待写入的 backlog 状态
|
|
51
|
+
* @param {(msg:string)=>void} [logFn] 写盘失败时用于告警的日志函数,
|
|
52
|
+
* 默认 console.warn。仍不抛出。
|
|
53
|
+
*/
|
|
54
|
+
export function saveBacklog(state, logFn = (m) => console.warn(m)) {
|
|
55
|
+
try {
|
|
56
|
+
fs.mkdirSync(TOOLKIT_DIR, { recursive: true });
|
|
57
|
+
const tmp = BACKLOG_PATH + '.tmp';
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(state, null, 2));
|
|
59
|
+
fs.renameSync(tmp, BACKLOG_PATH);
|
|
60
|
+
} catch (e) {
|
|
61
|
+
// 调用方决定;内存游标仍推进,下次幂等重算。仅告警,不抛出。
|
|
62
|
+
logFn(`WARN: saveBacklog failed: ${e && e.message ? e.message : e}`);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* 首次运行迁移:若 backlog.json 不存在或无 installed_at,
|
|
68
|
+
* 创建并落盘 installed_at = now。返回当前状态(可能已被前次调用初始化)。
|
|
69
|
+
*/
|
|
70
|
+
export function ensureBacklog() {
|
|
71
|
+
const st = loadBacklog();
|
|
72
|
+
if (!st.installed_at) {
|
|
73
|
+
st.installed_at = new Date().toISOString();
|
|
74
|
+
saveBacklog(st);
|
|
75
|
+
}
|
|
76
|
+
return st;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* 升序比较两个 {mtime, name} 键:older 更小。
|
|
81
|
+
* mtime 数值升序优先;mtime 相同时按 name 字典序。
|
|
82
|
+
*/
|
|
83
|
+
export function compareKey(a, b) {
|
|
84
|
+
if (a.mtime !== b.mtime) return a.mtime - b.mtime;
|
|
85
|
+
if (a.name < b.name) return -1;
|
|
86
|
+
if (a.name > b.name) return 1;
|
|
87
|
+
return 0;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 初始游标 = 安装边界(ms)。所有 backlog 文件(mtime ≤ installed)
|
|
92
|
+
* 都更小 = 都待处理。name:'' 保证同 mtime 的文件名都比游标大。
|
|
93
|
+
*/
|
|
94
|
+
export function topCursor(installedMs) {
|
|
95
|
+
return { mtime: installedMs, name: '' };
|
|
96
|
+
}
|
package/src/shared/config.mjs
CHANGED
|
@@ -123,3 +123,20 @@ export function unsetZentao(key) {
|
|
|
123
123
|
/** 导出键名映射(给 zentao 迁移用)。 */
|
|
124
124
|
export { ZENTAO_KEY_MAP };
|
|
125
125
|
|
|
126
|
+
// ── upload 段(给 flush 用)──────────────────────────
|
|
127
|
+
export const UPLOAD_DEFAULTS = Object.freeze({
|
|
128
|
+
sweep_interval_sec: 600,
|
|
129
|
+
chunk_max_files: 20,
|
|
130
|
+
chunk_max_bytes: 30 * 1024 * 1024,
|
|
131
|
+
pacing_ms: 200,
|
|
132
|
+
settle_ms: 500,
|
|
133
|
+
settle_max_attempts: 3,
|
|
134
|
+
http_timeout_sec: 30,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** 读 upload 段,缺省走 UPLOAD_DEFAULTS。 */
|
|
138
|
+
export function getUploadConfig() {
|
|
139
|
+
const u = load().upload || {};
|
|
140
|
+
return { ...UPLOAD_DEFAULTS, ...u };
|
|
141
|
+
}
|
|
142
|
+
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** POST zip 到平台,带指数退避重试。返回结果对象,不抛。 */
|
|
2
|
+
export async function postZip(url, zipBuf, uid, opts = {}) {
|
|
3
|
+
const timeoutSec = opts.timeoutSec ?? 30;
|
|
4
|
+
const delays = opts.delays ?? [2000, 4000, 8000];
|
|
5
|
+
for (let attempt = 0; attempt <= delays.length; attempt++) {
|
|
6
|
+
try {
|
|
7
|
+
const controller = new AbortController();
|
|
8
|
+
const t = setTimeout(() => controller.abort(), timeoutSec * 1000);
|
|
9
|
+
const res = await fetch(url, {
|
|
10
|
+
method: 'POST',
|
|
11
|
+
headers: { 'Content-Type': 'application/zip', 'X-Uid': uid },
|
|
12
|
+
body: zipBuf,
|
|
13
|
+
signal: controller.signal,
|
|
14
|
+
});
|
|
15
|
+
clearTimeout(t);
|
|
16
|
+
if (res.ok) return { ok: true, status: res.status, body: await res.json().catch(() => ({})) };
|
|
17
|
+
if (res.status >= 400 && res.status < 500 && res.status !== 429)
|
|
18
|
+
return { ok: false, status: res.status, error: `HTTP ${res.status}` };
|
|
19
|
+
throw new Error(`HTTP ${res.status}`);
|
|
20
|
+
} catch (e) {
|
|
21
|
+
if (attempt < delays.length) {
|
|
22
|
+
const jitter = (Math.random() - 0.5) * delays[attempt] * 0.5;
|
|
23
|
+
await new Promise(r => setTimeout(r, Math.max(500, delays[attempt] + jitter)));
|
|
24
|
+
} else {
|
|
25
|
+
return { ok: false, status: 0, error: e.message };
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* 探测 pid 是否存活。`process.kill(pid, 0)` 在 Windows/macOS/Linux 均适用。
|
|
5
|
+
* @param {number} pid
|
|
6
|
+
* @returns {boolean}
|
|
7
|
+
*/
|
|
8
|
+
function pidAlive(pid) {
|
|
9
|
+
try {
|
|
10
|
+
process.kill(pid, 0);
|
|
11
|
+
return true;
|
|
12
|
+
} catch {
|
|
13
|
+
return false;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 判断锁文件是否为僵尸:
|
|
19
|
+
* - 读取/解析失败 → 视为僵尸(可安全接管)
|
|
20
|
+
* - pid 已死 → 僵尸
|
|
21
|
+
* - 锁龄超过 staleMs → 僵尸
|
|
22
|
+
* - 否则 → 存活,不可接管
|
|
23
|
+
* @param {string} lockPath
|
|
24
|
+
* @param {number} staleMs
|
|
25
|
+
* @returns {boolean}
|
|
26
|
+
*/
|
|
27
|
+
function isStale(lockPath, staleMs) {
|
|
28
|
+
let content;
|
|
29
|
+
try {
|
|
30
|
+
content = JSON.parse(fs.readFileSync(lockPath, 'utf-8'));
|
|
31
|
+
} catch {
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
if (content && content.pid && pidAlive(content.pid) && (Date.now() - (content.ts || 0)) < staleMs) {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
return true;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* 抢 O_EXCL 锁。原子创建锁文件,内容为 `JSON({pid, ts})`。
|
|
42
|
+
* 冲突时若现存锁为僵尸(pid 已死 / 锁龄超时 / 损坏)则接管,否则放弃。
|
|
43
|
+
*
|
|
44
|
+
* @param {string} lockPath 锁文件路径
|
|
45
|
+
* @param {{ staleMs?: number }} [opts] 选项,`staleMs` 默认 30 分钟
|
|
46
|
+
* @returns {(() => void)|null} 成功返回 release()(unlink,幂等);失败返回 null
|
|
47
|
+
*/
|
|
48
|
+
export function acquireLock(lockPath, opts = {}) {
|
|
49
|
+
const staleMs = opts.staleMs ?? 30 * 60 * 1000;
|
|
50
|
+
|
|
51
|
+
const take = () => {
|
|
52
|
+
try {
|
|
53
|
+
const fd = fs.openSync(lockPath, 'wx');
|
|
54
|
+
try {
|
|
55
|
+
fs.writeSync(fd, JSON.stringify({ pid: process.pid, ts: Date.now() }));
|
|
56
|
+
} finally {
|
|
57
|
+
fs.closeSync(fd);
|
|
58
|
+
}
|
|
59
|
+
let released = false;
|
|
60
|
+
return () => {
|
|
61
|
+
if (released) return;
|
|
62
|
+
released = true;
|
|
63
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
64
|
+
};
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
const rel = take();
|
|
71
|
+
if (rel) return rel;
|
|
72
|
+
|
|
73
|
+
if (isStale(lockPath, staleMs)) {
|
|
74
|
+
try { fs.unlinkSync(lockPath); } catch {}
|
|
75
|
+
return take();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return null;
|
|
79
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/** 两次 stat 比较 size;稳定才认为文件写完。避免 async flush 滞后的残缺快照。 */
|
|
4
|
+
export async function isSettled(filePath, opts = {}) {
|
|
5
|
+
const settleMs = opts.settleMs ?? 500;
|
|
6
|
+
const maxAttempts = opts.maxAttempts ?? 3;
|
|
7
|
+
// maxAttempts <= 1:单次 stat,无法比较;存在即视为稳定。
|
|
8
|
+
if (maxAttempts <= 1) {
|
|
9
|
+
try { fs.statSync(filePath); return true; } catch { return false; }
|
|
10
|
+
}
|
|
11
|
+
let prev = -1;
|
|
12
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
13
|
+
let size;
|
|
14
|
+
try { size = fs.statSync(filePath).size; } catch { return false; }
|
|
15
|
+
if (i > 0 && size === prev) return true;
|
|
16
|
+
prev = size;
|
|
17
|
+
if (i < maxAttempts - 1) await new Promise(r => setTimeout(r, settleMs));
|
|
18
|
+
}
|
|
19
|
+
return false;
|
|
20
|
+
}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
/** 零依赖 zip(单文件,DEFLATE,无数据描述符)。 */
|
|
2
|
+
import { deflateRawSync } from 'node:zlib';
|
|
3
|
+
|
|
4
|
+
// CRC32 查表法
|
|
5
|
+
const CRC_TABLE = (() => {
|
|
6
|
+
const t = new Uint32Array(256);
|
|
7
|
+
for (let i = 0; i < 256; i++) {
|
|
8
|
+
let c = i;
|
|
9
|
+
for (let j = 0; j < 8; j++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1);
|
|
10
|
+
t[i] = c;
|
|
11
|
+
}
|
|
12
|
+
return t;
|
|
13
|
+
})();
|
|
14
|
+
|
|
15
|
+
export function crc32(buf) {
|
|
16
|
+
let crc = 0xFFFFFFFF;
|
|
17
|
+
for (let i = 0; i < buf.length; i++) {
|
|
18
|
+
crc = CRC_TABLE[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8);
|
|
19
|
+
}
|
|
20
|
+
return (crc ^ 0xFFFFFFFF) >>> 0;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function createZip(files) {
|
|
24
|
+
// files: [{ name: string, data: Buffer | string }]
|
|
25
|
+
const localParts = [];
|
|
26
|
+
const centralParts = [];
|
|
27
|
+
let offset = 0;
|
|
28
|
+
|
|
29
|
+
for (const file of files) {
|
|
30
|
+
const data = Buffer.isBuffer(file.data) ? file.data : Buffer.from(file.data);
|
|
31
|
+
const compressed = deflateRawSync(data);
|
|
32
|
+
const crc = crc32(data);
|
|
33
|
+
const nameBuf = Buffer.from(file.name);
|
|
34
|
+
|
|
35
|
+
// Local file header (30 bytes)
|
|
36
|
+
const lh = Buffer.alloc(30);
|
|
37
|
+
lh.writeUInt32LE(0x04034b50, 0);
|
|
38
|
+
lh.writeUInt16LE(20, 4); // version needed
|
|
39
|
+
lh.writeUInt16LE(0, 6); // flags
|
|
40
|
+
lh.writeUInt16LE(8, 8); // compression: deflate
|
|
41
|
+
lh.writeUInt16LE(0, 10); // mod time
|
|
42
|
+
lh.writeUInt16LE(0x0021, 12); // mod date (1980-01-01 safe)
|
|
43
|
+
lh.writeUInt32LE(crc, 14);
|
|
44
|
+
lh.writeUInt32LE(compressed.length, 18);
|
|
45
|
+
lh.writeUInt32LE(data.length, 22);
|
|
46
|
+
lh.writeUInt16LE(nameBuf.length, 26);
|
|
47
|
+
lh.writeUInt16LE(0, 28); // extra length
|
|
48
|
+
|
|
49
|
+
const localPart = Buffer.concat([lh, nameBuf, compressed]);
|
|
50
|
+
localParts.push(localPart);
|
|
51
|
+
|
|
52
|
+
// Central directory header (46 bytes)
|
|
53
|
+
const ch = Buffer.alloc(46);
|
|
54
|
+
ch.writeUInt32LE(0x02014b50, 0);
|
|
55
|
+
ch.writeUInt16LE(20, 4);
|
|
56
|
+
ch.writeUInt16LE(20, 6);
|
|
57
|
+
ch.writeUInt16LE(0, 8);
|
|
58
|
+
ch.writeUInt16LE(8, 10);
|
|
59
|
+
ch.writeUInt16LE(0, 12);
|
|
60
|
+
ch.writeUInt16LE(0x0021, 14);
|
|
61
|
+
ch.writeUInt32LE(crc, 16);
|
|
62
|
+
ch.writeUInt32LE(compressed.length, 20);
|
|
63
|
+
ch.writeUInt32LE(data.length, 24);
|
|
64
|
+
ch.writeUInt16LE(nameBuf.length, 28);
|
|
65
|
+
ch.writeUInt16LE(0, 30); // extra
|
|
66
|
+
ch.writeUInt16LE(0, 32); // comment
|
|
67
|
+
ch.writeUInt16LE(0, 34); // disk
|
|
68
|
+
ch.writeUInt16LE(0, 36); // internal attrs
|
|
69
|
+
ch.writeUInt32LE(0, 38); // external attrs
|
|
70
|
+
ch.writeUInt32LE(offset, 42); // local header offset
|
|
71
|
+
centralParts.push(Buffer.concat([ch, nameBuf]));
|
|
72
|
+
|
|
73
|
+
offset += localPart.length;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// End of central directory (22 bytes)
|
|
77
|
+
const centralBuf = Buffer.concat(centralParts);
|
|
78
|
+
const eocd = Buffer.alloc(22);
|
|
79
|
+
eocd.writeUInt32LE(0x06054b50, 0);
|
|
80
|
+
eocd.writeUInt16LE(0, 4);
|
|
81
|
+
eocd.writeUInt16LE(0, 6);
|
|
82
|
+
eocd.writeUInt16LE(files.length, 8);
|
|
83
|
+
eocd.writeUInt16LE(files.length, 10);
|
|
84
|
+
eocd.writeUInt32LE(centralBuf.length, 12);
|
|
85
|
+
eocd.writeUInt32LE(offset, 16);
|
|
86
|
+
eocd.writeUInt16LE(0, 20);
|
|
87
|
+
|
|
88
|
+
return Buffer.concat([...localParts, centralBuf, eocd]);
|
|
89
|
+
}
|
package/src/status.mjs
CHANGED
|
@@ -1,66 +1,133 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
/**
|
|
3
3
|
* status.mjs — `toolkit status`
|
|
4
|
+
*
|
|
5
|
+
* 报告 backlog 模型下的安装状态:
|
|
6
|
+
* - registry / uid(来自 config.json)
|
|
7
|
+
* - installed_at / last_sweep(来自 backlog.json)
|
|
8
|
+
* - 每 project 剩余 backlog 文件数(mtime ≤ installed_at 且
|
|
9
|
+
* compareKey(file, cursor) < 0 = 待排干)
|
|
10
|
+
*
|
|
11
|
+
* `collectStatus()` 返回字符串,`status()` 为 CLI 入口,调用 console.log。
|
|
4
12
|
*/
|
|
5
13
|
import fs from 'node:fs';
|
|
6
14
|
import path from 'node:path';
|
|
7
15
|
import os from 'node:os';
|
|
8
|
-
import {
|
|
16
|
+
import { fileURLToPath } from 'node:url';
|
|
17
|
+
import { load as loadConfig } from './shared/config.mjs';
|
|
18
|
+
import {
|
|
19
|
+
loadBacklog,
|
|
20
|
+
compareKey,
|
|
21
|
+
topCursor,
|
|
22
|
+
} from './shared/backlog.mjs';
|
|
9
23
|
|
|
10
24
|
const HOME = os.homedir();
|
|
11
|
-
const PLUGIN_DIR = path.join(HOME, '.claude', '
|
|
25
|
+
const PLUGIN_DIR = path.join(HOME, '.claude', 'skills', 'toolkit');
|
|
12
26
|
|
|
13
|
-
|
|
14
|
-
|
|
27
|
+
/**
|
|
28
|
+
* 收集状态文本。
|
|
29
|
+
* @returns {string}
|
|
30
|
+
*/
|
|
31
|
+
export function collectStatus() {
|
|
15
32
|
const config = loadConfig();
|
|
16
33
|
const configExists = !!config.registry || !!config.uid;
|
|
17
34
|
|
|
18
|
-
|
|
19
|
-
|
|
35
|
+
const pluginExists = fs.existsSync(
|
|
36
|
+
path.join(PLUGIN_DIR, '.claude-plugin', 'plugin.json'),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
const lines = [];
|
|
40
|
+
lines.push('Toolkit Status');
|
|
41
|
+
lines.push('════════════════════════════════════════');
|
|
20
42
|
|
|
21
43
|
if (!pluginExists && !configExists) {
|
|
22
|
-
|
|
23
|
-
return;
|
|
44
|
+
lines.push(' ❌ 未安装。运行: toolkit install claudecode --registry <url>');
|
|
45
|
+
return lines.join('\n');
|
|
24
46
|
}
|
|
25
47
|
|
|
26
48
|
// 插件状态
|
|
27
|
-
|
|
49
|
+
lines.push(` 插件: ${pluginExists ? '✅ 已安装' : '❌ 缺失'}`);
|
|
28
50
|
if (pluginExists) {
|
|
29
|
-
|
|
30
|
-
|
|
51
|
+
try {
|
|
52
|
+
const pj = JSON.parse(
|
|
53
|
+
fs.readFileSync(
|
|
54
|
+
path.join(PLUGIN_DIR, '.claude-plugin', 'plugin.json'),
|
|
55
|
+
'utf-8',
|
|
56
|
+
),
|
|
57
|
+
);
|
|
58
|
+
lines.push(` 版本: ${pj.version || '(未知)'}`);
|
|
59
|
+
} catch {
|
|
60
|
+
lines.push(' 版本: (读取失败)');
|
|
61
|
+
}
|
|
31
62
|
}
|
|
32
63
|
|
|
33
|
-
// 配置
|
|
64
|
+
// 配置
|
|
34
65
|
if (configExists) {
|
|
35
|
-
|
|
36
|
-
|
|
66
|
+
lines.push(
|
|
67
|
+
` 平台地址: ${config.registry}${config.contexts?.metrics || '/ai-metrics'}/`,
|
|
68
|
+
);
|
|
69
|
+
lines.push(` 身份 UID: ${(config.uid || '').slice(0, 12)}...`);
|
|
37
70
|
}
|
|
38
71
|
|
|
39
|
-
//
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
console.log(` 上次同步: ${up.last_flush || '从未'}`);
|
|
44
|
-
} catch {
|
|
45
|
-
console.log(' 同步状态: 未初始化');
|
|
46
|
-
}
|
|
72
|
+
// backlog 状态(只读:status 不应有写副作用,installed_at 边界由 install 写入)
|
|
73
|
+
const st = loadBacklog();
|
|
74
|
+
lines.push(` installed_at: ${st.installed_at || '(无)'}`);
|
|
75
|
+
lines.push(` last_sweep: ${st.last_sweep || '(从未)'}`);
|
|
47
76
|
|
|
48
|
-
//
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
77
|
+
// 每 project 剩余 backlog 文件计数
|
|
78
|
+
const base =
|
|
79
|
+
process.env.CLAUDE_PROJECTS_DIR ||
|
|
80
|
+
path.join(HOME, '.claude', 'projects');
|
|
81
|
+
const installedMs = st.installed_at
|
|
82
|
+
? new Date(st.installed_at).getTime()
|
|
83
|
+
: null;
|
|
84
|
+
|
|
85
|
+
if (installedMs != null && fs.existsSync(base)) {
|
|
86
|
+
lines.push(' backlog 剩余 (按 project):');
|
|
87
|
+
let total = 0;
|
|
88
|
+
for (const proj of fs.readdirSync(base)) {
|
|
89
|
+
const pp = path.join(base, proj);
|
|
90
|
+
let stat;
|
|
56
91
|
try {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
92
|
+
stat = fs.statSync(pp);
|
|
93
|
+
} catch {
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (!stat.isDirectory()) continue;
|
|
97
|
+
|
|
98
|
+
const cursor = st.cursors[proj] || topCursor(installedMs);
|
|
99
|
+
let remain = 0;
|
|
100
|
+
for (const f of fs.readdirSync(pp)) {
|
|
101
|
+
if (!f.endsWith('.jsonl')) continue;
|
|
102
|
+
let s;
|
|
103
|
+
try {
|
|
104
|
+
s = fs.statSync(path.join(pp, f));
|
|
105
|
+
} catch {
|
|
106
|
+
continue;
|
|
60
107
|
}
|
|
61
|
-
|
|
108
|
+
if (s.mtimeMs > installedMs) continue; // current 负责
|
|
109
|
+
if (compareKey({ mtime: s.mtimeMs, name: f }, cursor) < 0) remain++;
|
|
110
|
+
}
|
|
111
|
+
total += remain;
|
|
112
|
+
lines.push(` ${proj}: ${remain}`);
|
|
62
113
|
}
|
|
114
|
+
lines.push(` 合计待排干: ${total}`);
|
|
63
115
|
}
|
|
64
|
-
|
|
65
|
-
|
|
116
|
+
|
|
117
|
+
lines.push('════════════════════════════════════════');
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* CLI 入口。
|
|
123
|
+
*/
|
|
124
|
+
export function status() {
|
|
125
|
+
console.log(collectStatus());
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// self-invocation guard:被 import 时不触发 main()
|
|
129
|
+
const invoked =
|
|
130
|
+
process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
131
|
+
if (invoked) {
|
|
132
|
+
status();
|
|
66
133
|
}
|