@simoonfish/df-cli 1.0.5 → 1.0.7
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/dist/api/client.d.ts +38 -0
- package/dist/api/client.js +41 -0
- package/dist/commands/install.js +2 -2
- package/dist/commands/sync.js +66 -31
- package/dist/commands/upload.d.ts +2 -0
- package/dist/commands/upload.js +239 -0
- package/dist/config.d.ts +10 -2
- package/dist/config.js +45 -3
- package/dist/index.js +2 -0
- package/package.json +1 -1
package/dist/api/client.d.ts
CHANGED
|
@@ -13,6 +13,8 @@ declare class ApiClient {
|
|
|
13
13
|
private url;
|
|
14
14
|
get<T>(path: string, params?: Record<string, string | number | undefined>): Promise<T>;
|
|
15
15
|
post<T>(path: string, body?: unknown): Promise<T>;
|
|
16
|
+
put<T>(path: string, body?: unknown): Promise<T>;
|
|
17
|
+
del<T>(path: string): Promise<T>;
|
|
16
18
|
verifyKey(): Promise<{
|
|
17
19
|
valid: boolean;
|
|
18
20
|
user_id: number | null;
|
|
@@ -48,6 +50,42 @@ declare class ApiClient {
|
|
|
48
50
|
action: string;
|
|
49
51
|
detail?: string;
|
|
50
52
|
}): Promise<unknown>;
|
|
53
|
+
createKnowledgeBase(data: {
|
|
54
|
+
name: string;
|
|
55
|
+
path_in_repo?: string;
|
|
56
|
+
description?: string;
|
|
57
|
+
}): Promise<{
|
|
58
|
+
code: number;
|
|
59
|
+
data: any;
|
|
60
|
+
}>;
|
|
61
|
+
searchKnowledgeBases(keyword: string): Promise<{
|
|
62
|
+
code: number;
|
|
63
|
+
data: any[];
|
|
64
|
+
}>;
|
|
65
|
+
bindWorkspaceKnowledgeBase(wsId: number, kbIds: number[]): Promise<any>;
|
|
66
|
+
createKnowledgeNode(data: {
|
|
67
|
+
base_id: number;
|
|
68
|
+
parent_id?: number | null;
|
|
69
|
+
title: string;
|
|
70
|
+
type: string;
|
|
71
|
+
content?: string;
|
|
72
|
+
sort_order?: number;
|
|
73
|
+
path?: string;
|
|
74
|
+
}): Promise<{
|
|
75
|
+
code: number;
|
|
76
|
+
data: any;
|
|
77
|
+
}>;
|
|
78
|
+
updateKnowledgeNode(nodeId: number, data: {
|
|
79
|
+
title?: string;
|
|
80
|
+
content?: string;
|
|
81
|
+
parent_id?: number | null;
|
|
82
|
+
sort_order?: number;
|
|
83
|
+
path?: string;
|
|
84
|
+
}): Promise<{
|
|
85
|
+
code: number;
|
|
86
|
+
data: any;
|
|
87
|
+
}>;
|
|
88
|
+
deleteKnowledgeNode(nodeId: number): Promise<any>;
|
|
51
89
|
}
|
|
52
90
|
export declare const api: ApiClient;
|
|
53
91
|
export {};
|
package/dist/api/client.js
CHANGED
|
@@ -53,6 +53,27 @@ class ApiClient {
|
|
|
53
53
|
const json = await res.json();
|
|
54
54
|
return json;
|
|
55
55
|
}
|
|
56
|
+
async put(path, body) {
|
|
57
|
+
const res = await fetch(this.url(path), {
|
|
58
|
+
method: "PUT",
|
|
59
|
+
headers: this.headers(),
|
|
60
|
+
body: body ? JSON.stringify(body) : undefined,
|
|
61
|
+
});
|
|
62
|
+
if (!res.ok)
|
|
63
|
+
throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
64
|
+
const json = await res.json();
|
|
65
|
+
return json;
|
|
66
|
+
}
|
|
67
|
+
async del(path) {
|
|
68
|
+
const res = await fetch(this.url(path), {
|
|
69
|
+
method: "DELETE",
|
|
70
|
+
headers: this.headers(),
|
|
71
|
+
});
|
|
72
|
+
if (!res.ok)
|
|
73
|
+
throw new Error(`API ${res.status}: ${res.statusText}`);
|
|
74
|
+
const json = await res.json();
|
|
75
|
+
return json;
|
|
76
|
+
}
|
|
56
77
|
async verifyKey() {
|
|
57
78
|
return this.post("/auth/api-keys/verify");
|
|
58
79
|
}
|
|
@@ -77,5 +98,25 @@ class ApiClient {
|
|
|
77
98
|
async syncReport(data) {
|
|
78
99
|
return this.post("/clients/sync-report", data);
|
|
79
100
|
}
|
|
101
|
+
// ── Knowledge Base CRUD (for upload) ─────────────────────────
|
|
102
|
+
async createKnowledgeBase(data) {
|
|
103
|
+
return this.post("/knowledge-bases", data);
|
|
104
|
+
}
|
|
105
|
+
async searchKnowledgeBases(keyword) {
|
|
106
|
+
return this.get("/knowledge-bases", { keyword, pageSize: 50 });
|
|
107
|
+
}
|
|
108
|
+
async bindWorkspaceKnowledgeBase(wsId, kbIds) {
|
|
109
|
+
return this.post(`/workspaces/${wsId}/knowledge-bases`, { knowledge_base_ids: kbIds });
|
|
110
|
+
}
|
|
111
|
+
// ── Knowledge Node CRUD (for upload) ─────────────────────────
|
|
112
|
+
async createKnowledgeNode(data) {
|
|
113
|
+
return this.post("/knowledge-nodes", data);
|
|
114
|
+
}
|
|
115
|
+
async updateKnowledgeNode(nodeId, data) {
|
|
116
|
+
return this.put(`/knowledge-nodes/${nodeId}`, data);
|
|
117
|
+
}
|
|
118
|
+
async deleteKnowledgeNode(nodeId) {
|
|
119
|
+
return this.del(`/knowledge-nodes/${nodeId}`);
|
|
120
|
+
}
|
|
80
121
|
}
|
|
81
122
|
export const api = new ApiClient();
|
package/dist/commands/install.js
CHANGED
|
@@ -3,7 +3,7 @@ import chalk from "chalk";
|
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { readConfig, findBinding,
|
|
6
|
+
import { readConfig, findBinding, resolvePlatform, getSkillInstallDir, getKnowledgeInstallDir, SUPPORTED_PLATFORMS } from "../config.js";
|
|
7
7
|
import { api } from "../api/client.js";
|
|
8
8
|
export const installCommand = new Command("install")
|
|
9
9
|
.description("安装指定 Skill 或知识库到工作区")
|
|
@@ -53,7 +53,7 @@ async function installSkill(config, binding, name, opts) {
|
|
|
53
53
|
process.exit(1);
|
|
54
54
|
}
|
|
55
55
|
// Platform compatibility check
|
|
56
|
-
const currentPlatform =
|
|
56
|
+
const currentPlatform = resolvePlatform(opts.platform, binding);
|
|
57
57
|
const skillPlatform = skill.platform || "claude_code";
|
|
58
58
|
if (skillPlatform !== currentPlatform && !opts.force) {
|
|
59
59
|
spinner.warn(`Skill "${skill.name}" 的目标平台是 ${chalk.cyan(skillPlatform)},` +
|
package/dist/commands/sync.js
CHANGED
|
@@ -3,13 +3,14 @@ import chalk from "chalk";
|
|
|
3
3
|
import ora from "ora";
|
|
4
4
|
import { writeFileSync, mkdirSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
-
import { readConfig, writeConfig, findBinding, getSkillInstallDir, getKnowledgeInstallDir, getDevForgeDir, getRuleFileName, getRulesCacheDir } from "../config.js";
|
|
6
|
+
import { readConfig, writeConfig, findBinding, getSkillInstallDir, getKnowledgeInstallDir, getDevForgeDir, getRuleFileName, getRulesCacheDir, resolvePlatform, cleanupBindingArtifacts } from "../config.js";
|
|
7
7
|
import { api } from "../api/client.js";
|
|
8
8
|
export const syncCommand = new Command("sync")
|
|
9
9
|
.description("绑定工作区并同步 Skill + 知识库到本地")
|
|
10
10
|
.option("--local-dir <path>", "指定本地工程目录(默认当前目录)")
|
|
11
|
-
.option("--workspace-id <id>", "工作区 ID
|
|
12
|
-
.option("--workspace <name>", "
|
|
11
|
+
.option("--workspace-id <id>", "工作区 ID(首次绑定时必填,重新绑定时更新)")
|
|
12
|
+
.option("--workspace <name>", "工作区名称(按名称搜索)")
|
|
13
|
+
.option("--platform <name>", "目标 AI 编码平台 (claude_code | codex),覆盖绑定上的平台")
|
|
13
14
|
.option("--skills", "仅同步 Skill")
|
|
14
15
|
.option("--knowledge", "仅同步知识库")
|
|
15
16
|
.action(async (opts) => {
|
|
@@ -21,41 +22,75 @@ export const syncCommand = new Command("sync")
|
|
|
21
22
|
process.exit(1);
|
|
22
23
|
}
|
|
23
24
|
const localDir = opts.localDir || process.cwd();
|
|
25
|
+
const platformOverride = opts.platform;
|
|
24
26
|
let binding = findBinding(config, localDir);
|
|
25
|
-
// ──
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
spinner.
|
|
39
|
-
|
|
40
|
-
if (!res.data?.length) {
|
|
41
|
-
spinner.fail(`未找到名为 "${workspaceName}" 的工作区`);
|
|
42
|
-
process.exit(1);
|
|
43
|
-
}
|
|
44
|
-
wsId = res.data[0].id;
|
|
45
|
-
wsName = res.data[0].name;
|
|
27
|
+
// ── 解析目标工作区(如果命令行指定了 --workspace-id / --workspace)────────
|
|
28
|
+
let targetWsId;
|
|
29
|
+
let targetWsName;
|
|
30
|
+
if (opts.workspaceId) {
|
|
31
|
+
targetWsId = Number(opts.workspaceId);
|
|
32
|
+
spinner.text = "查询工作区...";
|
|
33
|
+
const ws = await api.getWorkspace(targetWsId);
|
|
34
|
+
targetWsName = ws.data?.name || `工作区 #${targetWsId}`;
|
|
35
|
+
}
|
|
36
|
+
else if (opts.workspace) {
|
|
37
|
+
spinner.text = `搜索工作区 "${opts.workspace}"...`;
|
|
38
|
+
const res = await api.get(`/workspaces?keyword=${encodeURIComponent(opts.workspace)}&pageSize=5`);
|
|
39
|
+
if (!res.data?.length) {
|
|
40
|
+
spinner.fail(`未找到名为 "${opts.workspace}" 的工作区`);
|
|
41
|
+
process.exit(1);
|
|
46
42
|
}
|
|
47
|
-
|
|
43
|
+
targetWsId = res.data[0].id;
|
|
44
|
+
targetWsName = res.data[0].name;
|
|
45
|
+
}
|
|
46
|
+
// ── 检测变更 & 执行清理 ──────────────────────────────────────
|
|
47
|
+
const workspaceChanged = binding && targetWsId && binding.workspace_id !== targetWsId;
|
|
48
|
+
const platformChanged = binding && platformOverride && resolvePlatform(undefined, binding) !== platformOverride;
|
|
49
|
+
let needsCleanup = workspaceChanged || platformChanged;
|
|
50
|
+
if (!binding) {
|
|
51
|
+
// ── 首次绑定 ─────────────────────────────────────────────
|
|
52
|
+
if (!targetWsId) {
|
|
48
53
|
spinner.fail("当前目录未绑定工作区,请通过 --workspace-id 或 --workspace 指定");
|
|
49
54
|
process.exit(1);
|
|
50
55
|
}
|
|
51
|
-
binding = {
|
|
56
|
+
binding = {
|
|
57
|
+
local_dir: localDir,
|
|
58
|
+
workspace_id: targetWsId,
|
|
59
|
+
workspace_name: targetWsName,
|
|
60
|
+
platform: platformOverride,
|
|
61
|
+
};
|
|
52
62
|
config.bindings.push(binding);
|
|
53
|
-
|
|
54
|
-
|
|
63
|
+
spinner.succeed(`已绑定工作区: ${targetWsName} (ID: ${targetWsId})`);
|
|
64
|
+
}
|
|
65
|
+
else if (needsCleanup) {
|
|
66
|
+
// ── 重新绑定(清理旧产物 → 更新绑定)────────────────────────
|
|
67
|
+
const oldPlatform = platformChanged ? resolvePlatform(undefined, binding) : undefined;
|
|
68
|
+
const oldWsLabel = workspaceChanged
|
|
69
|
+
? `${binding.workspace_name} (ID: ${binding.workspace_id})`
|
|
70
|
+
: "";
|
|
71
|
+
spinner.start("清理旧的同步数据...");
|
|
72
|
+
cleanupBindingArtifacts(localDir, workspaceChanged ? undefined : oldPlatform);
|
|
73
|
+
spinner.succeed("旧的同步数据已清理");
|
|
74
|
+
if (workspaceChanged) {
|
|
75
|
+
console.log(chalk.yellow(` 工作区变更: ${oldWsLabel} → ${targetWsName} (ID: ${targetWsId})`));
|
|
76
|
+
binding.workspace_id = targetWsId;
|
|
77
|
+
binding.workspace_name = targetWsName;
|
|
78
|
+
binding.last_synced = undefined;
|
|
79
|
+
}
|
|
80
|
+
if (platformOverride) {
|
|
81
|
+
binding.platform = platformOverride;
|
|
82
|
+
}
|
|
83
|
+
spinner.succeed(`已重新绑定工作区: ${binding.workspace_name} (ID: ${binding.workspace_id})`);
|
|
55
84
|
}
|
|
56
85
|
else {
|
|
86
|
+
// 无变更 — 仅更新 platform 空值(若未设置且命令行传入)
|
|
87
|
+
if (platformOverride && !binding.platform) {
|
|
88
|
+
binding.platform = platformOverride;
|
|
89
|
+
}
|
|
57
90
|
spinner.succeed(`已定位工作区: ${binding.workspace_name} (ID: ${binding.workspace_id})`);
|
|
58
91
|
}
|
|
92
|
+
// ── 确定生效平台(供写入规则文件时使用)─────────────────────────
|
|
93
|
+
const effectivePlatform = resolvePlatform(undefined, binding);
|
|
59
94
|
// ── 同步到 local-dir/.devforge ────────────────────────────
|
|
60
95
|
mkdirSync(getDevForgeDir(binding.local_dir), { recursive: true });
|
|
61
96
|
const syncAll = !opts.skills && !opts.knowledge;
|
|
@@ -127,15 +162,14 @@ export const syncCommand = new Command("sync")
|
|
|
127
162
|
spinner.succeed(`知识库同步完成 (${kbCount} 个)`);
|
|
128
163
|
}
|
|
129
164
|
let ruleCount = 0;
|
|
130
|
-
// Sync Rule — 写入工程根目录的 CLAUDE.md / .cursorrules
|
|
165
|
+
// Sync Rule — 写入工程根目录的 CLAUDE.md / .cursorrules / AGENTS.md
|
|
131
166
|
if (syncAll || opts.rules) {
|
|
132
167
|
spinner.start(`同步 AI 编码规则...`);
|
|
133
168
|
try {
|
|
134
169
|
const res = await api.getWorkspaceRule(binding.workspace_id);
|
|
135
170
|
const rule = res.data;
|
|
136
171
|
if (rule?.content) {
|
|
137
|
-
const
|
|
138
|
-
const fileName = getRuleFileName(platform);
|
|
172
|
+
const fileName = getRuleFileName(effectivePlatform);
|
|
139
173
|
const rulePath = join(localDir, fileName);
|
|
140
174
|
writeFileSync(rulePath, rule.content, "utf-8");
|
|
141
175
|
ruleCount = 1;
|
|
@@ -170,6 +204,7 @@ export const syncCommand = new Command("sync")
|
|
|
170
204
|
detail: JSON.stringify({ skills: skillCount, knowledge: kbCount, rules: ruleCount }),
|
|
171
205
|
});
|
|
172
206
|
console.log(chalk.gray(` 工作区: ${binding.workspace_name} (ID: ${binding.workspace_id})`));
|
|
207
|
+
console.log(chalk.gray(` 平台: ${chalk.cyan(effectivePlatform)}`));
|
|
173
208
|
console.log(chalk.green(` 同步完成 — Skill ${skillCount}, 知识库 ${kbCount}`));
|
|
174
209
|
}
|
|
175
210
|
catch (e) {
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import ora from "ora";
|
|
4
|
+
import { readFileSync, existsSync, readdirSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { readConfig, findBinding, getKnowledgeInstallDir } from "../config.js";
|
|
7
|
+
import { api } from "../api/client.js";
|
|
8
|
+
export const uploadCommand = new Command("upload")
|
|
9
|
+
.description("将本地数据上传到服务端(知识库等)")
|
|
10
|
+
.option("--kb", "上传知识库到服务端")
|
|
11
|
+
.option("--kb-name <name>", "指定上传的知识库名称(仅限当前工作区已绑定的)")
|
|
12
|
+
.option("--strategy <mode>", "覆盖策略: incremental(默认,只增改)| mirror(以本地为准,删除远程多余内容)", "incremental")
|
|
13
|
+
.option("--dry-run", "预览模式,只输出将要执行的操作,不实际写入")
|
|
14
|
+
.action(async (opts) => {
|
|
15
|
+
const spinner = ora("加载配置...").start();
|
|
16
|
+
try {
|
|
17
|
+
if (!opts.kb) {
|
|
18
|
+
spinner.fail("请指定上传内容类型,例如 --kb 上传知识库");
|
|
19
|
+
process.exit(1);
|
|
20
|
+
}
|
|
21
|
+
const config = readConfig();
|
|
22
|
+
if (!config) {
|
|
23
|
+
spinner.fail("未找到配置,请先运行 devforge init");
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
const binding = findBinding(config);
|
|
27
|
+
if (!binding) {
|
|
28
|
+
spinner.fail("当前目录未绑定工作区,请先运行 devforge sync");
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
const localDir = binding.local_dir;
|
|
32
|
+
const strategy = opts.strategy === "mirror" ? "mirror" : "incremental";
|
|
33
|
+
const dryRun = !!opts.dryRun;
|
|
34
|
+
if (opts.kb) {
|
|
35
|
+
await uploadKnowledgeBases(spinner, binding.workspace_id, localDir, opts.kbName, strategy, dryRun);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
spinner.fail(`上传失败: ${e.message}`);
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
});
|
|
43
|
+
async function uploadKnowledgeBases(spinner, wsId, localDir, kbNameFilter, strategy, dryRun) {
|
|
44
|
+
// 1. Get workspace's KBs from server
|
|
45
|
+
spinner.start("获取工作区绑定的知识库...");
|
|
46
|
+
const wsKbsRes = await api.getWorkspaceKnowledgeBases(wsId);
|
|
47
|
+
const serverKbs = wsKbsRes.data || [];
|
|
48
|
+
if (serverKbs.length === 0) {
|
|
49
|
+
spinner.info("当前工作区未绑定知识库,跳过");
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
spinner.succeed(`工作区已绑定 ${serverKbs.length} 个知识库`);
|
|
53
|
+
// 2. Read local KB directories
|
|
54
|
+
const kbRootDir = getKnowledgeInstallDir(localDir);
|
|
55
|
+
if (!existsSync(kbRootDir)) {
|
|
56
|
+
spinner.fail(`本地未找到知识库目录: ${kbRootDir},请先运行 devforge sync`);
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
const kbDirs = readdirSync(kbRootDir, { withFileTypes: true })
|
|
60
|
+
.filter((d) => d.isDirectory())
|
|
61
|
+
.map((d) => d.name);
|
|
62
|
+
if (kbDirs.length === 0) {
|
|
63
|
+
spinner.info("本地无可上传的知识库");
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
for (const kbName of kbDirs) {
|
|
67
|
+
// Apply kb-name filter
|
|
68
|
+
if (kbNameFilter && kbName !== kbNameFilter)
|
|
69
|
+
continue;
|
|
70
|
+
const kbDir = join(kbRootDir, kbName);
|
|
71
|
+
const manifestPath = join(kbDir, "manifest.json");
|
|
72
|
+
if (!existsSync(manifestPath)) {
|
|
73
|
+
spinner.warn(`跳过 ${kbName}: 缺少 manifest.json`);
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
|
|
77
|
+
// 3. Match server KB by name
|
|
78
|
+
let serverKb = serverKbs.find((k) => k.name === kbName);
|
|
79
|
+
if (!serverKb) {
|
|
80
|
+
spinner.warn(`服务端未找到同名知识库 "${kbName}",将自动创建`);
|
|
81
|
+
if (!dryRun) {
|
|
82
|
+
const createRes = await api.createKnowledgeBase({ name: kbName });
|
|
83
|
+
serverKb = createRes.data;
|
|
84
|
+
// Bind to workspace
|
|
85
|
+
if (serverKb?.id) {
|
|
86
|
+
await api.bindWorkspaceKnowledgeBase(wsId, [serverKb.id]);
|
|
87
|
+
spinner.succeed(`已创建并绑定知识库: ${kbName} (ID: ${serverKb.id})`);
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
else {
|
|
91
|
+
console.log(chalk.yellow(` [预览] 创建知识库: ${kbName}`));
|
|
92
|
+
console.log(chalk.yellow(` [预览] 绑定到工作区: ${wsId}`));
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
spinner.succeed(`已匹配知识库: ${kbName} (ID: ${serverKb.id})`);
|
|
98
|
+
}
|
|
99
|
+
if (!serverKb?.id)
|
|
100
|
+
continue;
|
|
101
|
+
// 4. Read index.json for local node tree
|
|
102
|
+
const indexPath = join(kbDir, "index.json");
|
|
103
|
+
if (!existsSync(indexPath)) {
|
|
104
|
+
spinner.warn(`跳过 ${kbName}: 缺少 index.json`);
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
const localNodes = JSON.parse(readFileSync(indexPath, "utf-8"));
|
|
108
|
+
// 5. Get server nodes
|
|
109
|
+
const serverNodesRes = await api.getKnowledgeNodes(serverKb.id);
|
|
110
|
+
const serverNodes = serverNodesRes.data || [];
|
|
111
|
+
// 6. Build path→node maps
|
|
112
|
+
const localByPath = new Map();
|
|
113
|
+
const serverByPath = new Map();
|
|
114
|
+
for (const n of localNodes) {
|
|
115
|
+
if (n.path)
|
|
116
|
+
localByPath.set(n.path, n);
|
|
117
|
+
}
|
|
118
|
+
for (const n of serverNodes) {
|
|
119
|
+
if (n.path)
|
|
120
|
+
serverByPath.set(n.path, n);
|
|
121
|
+
}
|
|
122
|
+
// Also index server nodes by title for pathless folders
|
|
123
|
+
const serverByTitle = new Map();
|
|
124
|
+
for (const n of serverNodes) {
|
|
125
|
+
if (n.type === "folder")
|
|
126
|
+
serverByTitle.set(n.title, n);
|
|
127
|
+
}
|
|
128
|
+
// 7. Execute diff sync
|
|
129
|
+
let created = 0, updated = 0, deleted = 0;
|
|
130
|
+
// 7a. Create/update local nodes
|
|
131
|
+
for (const localNode of localNodes) {
|
|
132
|
+
const serverNode = localNode.path ? serverByPath.get(localNode.path) : undefined;
|
|
133
|
+
if (serverNode) {
|
|
134
|
+
// Update existing node
|
|
135
|
+
const needsUpdate = localNode.title !== serverNode.title ||
|
|
136
|
+
localNode.sort_order !== serverNode.sort_order ||
|
|
137
|
+
localNode.parent_id !== serverNode.parent_id;
|
|
138
|
+
// For documents, also check content
|
|
139
|
+
let contentChanged = false;
|
|
140
|
+
if (localNode.type === "document") {
|
|
141
|
+
const localContent = readLocalDocumentContent(kbDir, localNode);
|
|
142
|
+
if (localContent !== (serverNode.content || "")) {
|
|
143
|
+
contentChanged = true;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
if (needsUpdate || contentChanged) {
|
|
147
|
+
const payload = { title: localNode.title, sort_order: localNode.sort_order };
|
|
148
|
+
if (contentChanged && localNode.type === "document") {
|
|
149
|
+
payload.content = readLocalDocumentContent(kbDir, localNode);
|
|
150
|
+
}
|
|
151
|
+
if (!dryRun) {
|
|
152
|
+
await api.updateKnowledgeNode(serverNode.id, payload);
|
|
153
|
+
}
|
|
154
|
+
updated++;
|
|
155
|
+
const action = contentChanged ? "内容更新" : "元信息更新";
|
|
156
|
+
spinner.text = ` ~ ${localNode.path || localNode.title} (${action})`;
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
spinner.text = ` ${localNode.path || localNode.title} (相同,跳过)`;
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
// Create new node — need to resolve parent_id
|
|
164
|
+
const parentId = resolveParentId(localNode, localNodes, serverNodes);
|
|
165
|
+
const payload = {
|
|
166
|
+
base_id: serverKb.id,
|
|
167
|
+
parent_id: parentId,
|
|
168
|
+
title: localNode.title,
|
|
169
|
+
type: localNode.type,
|
|
170
|
+
sort_order: localNode.sort_order,
|
|
171
|
+
path: localNode.path,
|
|
172
|
+
};
|
|
173
|
+
if (localNode.type === "document") {
|
|
174
|
+
payload.content = readLocalDocumentContent(kbDir, localNode);
|
|
175
|
+
}
|
|
176
|
+
if (!dryRun) {
|
|
177
|
+
await api.createKnowledgeNode(payload);
|
|
178
|
+
}
|
|
179
|
+
created++;
|
|
180
|
+
spinner.text = ` + ${localNode.path || localNode.title}`;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
// 7b. Delete server nodes missing locally (mirror mode only)
|
|
184
|
+
if (strategy === "mirror") {
|
|
185
|
+
for (const serverNode of serverNodes) {
|
|
186
|
+
const localExists = serverNode.path
|
|
187
|
+
? localByPath.has(serverNode.path)
|
|
188
|
+
: localNodes.some((n) => n.title === serverNode.title && n.type === serverNode.type);
|
|
189
|
+
if (!localExists) {
|
|
190
|
+
if (!dryRun) {
|
|
191
|
+
await api.deleteKnowledgeNode(serverNode.id);
|
|
192
|
+
}
|
|
193
|
+
deleted++;
|
|
194
|
+
spinner.text = ` - ${serverNode.path || serverNode.title}`;
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// 8. Report
|
|
199
|
+
const modeLabel = strategy === "mirror" ? "镜像" : "增量";
|
|
200
|
+
const dryLabel = dryRun ? " [预览]" : "";
|
|
201
|
+
spinner.succeed(`知识库 "${kbName}" 上传完成${dryLabel}(${modeLabel}) — +${created} ~${updated} -${deleted}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
// ── Helpers ──────────────────────────────────────────────────
|
|
205
|
+
/** 读取本地文档文件内容 */
|
|
206
|
+
function readLocalDocumentContent(kbDir, node) {
|
|
207
|
+
if (node.type !== "document")
|
|
208
|
+
return "";
|
|
209
|
+
// Try path first, then title as filename
|
|
210
|
+
const relPath = node.path || `${node.title}.md`;
|
|
211
|
+
const filePath = join(kbDir, relPath);
|
|
212
|
+
try {
|
|
213
|
+
return readFileSync(filePath, "utf-8");
|
|
214
|
+
}
|
|
215
|
+
catch {
|
|
216
|
+
return "";
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 为新建节点解析 parent_id。
|
|
221
|
+
* 优先用本地 index.json 中的 parent_id(指向本地 ID),映射为服务端节点 ID。
|
|
222
|
+
* 如果映射不到,尝试按 title 匹配服务端文件夹。
|
|
223
|
+
*/
|
|
224
|
+
function resolveParentId(localNode, allLocalNodes, serverNodes) {
|
|
225
|
+
if (localNode.parent_id == null)
|
|
226
|
+
return null;
|
|
227
|
+
const localParent = allLocalNodes.find((n) => n.id === localNode.parent_id);
|
|
228
|
+
if (!localParent)
|
|
229
|
+
return null;
|
|
230
|
+
// Match by path first
|
|
231
|
+
if (localParent.path) {
|
|
232
|
+
const match = serverNodes.find((sn) => sn.path === localParent.path);
|
|
233
|
+
if (match)
|
|
234
|
+
return match.id;
|
|
235
|
+
}
|
|
236
|
+
// Fallback: match by title (for folders)
|
|
237
|
+
const match = serverNodes.find((sn) => sn.type === "folder" && sn.title === localParent.title);
|
|
238
|
+
return match?.id ?? null;
|
|
239
|
+
}
|
package/dist/config.d.ts
CHANGED
|
@@ -2,6 +2,8 @@ export interface Binding {
|
|
|
2
2
|
local_dir: string;
|
|
3
3
|
workspace_id: number;
|
|
4
4
|
workspace_name: string;
|
|
5
|
+
/** 绑定到当前目录时的目标 AI 编码平台,留空则使用全局配置/环境变量/默认值 */
|
|
6
|
+
platform?: string;
|
|
5
7
|
last_synced?: string;
|
|
6
8
|
}
|
|
7
9
|
export interface DevForgeConfig {
|
|
@@ -16,8 +18,14 @@ export type SupportedPlatform = (typeof SUPPORTED_PLATFORMS)[number];
|
|
|
16
18
|
export declare function ensureConfigDir(): void;
|
|
17
19
|
export declare function readConfig(): DevForgeConfig | null;
|
|
18
20
|
export declare function writeConfig(config: DevForgeConfig): void;
|
|
19
|
-
/**
|
|
20
|
-
export declare function
|
|
21
|
+
/** 确定目标 AI 编码平台。优先级: 显示传入 > 绑定上的平台 > 全局配置 > 环境变量 > 默认 claude_code */
|
|
22
|
+
export declare function resolvePlatform(override?: string, binding?: Binding): string;
|
|
23
|
+
/**
|
|
24
|
+
* 清理绑定残留的同步产物。
|
|
25
|
+
* 工作区间切换时: 删除所有可能遗留的内容(知识库、规则缓存、规则文件、项目级 skill)。
|
|
26
|
+
* 平台切换时: 仅清理特定平台的文件。
|
|
27
|
+
*/
|
|
28
|
+
export declare function cleanupBindingArtifacts(localDir: string, oldPlatform?: string): void;
|
|
21
29
|
export declare function findBinding(config: DevForgeConfig, localDir?: string): Binding | null;
|
|
22
30
|
export declare function getWorkspaceDir(workspaceId: number): string;
|
|
23
31
|
export declare function ensureWorkspaceDir(workspaceId: number): string;
|
package/dist/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
const CONFIG_DIR = join(homedir(), ".devforge");
|
|
@@ -23,11 +23,14 @@ export function writeConfig(config) {
|
|
|
23
23
|
ensureConfigDir();
|
|
24
24
|
writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), "utf-8");
|
|
25
25
|
}
|
|
26
|
-
/**
|
|
27
|
-
export function
|
|
26
|
+
/** 确定目标 AI 编码平台。优先级: 显示传入 > 绑定上的平台 > 全局配置 > 环境变量 > 默认 claude_code */
|
|
27
|
+
export function resolvePlatform(override, binding) {
|
|
28
28
|
if (override && SUPPORTED_PLATFORMS.includes(override)) {
|
|
29
29
|
return override;
|
|
30
30
|
}
|
|
31
|
+
if (binding?.platform && SUPPORTED_PLATFORMS.includes(binding.platform)) {
|
|
32
|
+
return binding.platform;
|
|
33
|
+
}
|
|
31
34
|
const config = readConfig();
|
|
32
35
|
if (config?.platform && SUPPORTED_PLATFORMS.includes(config.platform)) {
|
|
33
36
|
return config.platform;
|
|
@@ -38,6 +41,45 @@ export function getPlatform(override) {
|
|
|
38
41
|
}
|
|
39
42
|
return "claude_code";
|
|
40
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* 清理绑定残留的同步产物。
|
|
46
|
+
* 工作区间切换时: 删除所有可能遗留的内容(知识库、规则缓存、规则文件、项目级 skill)。
|
|
47
|
+
* 平台切换时: 仅清理特定平台的文件。
|
|
48
|
+
*/
|
|
49
|
+
export function cleanupBindingArtifacts(localDir, oldPlatform) {
|
|
50
|
+
// 知识库目录(按工作区隔离,始终清理)
|
|
51
|
+
const kbDir = getKnowledgeInstallDir(localDir);
|
|
52
|
+
if (existsSync(kbDir))
|
|
53
|
+
rmSync(kbDir, { recursive: true, force: true });
|
|
54
|
+
// 规则元数据缓存(始终清理)
|
|
55
|
+
const rulesDir = getRulesCacheDir(localDir);
|
|
56
|
+
if (existsSync(rulesDir))
|
|
57
|
+
rmSync(rulesDir, { recursive: true, force: true });
|
|
58
|
+
if (oldPlatform) {
|
|
59
|
+
// 已知旧平台 → 只删除该平台的规则文件
|
|
60
|
+
const rulePath = join(localDir, getRuleFileName(oldPlatform));
|
|
61
|
+
if (existsSync(rulePath))
|
|
62
|
+
rmSync(rulePath, { force: true });
|
|
63
|
+
// 只删除该平台的项目级技能
|
|
64
|
+
const skillDir = getSkillInstallDir(localDir, "project", oldPlatform);
|
|
65
|
+
if (existsSync(skillDir))
|
|
66
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
// 不知道旧平台 → 扫一遍所有已知的规则文件名
|
|
70
|
+
for (const fileName of Object.values(RULE_FILE_NAMES)) {
|
|
71
|
+
const rulePath = join(localDir, fileName);
|
|
72
|
+
if (existsSync(rulePath))
|
|
73
|
+
rmSync(rulePath, { force: true });
|
|
74
|
+
}
|
|
75
|
+
// 与规则文件名列表同源遍历所有平台的项目级技能目录
|
|
76
|
+
for (const plat of Object.keys(RULE_FILE_NAMES)) {
|
|
77
|
+
const skillDir = getSkillInstallDir(localDir, "project", plat);
|
|
78
|
+
if (existsSync(skillDir))
|
|
79
|
+
rmSync(skillDir, { recursive: true, force: true });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
41
83
|
export function findBinding(config, localDir) {
|
|
42
84
|
const dir = localDir || process.cwd();
|
|
43
85
|
return config.bindings.find((b) => b.local_dir === dir) || null;
|
package/dist/index.js
CHANGED
|
@@ -10,6 +10,7 @@ import { installCommand } from "./commands/install.js";
|
|
|
10
10
|
import { listCommand } from "./commands/list.js";
|
|
11
11
|
import { statusCommand } from "./commands/status.js";
|
|
12
12
|
import { unbindCommand } from "./commands/unbind.js";
|
|
13
|
+
import { uploadCommand } from "./commands/upload.js";
|
|
13
14
|
const program = new Command();
|
|
14
15
|
program
|
|
15
16
|
.name("devforge")
|
|
@@ -24,6 +25,7 @@ program
|
|
|
24
25
|
.addCommand(listCommand)
|
|
25
26
|
.addCommand(statusCommand)
|
|
26
27
|
.addCommand(unbindCommand)
|
|
28
|
+
.addCommand(uploadCommand)
|
|
27
29
|
.hook("preAction", (thisCommand) => {
|
|
28
30
|
const opts = thisCommand.opts();
|
|
29
31
|
// require api-key for most commands except version/help
|