agent-syncer 0.1.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.
- package/README.md +155 -0
- package/bin/agent-sync.js +104 -0
- package/lib/commands/doctor.js +132 -0
- package/lib/commands/link.js +215 -0
- package/lib/commands/status.js +133 -0
- package/lib/config.js +170 -0
- package/lib/gitignore.js +108 -0
- package/lib/link.js +220 -0
- package/lib/log.js +58 -0
- package/lib/prompt.js +125 -0
- package/lib/target.js +144 -0
- package/package.json +34 -0
package/lib/prompt.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import process from 'node:process';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
import { bold, cyan, dim, green } from './log.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* 是否具备交互条件。
|
|
8
|
+
*
|
|
9
|
+
* 必须严格判断:CI、管道、postinstall 里 stdin 不是 TTY,
|
|
10
|
+
* 这时弹提示会**永久挂住**——宁可退回非交互的默认行为,也不能卡住。
|
|
11
|
+
*
|
|
12
|
+
* @param {NodeJS.ReadStream} [input] @param {NodeJS.WriteStream} [output]
|
|
13
|
+
*/
|
|
14
|
+
export function isInteractive(input = process.stdin, output = process.stdout) {
|
|
15
|
+
return Boolean(input.isTTY && output.isTTY);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* 多选列表。零依赖,用 node:readline 的 keypress 事件解析方向键。
|
|
20
|
+
*
|
|
21
|
+
* 按键:↑↓(或 k/j)移动 空格 勾选 a 全选/全不选 回车 确认 Ctrl-C 取消
|
|
22
|
+
*
|
|
23
|
+
* 输入输出可注入,便于测试时不必去动全局的 process.stdin / process.stdout。
|
|
24
|
+
*
|
|
25
|
+
* @param {{
|
|
26
|
+
* message: string,
|
|
27
|
+
* choices: {value: string, label: string, note?: string, checked?: boolean}[],
|
|
28
|
+
* hint?: string,
|
|
29
|
+
* input?: NodeJS.ReadStream,
|
|
30
|
+
* output?: NodeJS.WriteStream,
|
|
31
|
+
* }} opts
|
|
32
|
+
* @returns {Promise<string[]|null>} 选中的 value 列表;取消返回 null
|
|
33
|
+
*/
|
|
34
|
+
export function checkbox({ message, choices, hint, input = process.stdin, output = process.stdout }) {
|
|
35
|
+
return new Promise((resolve) => {
|
|
36
|
+
if (choices.length === 0) {
|
|
37
|
+
resolve([]);
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const items = choices.map((c) => ({ ...c, checked: Boolean(c.checked) }));
|
|
42
|
+
const trailing = hint ?? '↑↓ 移动 · 空格 勾选 · a 全选 · 回车 确认 · Ctrl-C 取消';
|
|
43
|
+
|
|
44
|
+
let cursor = 0;
|
|
45
|
+
let drawn = 0;
|
|
46
|
+
let settled = false;
|
|
47
|
+
|
|
48
|
+
/** 重绘:先退回已画的行数再重画,避免刷屏 */
|
|
49
|
+
const render = (final = false) => {
|
|
50
|
+
if (drawn > 0) {
|
|
51
|
+
readline.moveCursor(output, 0, -drawn);
|
|
52
|
+
readline.cursorTo(output, 0);
|
|
53
|
+
readline.clearScreenDown(output);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const lines = [bold(message)];
|
|
57
|
+
items.forEach((c, i) => {
|
|
58
|
+
const active = i === cursor && !final;
|
|
59
|
+
const box = c.checked ? green('◉') : dim('◯');
|
|
60
|
+
const text = active ? cyan(c.label) : c.label;
|
|
61
|
+
lines.push(` ${active ? cyan('❯') : ' '} ${box} ${text}${c.note ? ` ${dim(c.note)}` : ''}`);
|
|
62
|
+
});
|
|
63
|
+
if (!final) lines.push(dim(` ${trailing}`));
|
|
64
|
+
|
|
65
|
+
output.write(`${lines.join('\n')}\n`);
|
|
66
|
+
drawn = lines.length;
|
|
67
|
+
};
|
|
68
|
+
|
|
69
|
+
/** @param {string[]|null} result */
|
|
70
|
+
const finish = (result) => {
|
|
71
|
+
if (settled) return;
|
|
72
|
+
settled = true;
|
|
73
|
+
input.removeListener('keypress', onKey);
|
|
74
|
+
try {
|
|
75
|
+
input.setRawMode(false);
|
|
76
|
+
} catch {
|
|
77
|
+
// 非 TTY 时 setRawMode 会抛,忽略即可
|
|
78
|
+
}
|
|
79
|
+
input.pause();
|
|
80
|
+
try {
|
|
81
|
+
render(true);
|
|
82
|
+
} catch {
|
|
83
|
+
// 输出流可能已被关闭,渲染失败不应影响返回值
|
|
84
|
+
}
|
|
85
|
+
resolve(result);
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** @param {string} _str @param {import('node:readline').Key} key */
|
|
89
|
+
const onKey = (_str, key) => {
|
|
90
|
+
if (!key) return;
|
|
91
|
+
|
|
92
|
+
if (key.ctrl && key.name === 'c') {
|
|
93
|
+
finish(null);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (key.name === 'up' || key.name === 'k') {
|
|
97
|
+
cursor = (cursor - 1 + items.length) % items.length;
|
|
98
|
+
} else if (key.name === 'down' || key.name === 'j') {
|
|
99
|
+
cursor = (cursor + 1) % items.length;
|
|
100
|
+
} else if (key.name === 'space') {
|
|
101
|
+
items[cursor].checked = !items[cursor].checked;
|
|
102
|
+
} else if (key.name === 'a') {
|
|
103
|
+
const allChecked = items.every((c) => c.checked);
|
|
104
|
+
for (const c of items) c.checked = !allChecked;
|
|
105
|
+
} else if (key.name === 'return' || key.name === 'enter') {
|
|
106
|
+
finish(items.filter((c) => c.checked).map((c) => c.value));
|
|
107
|
+
return;
|
|
108
|
+
} else {
|
|
109
|
+
return;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
render();
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
readline.emitKeypressEvents(input);
|
|
116
|
+
try {
|
|
117
|
+
input.setRawMode(true);
|
|
118
|
+
} catch {
|
|
119
|
+
// 同上:真实交互场景下不会走到这里
|
|
120
|
+
}
|
|
121
|
+
input.resume();
|
|
122
|
+
input.on('keypress', onKey);
|
|
123
|
+
render();
|
|
124
|
+
});
|
|
125
|
+
}
|
package/lib/target.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* 所有 AI 资产的规范存放目录(相对项目根)。
|
|
6
|
+
* 内容只在这一处保存一份,各工具目录通过链接指向它。
|
|
7
|
+
*/
|
|
8
|
+
export const CONTENT_ROOT = '.agents';
|
|
9
|
+
|
|
10
|
+
/** 内容类型。与 .agents/ 下的子目录名一一对应。 */
|
|
11
|
+
export const KINDS = ['skills', 'rules', 'commands', 'agents'];
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* 工具 → 目标路径映射表。
|
|
15
|
+
*
|
|
16
|
+
* 这是全项目唯一需要关心「哪个工具把内容放哪」的地方——
|
|
17
|
+
* link.js / status.js / doctor.js 都不得出现 if-tool 分支。
|
|
18
|
+
*
|
|
19
|
+
* - claude:skills / rules / commands / agents 四类都是目录
|
|
20
|
+
* (rules 目录的有效性已在本机 Claude Code 2.1.267 上实测确认,含嵌套子目录)
|
|
21
|
+
* - trae:三类目录,无 agents
|
|
22
|
+
* - codex:只有 skills 是目录。
|
|
23
|
+
*
|
|
24
|
+
* ⚠️ 这不代表 Codex 没有规则机制——它有,只是形态不同。Codex 的规则走
|
|
25
|
+
* **AGENTS.md 系列文件**而非目录,优先级为
|
|
26
|
+
* `AGENTS.override.md` > `AGENTS.md` > `project_doc_fallback_filenames` 里配置的名字,
|
|
27
|
+
* 且按作用域生效("the entire directory tree rooted at the folder that contains it")。
|
|
28
|
+
* 它也没有 `@import` 语法,所以 rules 无法靠目录链接分发,只能合并进 AGENTS.md
|
|
29
|
+
* (第二阶段实现)。合并时要留意 `project_doc_max_bytes`(默认 32768)。
|
|
30
|
+
*/
|
|
31
|
+
export const TOOLS = {
|
|
32
|
+
claude: {
|
|
33
|
+
label: 'Claude Code',
|
|
34
|
+
links: {
|
|
35
|
+
skills: '.claude/skills',
|
|
36
|
+
rules: '.claude/rules',
|
|
37
|
+
commands: '.claude/commands',
|
|
38
|
+
agents: '.claude/agents',
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
trae: {
|
|
42
|
+
label: 'Trae',
|
|
43
|
+
links: {
|
|
44
|
+
skills: '.trae/skills',
|
|
45
|
+
rules: '.trae/rules',
|
|
46
|
+
commands: '.trae/commands',
|
|
47
|
+
},
|
|
48
|
+
},
|
|
49
|
+
codex: {
|
|
50
|
+
label: 'Codex CLI',
|
|
51
|
+
links: {
|
|
52
|
+
skills: '.codex/skills',
|
|
53
|
+
},
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
export const TOOL_NAMES = Object.keys(TOOLS);
|
|
58
|
+
|
|
59
|
+
/** 工具是否已知 */
|
|
60
|
+
export function isTool(name) {
|
|
61
|
+
return Object.hasOwn(TOOLS, name);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** 内容源目录的绝对路径:<项目根>/.agents/<kind> */
|
|
65
|
+
export function contentDir(projectRoot, kind) {
|
|
66
|
+
return path.resolve(projectRoot, CONTENT_ROOT, kind);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* 某工具支持的内容类型列表
|
|
71
|
+
* @param {string} tool
|
|
72
|
+
* @returns {string[]}
|
|
73
|
+
*/
|
|
74
|
+
export function kindsOf(tool) {
|
|
75
|
+
const cfg = TOOLS[tool];
|
|
76
|
+
if (!cfg) throw new Error(`未知工具:${tool}。可用值:${TOOL_NAMES.join(', ')}`);
|
|
77
|
+
return KINDS.filter((k) => Object.hasOwn(cfg.links, k));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** 条目的规范写法:"claude/skills" */
|
|
81
|
+
export function specOf(tool, kind) {
|
|
82
|
+
return `${tool}/${kind}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** 某工具支持的全部条目(用于 `tools` 形式的配置展开) */
|
|
86
|
+
export function allSpecs(tool) {
|
|
87
|
+
return kindsOf(tool).map((k) => specOf(tool, k));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 解析 "claude/skills" 形式的条目。非法即抛错——配置写错时要立刻说清楚,
|
|
92
|
+
* 不能静默忽略,否则用户会以为已经生效。
|
|
93
|
+
* @param {string} spec
|
|
94
|
+
* @returns {{tool: string, kind: string}}
|
|
95
|
+
*/
|
|
96
|
+
export function parseSpec(spec) {
|
|
97
|
+
if (typeof spec !== 'string') {
|
|
98
|
+
throw new Error(`条目必须是字符串,收到 ${JSON.stringify(spec)}`);
|
|
99
|
+
}
|
|
100
|
+
const slash = spec.indexOf('/');
|
|
101
|
+
if (slash === -1) {
|
|
102
|
+
throw new Error(`条目 ${JSON.stringify(spec)} 格式不对,应为 "工具/类型",例如 "claude/skills"`);
|
|
103
|
+
}
|
|
104
|
+
const tool = spec.slice(0, slash).trim().toLowerCase();
|
|
105
|
+
const kind = spec.slice(slash + 1).trim();
|
|
106
|
+
|
|
107
|
+
if (!isTool(tool)) {
|
|
108
|
+
throw new Error(`未知工具 "${tool}"(可用值:${TOOL_NAMES.join(', ')})`);
|
|
109
|
+
}
|
|
110
|
+
if (!Object.hasOwn(TOOLS[tool].links, kind)) {
|
|
111
|
+
throw new Error(`工具 ${tool} 不支持内容类型 "${kind}"(可用:${kindsOf(tool).join(', ')})`);
|
|
112
|
+
}
|
|
113
|
+
return { tool, kind };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* 把条目展开成待处理的链接清单
|
|
118
|
+
* @param {string} projectRoot
|
|
119
|
+
* @param {string[]} specs
|
|
120
|
+
* @returns {{spec: string, tool: string, kind: string, rel: string, source: string, target: string}[]}
|
|
121
|
+
*/
|
|
122
|
+
export function plannedLinks(projectRoot, specs) {
|
|
123
|
+
return specs.map((spec) => {
|
|
124
|
+
const { tool, kind } = parseSpec(spec);
|
|
125
|
+
const rel = TOOLS[tool].links[kind];
|
|
126
|
+
return {
|
|
127
|
+
spec,
|
|
128
|
+
tool,
|
|
129
|
+
kind,
|
|
130
|
+
rel,
|
|
131
|
+
source: contentDir(projectRoot, kind),
|
|
132
|
+
target: path.resolve(projectRoot, rel),
|
|
133
|
+
};
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* 条目里出现过哪些工具,按 TOOL_NAMES 的固定顺序返回
|
|
139
|
+
* @param {string[]} specs
|
|
140
|
+
*/
|
|
141
|
+
export function toolsOfSpecs(specs) {
|
|
142
|
+
const seen = new Set(specs.map((s) => parseSpec(s).tool));
|
|
143
|
+
return TOOL_NAMES.filter((t) => seen.has(t));
|
|
144
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "agent-syncer",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "把 .agents/ 下的 AI 资产(skills / rules / commands)分发到 Claude Code、Trae、Codex 等工具的配置目录",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"agent-syncer": "bin/agent-sync.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"bin",
|
|
11
|
+
"lib",
|
|
12
|
+
"README.md"
|
|
13
|
+
],
|
|
14
|
+
"engines": {
|
|
15
|
+
"node": ">=20.11.0"
|
|
16
|
+
},
|
|
17
|
+
"scripts": {
|
|
18
|
+
"test": "node --test"
|
|
19
|
+
},
|
|
20
|
+
"keywords": [
|
|
21
|
+
"ai",
|
|
22
|
+
"agents",
|
|
23
|
+
"claude-code",
|
|
24
|
+
"codex",
|
|
25
|
+
"trae",
|
|
26
|
+
"skills",
|
|
27
|
+
"symlink",
|
|
28
|
+
"junction"
|
|
29
|
+
],
|
|
30
|
+
"publishConfig": {
|
|
31
|
+
"registry": "https://registry.npmjs.org/"
|
|
32
|
+
},
|
|
33
|
+
"license": "MIT"
|
|
34
|
+
}
|