@ppdocs/mcp 2.9.0 → 3.0.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.
@@ -4,7 +4,7 @@
4
4
  *
5
5
  * API URL 格式: http://localhost:20001/api/:projectId/:password/...
6
6
  */
7
- import type { DocData, DocNode, DocSearchResult, Task, TaskSummary, TaskLogType, TaskExperience } from './types.js';
7
+ import type { DocData, DocNode, DocSearchResult, Task, TaskSummary, TaskLogType, TaskExperience, FileInfo, RuleMeta } from './types.js';
8
8
  interface TreeNode {
9
9
  path: string;
10
10
  name: string;
@@ -14,6 +14,7 @@ interface TreeNode {
14
14
  }
15
15
  export declare class PpdocsApiClient {
16
16
  private baseUrl;
17
+ private serverUrl;
17
18
  constructor(apiUrl: string);
18
19
  private request;
19
20
  listDocs(): Promise<DocNode[]>;
@@ -28,6 +29,11 @@ export declare class PpdocsApiClient {
28
29
  getTree(): Promise<TreeNode[]>;
29
30
  getRulesApi(ruleType: string): Promise<string[]>;
30
31
  saveRulesApi(ruleType: string, rules: string[]): Promise<boolean>;
32
+ getRulesMeta(): Promise<Record<string, RuleMeta>>;
33
+ saveRulesMeta(meta: Record<string, RuleMeta>): Promise<boolean>;
34
+ crossGetRulesMeta(target: string): Promise<Record<string, RuleMeta>>;
35
+ getGlobalRulesMeta(): Promise<Record<string, RuleMeta>>;
36
+ saveGlobalRulesMeta(meta: Record<string, RuleMeta>): Promise<boolean>;
31
37
  listTasks(status?: 'active' | 'archived'): Promise<TaskSummary[]>;
32
38
  getTask(taskId: string): Promise<Task | null>;
33
39
  createTask(task: {
@@ -37,12 +43,20 @@ export declare class PpdocsApiClient {
37
43
  }, creator: string): Promise<Task>;
38
44
  addTaskLog(taskId: string, logType: TaskLogType, content: string): Promise<Task | null>;
39
45
  completeTask(taskId: string, experience: TaskExperience): Promise<Task | null>;
46
+ /** 创建项目 (公开路由,无需认证) */
47
+ createProject(id: string, name: string, description?: string, projectPath?: string): Promise<{
48
+ project: {
49
+ id: string;
50
+ name: string;
51
+ };
52
+ password: string;
53
+ }>;
40
54
  /** 列出所有可访问的项目 */
41
55
  crossListProjects(): Promise<{
42
56
  id: string;
43
57
  name: string;
44
58
  description: string;
45
- updated_at: string;
59
+ updatedAt: string;
46
60
  }[]>;
47
61
  /** 跨项目: 列出文档 */
48
62
  crossListDocs(target: string): Promise<DocNode[]>;
@@ -54,6 +68,30 @@ export declare class PpdocsApiClient {
54
68
  crossGetDocsByStatus(target: string, statusList: string[]): Promise<DocNode[]>;
55
69
  /** 跨项目: 获取规则 */
56
70
  crossGetRules(target: string, ruleType: string): Promise<string[]>;
71
+ /** 列出项目文件 */
72
+ listFiles(dir?: string): Promise<FileInfo[]>;
73
+ /** 读取项目文件 */
74
+ readFile(filePath: string): Promise<string>;
75
+ /** 下载项目目录 (zip) → 自动解压到 temp 目录 */
76
+ downloadDir(dir: string): Promise<{
77
+ localPath: string;
78
+ fileCount: number;
79
+ }>;
80
+ /** 上传本地目录到中心服务器 (打包 zip → POST) */
81
+ uploadFiles(localDir: string, remoteDir?: string): Promise<{
82
+ fileCount: number;
83
+ }>;
84
+ /** 清空项目文件存储区 */
85
+ clearFiles(): Promise<void>;
86
+ /** 跨项目: 列出文件 */
87
+ crossListFiles(target: string, dir?: string): Promise<FileInfo[]>;
88
+ /** 跨项目: 读取文件 */
89
+ crossReadFile(target: string, filePath: string): Promise<string>;
90
+ /** 跨项目: 下载目录 */
91
+ crossDownloadDir(target: string, dir: string): Promise<{
92
+ localPath: string;
93
+ fileCount: number;
94
+ }>;
57
95
  }
58
96
  export declare function initClient(apiUrl: string): void;
59
97
  export declare function listDocs(_projectId: string): Promise<DocNode[]>;
@@ -66,6 +104,11 @@ export declare function getDocsByStatus(_projectId: string, statusList: string[]
66
104
  export declare function getTree(_projectId: string): Promise<TreeNode[]>;
67
105
  export declare function getRules(_projectId: string, ruleType: string): Promise<string[]>;
68
106
  export declare function saveRules(_projectId: string, ruleType: string, rules: string[]): Promise<boolean>;
107
+ export declare function getRulesMeta(): Promise<Record<string, RuleMeta>>;
108
+ export declare function saveRulesMeta(meta: Record<string, RuleMeta>): Promise<boolean>;
109
+ export declare function crossGetRulesMeta(target: string): Promise<Record<string, RuleMeta>>;
110
+ export declare function getGlobalRulesMeta(): Promise<Record<string, RuleMeta>>;
111
+ export declare function saveGlobalRulesMeta(meta: Record<string, RuleMeta>): Promise<boolean>;
69
112
  export declare function listTasks(_projectId: string, status?: 'active' | 'archived'): Promise<TaskSummary[]>;
70
113
  export declare function getTask(_projectId: string, taskId: string): Promise<Task | null>;
71
114
  export declare function createTask(_projectId: string, task: {
@@ -75,14 +118,37 @@ export declare function createTask(_projectId: string, task: {
75
118
  }, creator: string): Promise<Task>;
76
119
  export declare function addTaskLog(_projectId: string, taskId: string, logType: TaskLogType, content: string): Promise<Task | null>;
77
120
  export declare function completeTask(_projectId: string, taskId: string, experience: TaskExperience): Promise<Task | null>;
121
+ export declare function createProject(id: string, name: string, description?: string, projectPath?: string): Promise<{
122
+ project: {
123
+ id: string;
124
+ name: string;
125
+ };
126
+ password: string;
127
+ }>;
78
128
  export declare function crossListProjects(): Promise<{
79
129
  id: string;
80
130
  name: string;
81
131
  description: string;
82
- updated_at: string;
132
+ updatedAt: string;
83
133
  }[]>;
84
134
  export declare function crossGetDoc(target: string, docPath: string): Promise<DocData | null>;
85
135
  export declare function crossGetTree(target: string): Promise<TreeNode[]>;
86
136
  export declare function crossGetDocsByStatus(target: string, statusList: string[]): Promise<DocNode[]>;
87
137
  export declare function crossGetRules(target: string, ruleType: string): Promise<string[]>;
138
+ export declare function listFiles(dir?: string): Promise<FileInfo[]>;
139
+ export declare function readFile(filePath: string): Promise<string>;
140
+ export declare function downloadDir(dir: string): Promise<{
141
+ localPath: string;
142
+ fileCount: number;
143
+ }>;
144
+ export declare function crossListFiles(target: string, dir?: string): Promise<FileInfo[]>;
145
+ export declare function crossReadFile(target: string, filePath: string): Promise<string>;
146
+ export declare function crossDownloadDir(target: string, dir: string): Promise<{
147
+ localPath: string;
148
+ fileCount: number;
149
+ }>;
150
+ export declare function uploadFiles(localDir: string, remoteDir?: string): Promise<{
151
+ fileCount: number;
152
+ }>;
153
+ export declare function clearFiles(): Promise<void>;
88
154
  export type { TreeNode };
@@ -4,6 +4,57 @@
4
4
  *
5
5
  * API URL 格式: http://localhost:20001/api/:projectId/:password/...
6
6
  */
7
+ // ============ 工具函数 ============
8
+ /** 清理路径前缀斜杠 */
9
+ function cleanPath(p) {
10
+ return p.replace(/^\//, '');
11
+ }
12
+ /** 排除的目录 (与 Rust 端 EXCLUDED_DIRS 保持一致) */
13
+ const EXCLUDED_DIRS = new Set([
14
+ 'node_modules', '.git', 'target', 'dist', 'build', '.next',
15
+ '__pycache__', '.venv', 'venv', '.idea', '.vs', '.vscode',
16
+ ]);
17
+ /** 下载 zip 并解压到 temp 目录 */
18
+ async function fetchAndExtractZip(url) {
19
+ const controller = new AbortController();
20
+ const timeout = setTimeout(() => controller.abort(), 60000);
21
+ try {
22
+ const response = await fetch(url, { signal: controller.signal });
23
+ if (!response.ok) {
24
+ const error = await response.json().catch(() => ({ error: response.statusText }));
25
+ throw new Error(error.error || `HTTP ${response.status}`);
26
+ }
27
+ const buffer = Buffer.from(await response.arrayBuffer());
28
+ const os = await import('os');
29
+ const path = await import('path');
30
+ const fs = await import('fs');
31
+ const AdmZip = (await import('adm-zip')).default;
32
+ const tempDir = path.join(os.tmpdir(), `ppdocs-files-${Date.now()}`);
33
+ fs.mkdirSync(tempDir, { recursive: true });
34
+ const zip = new AdmZip(buffer);
35
+ zip.extractAllTo(tempDir, true);
36
+ let fileCount = 0;
37
+ const countFiles = (dirPath) => {
38
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
39
+ if (entry.isDirectory())
40
+ countFiles(path.join(dirPath, entry.name));
41
+ else
42
+ fileCount++;
43
+ }
44
+ };
45
+ countFiles(tempDir);
46
+ return { localPath: tempDir, fileCount };
47
+ }
48
+ catch (err) {
49
+ if (err instanceof Error && err.name === 'AbortError') {
50
+ throw new Error('Download timeout (60s)');
51
+ }
52
+ throw err;
53
+ }
54
+ finally {
55
+ clearTimeout(timeout);
56
+ }
57
+ }
7
58
  /** 从扁平文档列表构建目录树 */
8
59
  function buildTree(docs) {
9
60
  const root = [];
@@ -40,9 +91,12 @@ function buildTree(docs) {
40
91
  // API 客户端类
41
92
  export class PpdocsApiClient {
42
93
  baseUrl; // http://localhost:20001/api/projectId/password
94
+ serverUrl; // http://localhost:20001
43
95
  constructor(apiUrl) {
44
- // 移除末尾斜杠
45
96
  this.baseUrl = apiUrl.replace(/\/$/, '');
97
+ // 从 /api/projectId/password 提取服务器根地址
98
+ const apiIndex = this.baseUrl.indexOf('/api/');
99
+ this.serverUrl = apiIndex > 0 ? this.baseUrl.substring(0, apiIndex) : this.baseUrl;
46
100
  }
47
101
  // ============ HTTP 请求工具 ============
48
102
  async request(path, options = {}) {
@@ -84,16 +138,13 @@ export class PpdocsApiClient {
84
138
  }
85
139
  async getDoc(docPath) {
86
140
  try {
87
- // 移除开头的斜杠,API 路径会自动处理
88
- const cleanPath = docPath.replace(/^\//, '');
89
- return await this.request(`/docs/${cleanPath}`);
141
+ return await this.request(`/docs/${cleanPath(docPath)}`);
90
142
  }
91
143
  catch {
92
144
  return null;
93
145
  }
94
146
  }
95
147
  async createDoc(docPath, doc) {
96
- const cleanPath = docPath.replace(/^\//, '');
97
148
  const payload = {
98
149
  summary: doc.summary || '',
99
150
  content: doc.content || '',
@@ -103,17 +154,15 @@ export class PpdocsApiClient {
103
154
  changes: '初始创建'
104
155
  }],
105
156
  bugfixes: doc.bugfixes || [],
106
- status: doc.status || '已完成' // 默认状态
157
+ status: doc.status || '已完成'
107
158
  };
108
- return this.request(`/docs/${cleanPath}`, {
159
+ return this.request(`/docs/${cleanPath(docPath)}`, {
109
160
  method: 'POST',
110
161
  body: JSON.stringify(payload)
111
162
  });
112
163
  }
113
164
  async updateDoc(docPath, updates) {
114
165
  try {
115
- const cleanPath = docPath.replace(/^\//, '');
116
- // 先获取现有文档
117
166
  const existing = await this.getDoc(docPath);
118
167
  if (!existing)
119
168
  return null;
@@ -124,7 +173,7 @@ export class PpdocsApiClient {
124
173
  bugfixes: updates.bugfixes ?? existing.bugfixes,
125
174
  status: updates.status ?? existing.status ?? '已完成'
126
175
  };
127
- return await this.request(`/docs/${cleanPath}`, {
176
+ return await this.request(`/docs/${cleanPath(docPath)}`, {
128
177
  method: 'PUT',
129
178
  body: JSON.stringify(payload)
130
179
  });
@@ -135,8 +184,7 @@ export class PpdocsApiClient {
135
184
  }
136
185
  async deleteDoc(docPath) {
137
186
  try {
138
- const cleanPath = docPath.replace(/^\//, '');
139
- await this.request(`/docs/${cleanPath}`, { method: 'DELETE' });
187
+ await this.request(`/docs/${cleanPath(docPath)}`, { method: 'DELETE' });
140
188
  return true;
141
189
  }
142
190
  catch {
@@ -182,6 +230,54 @@ export class PpdocsApiClient {
182
230
  return false;
183
231
  }
184
232
  }
233
+ async getRulesMeta() {
234
+ try {
235
+ return await this.request('/rules-meta');
236
+ }
237
+ catch {
238
+ return {};
239
+ }
240
+ }
241
+ async saveRulesMeta(meta) {
242
+ try {
243
+ await this.request('/rules-meta', {
244
+ method: 'PUT',
245
+ body: JSON.stringify(meta)
246
+ });
247
+ return true;
248
+ }
249
+ catch {
250
+ return false;
251
+ }
252
+ }
253
+ async crossGetRulesMeta(target) {
254
+ try {
255
+ return await this.request(`/cross/${encodeURIComponent(target)}/rules-meta`);
256
+ }
257
+ catch {
258
+ return {};
259
+ }
260
+ }
261
+ async getGlobalRulesMeta() {
262
+ try {
263
+ return await this.request('/global-rules-meta');
264
+ }
265
+ catch {
266
+ return {};
267
+ }
268
+ }
269
+ async saveGlobalRulesMeta(meta) {
270
+ try {
271
+ await this.request('/global-rules-meta', {
272
+ method: 'PUT',
273
+ body: JSON.stringify(meta)
274
+ });
275
+ return true;
276
+ }
277
+ catch {
278
+ return false;
279
+ }
280
+ }
185
281
  // ============ 任务管理 ============
186
282
  async listTasks(status) {
187
283
  const query = status ? `?status=${status}` : '';
@@ -232,6 +328,22 @@ export class PpdocsApiClient {
232
328
  return null;
233
329
  }
234
330
  }
331
+ // ============ 项目管理 ============
332
+ /** 创建项目 (公开路由,无需认证) */
333
+ async createProject(id, name, description, projectPath) {
334
+ const url = `${this.serverUrl}/api/projects`;
335
+ const response = await fetch(url, {
336
+ method: 'POST',
337
+ headers: { 'Content-Type': 'application/json' },
338
+ body: JSON.stringify({ id, name, description: description || '', project_path: projectPath || '' }),
339
+ });
340
+ if (!response.ok) {
341
+ const error = await response.json().catch(() => ({ error: response.statusText }));
342
+ throw new Error(error.error || `HTTP ${response.status}`);
343
+ }
344
+ const json = await response.json();
345
+ return json.data;
346
+ }
235
347
  // ============ 跨项目只读访问 ============
236
348
  /** 列出所有可访问的项目 */
237
349
  async crossListProjects() {
@@ -244,8 +356,7 @@ export class PpdocsApiClient {
244
356
  /** 跨项目: 获取文档 */
245
357
  async crossGetDoc(target, docPath) {
246
358
  try {
247
- const cleanPath = docPath.replace(/^\//, '');
248
- return await this.request(`/cross/${encodeURIComponent(target)}/docs/${cleanPath}`);
359
+ return await this.request(`/cross/${encodeURIComponent(target)}/docs/${cleanPath(docPath)}`);
249
360
  }
250
361
  catch {
251
362
  return null;
@@ -272,6 +383,87 @@ export class PpdocsApiClient {
272
383
  return [];
273
384
  }
274
385
  }
386
+ // ============ 项目文件访问 ============
387
+ /** 列出项目文件 */
388
+ async listFiles(dir) {
389
+ const query = dir ? `?dir=${encodeURIComponent(dir)}` : '';
390
+ return this.request(`/files${query}`);
391
+ }
392
+ /** 读取项目文件 */
393
+ async readFile(filePath) {
394
+ return this.request(`/files/${cleanPath(filePath)}`);
395
+ }
396
+ /** 下载项目目录 (zip) → 自动解压到 temp 目录 */
397
+ async downloadDir(dir) {
398
+ return fetchAndExtractZip(`${this.baseUrl}/files-download/${cleanPath(dir)}`);
399
+ }
400
+ /** 上传本地目录到中心服务器 (打包 zip → POST) */
401
+ async uploadFiles(localDir, remoteDir) {
402
+ const fs = await import('fs');
403
+ const path = await import('path');
404
+ const AdmZip = (await import('adm-zip')).default;
405
+ if (!fs.existsSync(localDir) || !fs.statSync(localDir).isDirectory()) {
406
+ throw new Error(`本地目录不存在: ${localDir}`);
407
+ }
408
+ const zip = new AdmZip();
409
+ const addDir = (dirPath, zipPath) => {
410
+ for (const entry of fs.readdirSync(dirPath, { withFileTypes: true })) {
411
+ if (entry.name.startsWith('.'))
412
+ continue;
413
+ const fullPath = path.join(dirPath, entry.name);
414
+ if (entry.isDirectory()) {
415
+ if (EXCLUDED_DIRS.has(entry.name))
416
+ continue;
417
+ addDir(fullPath, zipPath ? `${zipPath}/${entry.name}` : entry.name);
418
+ }
419
+ else {
420
+ if (fs.statSync(fullPath).size > 10_485_760)
421
+ continue;
422
+ zip.addLocalFile(fullPath, zipPath || undefined);
423
+ }
424
+ }
425
+ };
426
+ addDir(localDir, '');
427
+ const buffer = zip.toBuffer();
428
+ if (buffer.length > 104_857_600) {
429
+ throw new Error('打包后超过 100MB 限制,请缩小目录范围');
430
+ }
431
+ const query = remoteDir ? `?dir=${encodeURIComponent(remoteDir)}` : '';
432
+ const response = await fetch(`${this.baseUrl}/files${query}`, {
433
+ method: 'POST',
434
+ headers: { 'Content-Type': 'application/octet-stream' },
435
+ body: new Uint8Array(buffer),
436
+ });
437
+ if (!response.ok) {
438
+ const error = await response.json().catch(() => ({ error: response.statusText }));
439
+ throw new Error(error.error || `HTTP ${response.status}`);
440
+ }
441
+ const result = await response.json();
442
+ return { fileCount: result.data?.fileCount || 0 };
443
+ }
444
+ /** 清空项目文件存储区 */
445
+ async clearFiles() {
446
+ const url = `${this.baseUrl}/files`;
447
+ const response = await fetch(url, { method: 'DELETE' });
448
+ if (!response.ok) {
449
+ const error = await response.json().catch(() => ({ error: response.statusText }));
450
+ throw new Error(error.error || `HTTP ${response.status}`);
451
+ }
452
+ }
453
+ // ============ 跨项目文件访问 (只读) ============
454
+ /** 跨项目: 列出文件 */
455
+ async crossListFiles(target, dir) {
456
+ const query = dir ? `?dir=${encodeURIComponent(dir)}` : '';
457
+ return this.request(`/cross/${encodeURIComponent(target)}/files${query}`);
458
+ }
459
+ /** 跨项目: 读取文件 */
460
+ async crossReadFile(target, filePath) {
461
+ return this.request(`/cross/${encodeURIComponent(target)}/files/${cleanPath(filePath)}`);
462
+ }
463
+ /** 跨项目: 下载目录 */
464
+ async crossDownloadDir(target, dir) {
465
+ return fetchAndExtractZip(`${this.baseUrl}/cross/${encodeURIComponent(target)}/files-download/${cleanPath(dir)}`);
466
+ }
275
467
  }
276
468
  // ============ 模块级 API ============
277
469
  let client = null;
@@ -316,6 +508,21 @@ export async function getRules(_projectId, ruleType) {
316
508
  export async function saveRules(_projectId, ruleType, rules) {
317
509
  return getClient().saveRulesApi(ruleType, rules);
318
510
  }
511
+ export async function getRulesMeta() {
512
+ return getClient().getRulesMeta();
513
+ }
514
+ export async function saveRulesMeta(meta) {
515
+ return getClient().saveRulesMeta(meta);
516
+ }
517
+ export async function crossGetRulesMeta(target) {
518
+ return getClient().crossGetRulesMeta(target);
519
+ }
520
+ export async function getGlobalRulesMeta() {
521
+ return getClient().getGlobalRulesMeta();
522
+ }
523
+ export async function saveGlobalRulesMeta(meta) {
524
+ return getClient().saveGlobalRulesMeta(meta);
525
+ }
319
526
  // ============ 任务管理 ============
320
527
  export async function listTasks(_projectId, status) {
321
528
  return getClient().listTasks(status);
@@ -332,6 +539,10 @@ export async function addTaskLog(_projectId, taskId, logType, content) {
332
539
  export async function completeTask(_projectId, taskId, experience) {
333
540
  return getClient().completeTask(taskId, experience);
334
541
  }
542
+ // ============ 项目管理 ============
543
+ export async function createProject(id, name, description, projectPath) {
544
+ return getClient().createProject(id, name, description, projectPath);
545
+ }
335
546
  // ============ 跨项目只读访问 ============
336
547
  export async function crossListProjects() {
337
548
  return getClient().crossListProjects();
@@ -348,3 +559,28 @@ export async function crossGetDocsByStatus(target, statusList) {
348
559
  export async function crossGetRules(target, ruleType) {
349
560
  return getClient().crossGetRules(target, ruleType);
350
561
  }
562
+ // ============ 项目文件访问 ============
563
+ export async function listFiles(dir) {
564
+ return getClient().listFiles(dir);
565
+ }
566
+ export async function readFile(filePath) {
567
+ return getClient().readFile(filePath);
568
+ }
569
+ export async function downloadDir(dir) {
570
+ return getClient().downloadDir(dir);
571
+ }
572
+ export async function crossListFiles(target, dir) {
573
+ return getClient().crossListFiles(target, dir);
574
+ }
575
+ export async function crossReadFile(target, filePath) {
576
+ return getClient().crossReadFile(target, filePath);
577
+ }
578
+ export async function crossDownloadDir(target, dir) {
579
+ return getClient().crossDownloadDir(target, dir);
580
+ }
581
+ export async function uploadFiles(localDir, remoteDir) {
582
+ return getClient().uploadFiles(localDir, remoteDir);
583
+ }
584
+ export async function clearFiles() {
585
+ return getClient().clearFiles();
586
+ }
@@ -44,6 +44,21 @@ export interface Project {
44
44
  name: string;
45
45
  description?: string;
46
46
  updatedAt: string;
47
+ createdAt?: string;
48
+ projectPath?: string;
49
+ }
50
+ export interface RuleMeta {
51
+ label: string;
52
+ keywords: string[];
53
+ min_hits: number;
54
+ always?: boolean;
55
+ }
56
+ export interface FileInfo {
57
+ name: string;
58
+ path: string;
59
+ isDir: boolean;
60
+ size: number;
61
+ modifiedAt?: string;
47
62
  }
48
63
  export type TaskLogType = 'progress' | 'issue' | 'solution' | 'reference';
49
64
  export interface TaskLog {
@@ -30,4 +30,8 @@ export declare function formatTreeText(tree: TreeNode[]): string;
30
30
  * 统计树中的文档数
31
31
  */
32
32
  export declare function countTreeDocs(tree: TreeNode[]): number;
33
+ /**
34
+ * 格式化文件大小
35
+ */
36
+ export declare function formatFileSize(bytes: number): string;
33
37
  export {};
@@ -81,3 +81,14 @@ export function countTreeDocs(tree) {
81
81
  traverse(tree);
82
82
  return count;
83
83
  }
84
+ // ==================== 文件大小格式化 ====================
85
+ /**
86
+ * 格式化文件大小
87
+ */
88
+ export function formatFileSize(bytes) {
89
+ if (bytes < 1024)
90
+ return `${bytes} B`;
91
+ if (bytes < 1024 * 1024)
92
+ return `${(bytes / 1024).toFixed(1)} KB`;
93
+ return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
94
+ }
@@ -1,7 +1,7 @@
1
1
  import { z } from 'zod';
2
2
  import * as storage from '../storage/httpClient.js';
3
3
  import { decodeObjectStrings, getRules, RULE_TYPE_LABELS } from '../utils.js';
4
- import { wrap, formatDocMarkdown, formatTreeText, countTreeDocs } from './helpers.js';
4
+ import { wrap, formatDocMarkdown, formatTreeText, countTreeDocs, formatFileSize } from './helpers.js';
5
5
  export function registerTools(server, projectId, _user) {
6
6
  // ===================== 跨项目访问 =====================
7
7
  // 0. 列出所有可访问的项目
@@ -21,6 +21,21 @@ export function registerTools(server, projectId, _user) {
21
21
  return wrap(`获取项目列表失败: ${String(e)}`);
22
22
  }
23
23
  });
24
+ // 0.1 创建项目
25
+ server.tool('kg_create_project', '创建新项目。返回项目ID和密码(密码用于MCP连接)', {
26
+ id: z.string().describe('项目ID(英文/数字/中文,如"my-app")'),
27
+ name: z.string().describe('项目名称'),
28
+ description: z.string().optional().describe('项目简介'),
29
+ projectPath: z.string().optional().describe('项目源码路径(本地绝对路径)'),
30
+ }, async (args) => {
31
+ try {
32
+ const result = await storage.createProject(args.id, args.name, args.description, args.projectPath);
33
+ return wrap(`✅ 项目创建成功\n\n- 项目ID: ${result.project.id}\n- 项目名: ${result.project.name}\n- 密码: ${result.password}\n\nMCP 连接地址: http://<服务器IP>:20001/api/${result.project.id}/${result.password}`);
34
+ }
35
+ catch (e) {
36
+ return wrap(`❌ ${String(e)}`);
37
+ }
38
+ });
24
39
  // ===================== 文档管理 =====================
25
40
  // 1. 创建文档
26
41
  server.tool('kg_create_node', '创建知识文档。使用目录路径分类,文件名作为文档名', {
@@ -183,25 +198,29 @@ export function registerTools(server, projectId, _user) {
183
198
  });
184
199
  // 3.6 获取项目规则
185
200
  server.tool('kg_get_rules', '获取项目规则(可指定类型或获取全部)。支持跨项目只读访问', {
186
- ruleType: z.enum(['userStyles', 'testRules', 'reviewRules', 'codeStyle', 'unitTests']).optional()
187
- .describe('规则类型: userStyles=用户沟通规则, codeStyle=编码风格规则, reviewRules=代码审查规则, testRules=错误分析规则, unitTests=代码测试规则。不传则返回全部'),
201
+ ruleType: z.string().optional()
202
+ .describe('规则类型(如 userStyles, codeStyle, reviewRules, testRules, unitTests, 或自定义类型)。不传则返回全部'),
188
203
  targetProject: z.string().optional().describe('目标项目ID或名称(不填=当前项目,跨项目只读)')
189
204
  }, async (args) => {
190
205
  // 跨项目访问
191
206
  if (args.targetProject) {
207
+ const crossMeta = await storage.crossGetRulesMeta(args.targetProject);
192
208
  if (args.ruleType) {
193
209
  const rules = await storage.crossGetRules(args.targetProject, args.ruleType);
194
210
  if (rules.length === 0) {
195
- return { content: [{ type: 'text', text: `暂无${RULE_TYPE_LABELS[args.ruleType]}规则(项目: ${args.targetProject})` }] };
211
+ const label = crossMeta[args.ruleType]?.label || args.ruleType;
212
+ return { content: [{ type: 'text', text: `暂无${label}规则(项目: ${args.targetProject})` }] };
196
213
  }
197
214
  return { content: [{ type: 'text', text: `[跨项目: ${args.targetProject}]\n\n${rules.join('\n')}` }] };
198
215
  }
199
- // 获取全部规则
216
+ // 获取全部规则 (从 meta 获取所有类型)
217
+ const types = Object.keys(crossMeta).length > 0 ? Object.keys(crossMeta) : Object.keys(RULE_TYPE_LABELS);
200
218
  const allRules = [];
201
- for (const type of ['userStyles', 'codeStyle', 'reviewRules', 'testRules', 'unitTests']) {
219
+ for (const type of types) {
202
220
  const rules = await storage.crossGetRules(args.targetProject, type);
203
221
  if (rules.length > 0) {
204
- allRules.push(`## ${RULE_TYPE_LABELS[type]}\n${rules.join('\n')}`);
222
+ const label = crossMeta[type]?.label || RULE_TYPE_LABELS[type] || type;
223
+ allRules.push(`## ${label}\n${rules.join('\n')}`);
205
224
  }
206
225
  }
207
226
  if (allRules.length === 0) {
@@ -210,17 +229,18 @@ export function registerTools(server, projectId, _user) {
210
229
  return { content: [{ type: 'text', text: `[跨项目: ${args.targetProject}]\n\n${allRules.join('\n\n')}` }] };
211
230
  }
212
231
  // 当前项目
213
- const rules = await getRules(projectId, args.ruleType);
232
+ const rules = await getRules(projectId, args.ruleType || undefined);
214
233
  if (!rules || rules.trim() === '') {
215
- const typeName = args.ruleType ? RULE_TYPE_LABELS[args.ruleType] : '项目';
234
+ const meta = await storage.getRulesMeta();
235
+ const typeName = args.ruleType ? (meta[args.ruleType]?.label || args.ruleType) : '项目';
216
236
  return { content: [{ type: 'text', text: `暂无${typeName}规则` }] };
217
237
  }
218
238
  return { content: [{ type: 'text', text: rules }] };
219
239
  });
220
240
  // 3.7 保存项目规则
221
241
  server.tool('kg_save_rules', '保存单个类型的项目规则(独立文件存储)', {
222
- ruleType: z.enum(['userStyles', 'testRules', 'reviewRules', 'codeStyle', 'unitTests'])
223
- .describe('规则类型: userStyles=用户沟通规则, codeStyle=编码风格规则, reviewRules=代码审查规则, testRules=错误分析规则, unitTests=代码测试规则'),
242
+ ruleType: z.string()
243
+ .describe('规则类型(如 userStyles, codeStyle, reviewRules, testRules, unitTests, 或自定义类型)'),
224
244
  rules: z.array(z.string()).describe('规则数组')
225
245
  }, async (args) => {
226
246
  const decoded = decodeObjectStrings(args);
@@ -228,7 +248,87 @@ export function registerTools(server, projectId, _user) {
228
248
  if (!success) {
229
249
  return wrap('❌ 保存失败');
230
250
  }
231
- return wrap(`✅ ${RULE_TYPE_LABELS[decoded.ruleType]}已保存 (${decoded.rules.length} 条)`);
251
+ const meta = await storage.getRulesMeta();
252
+ const label = meta[decoded.ruleType]?.label || decoded.ruleType;
253
+ return wrap(`✅ ${label}已保存 (${decoded.rules.length} 条)`);
254
+ });
255
+ // 3.8 获取规则触发配置
256
+ server.tool('kg_get_rules_meta', '获取规则触发配置(所有类型的标签、关键词、触发数)。用于查看/编辑 hooks 触发条件', {}, async () => {
257
+ try {
258
+ const meta = await storage.getRulesMeta();
259
+ if (Object.keys(meta).length === 0) {
260
+ return wrap('暂无规则配置');
261
+ }
262
+ const lines = Object.entries(meta).map(([type, m]) => {
263
+ const kw = m.keywords.length > 0 ? m.keywords.join(', ') : '(无)';
264
+ const trigger = m.always ? '始终触发' : `关键词≥${m.min_hits}: ${kw}`;
265
+ return `- **${m.label}** (${type}): ${trigger}`;
266
+ });
267
+ return wrap(`规则触发配置:\n\n${lines.join('\n')}`);
268
+ }
269
+ catch (e) {
270
+ return wrap(`❌ ${String(e)}`);
271
+ }
272
+ });
273
+ // 3.9 保存规则触发配置
274
+ server.tool('kg_save_rules_meta', '保存规则触发配置(标签、关键词、触发数)。支持新增自定义规则类型', {
275
+ meta: z.record(z.string(), z.object({
276
+ label: z.string().describe('规则显示名称'),
277
+ keywords: z.array(z.string()).default([]).describe('触发关键词列表'),
278
+ min_hits: z.number().default(1).describe('最低触发关键词数'),
279
+ always: z.boolean().default(false).describe('是否始终触发(如 userStyles)')
280
+ })).describe('规则类型 → 触发配置的映射')
281
+ }, async (args) => {
282
+ try {
283
+ const decoded = decodeObjectStrings(args.meta);
284
+ const success = await storage.saveRulesMeta(decoded);
285
+ if (!success) {
286
+ return wrap('❌ 保存失败');
287
+ }
288
+ return wrap(`✅ 规则触发配置已保存 (${Object.keys(decoded).length} 个类型)`);
289
+ }
290
+ catch (e) {
291
+ return wrap(`❌ ${String(e)}`);
292
+ }
293
+ });
294
+ // 3.10 获取全局默认规则触发配置
295
+ server.tool('kg_get_global_rules_meta', '获取全局默认规则触发配置(新项目创建时继承此配置)', {}, async () => {
296
+ try {
297
+ const meta = await storage.getGlobalRulesMeta();
298
+ if (Object.keys(meta).length === 0) {
299
+ return wrap('暂无全局默认规则配置');
300
+ }
301
+ const lines = Object.entries(meta).map(([type, m]) => {
302
+ const kw = m.keywords.length > 0 ? m.keywords.join(', ') : '(无)';
303
+ const trigger = m.always ? '始终触发' : `关键词≥${m.min_hits}: ${kw}`;
304
+ return `- **${m.label}** (${type}): ${trigger}`;
305
+ });
306
+ return wrap(`全局默认规则触发配置:\n\n${lines.join('\n')}`);
307
+ }
308
+ catch (e) {
309
+ return wrap(`❌ ${String(e)}`);
310
+ }
311
+ });
312
+ // 3.11 保存全局默认规则触发配置
313
+ server.tool('kg_save_global_rules_meta', '保存全局默认规则触发配置(新项目创建时继承此配置)。支持新增自定义规则类型', {
314
+ meta: z.record(z.string(), z.object({
315
+ label: z.string().describe('规则显示名称'),
316
+ keywords: z.array(z.string()).default([]).describe('触发关键词列表'),
317
+ min_hits: z.number().default(1).describe('最低触发关键词数'),
318
+ always: z.boolean().default(false).describe('是否始终触发(如 userStyles)')
319
+ })).describe('规则类型 → 触发配置的映射')
320
+ }, async (args) => {
321
+ try {
322
+ const decoded = decodeObjectStrings(args.meta);
323
+ const success = await storage.saveGlobalRulesMeta(decoded);
324
+ if (!success) {
325
+ return wrap('❌ 保存失败');
326
+ }
327
+ return wrap(`✅ 全局默认规则触发配置已保存 (${Object.keys(decoded).length} 个类型)`);
328
+ }
329
+ catch (e) {
330
+ return wrap(`❌ ${String(e)}`);
331
+ }
232
332
  });
233
333
  // 5. 读取单个文档详情
234
334
  server.tool('kg_read_node', '读取文档完整内容(简介、正文、版本历史、修复记录)。支持跨项目只读访问', {
@@ -393,4 +493,85 @@ export function registerTools(server, projectId, _user) {
393
493
  }
394
494
  return wrap(`✅ 任务已归档: ${task.title}`);
395
495
  });
496
+ // ===================== 项目文件访问 =====================
497
+ // 上传本地目录到中心服务器
498
+ server.tool('project_upload', '上传本地项目目录到中心服务器(打包zip上传,自动排除node_modules/.git等)。上传后其他客户端可通过跨项目访问下载', {
499
+ localDir: z.string().describe('本地目录的绝对路径(如"/home/user/project/src")'),
500
+ remoteDir: z.string().optional().describe('上传到服务器的目标子目录(如"src"),不填则上传到根目录'),
501
+ }, async (args) => {
502
+ try {
503
+ const result = await storage.uploadFiles(args.localDir, args.remoteDir);
504
+ return wrap(`✅ 上传成功\n\n- 文件数量: ${result.fileCount}\n- 目标目录: ${args.remoteDir || '/'}\n\n其他客户端可通过 project_read_file / project_download_dir 访问`);
505
+ }
506
+ catch (e) {
507
+ return wrap(`❌ ${String(e)}`);
508
+ }
509
+ });
510
+ // 清空项目文件存储区
511
+ server.tool('project_clear_files', '清空当前项目在中心服务器上的文件存储区', {}, async () => {
512
+ try {
513
+ await storage.clearFiles();
514
+ return wrap('✅ 文件存储已清空');
515
+ }
516
+ catch (e) {
517
+ return wrap(`❌ ${String(e)}`);
518
+ }
519
+ });
520
+ // 列出项目文件
521
+ server.tool('project_list_files', '列出项目源码目录下的文件。需要先上传文件或项目已关联源码目录', {
522
+ dir: z.string().optional().describe('子目录路径(如"src/components"),不填则列出根目录'),
523
+ targetProject: z.string().optional().describe('目标项目ID或名称(不填=当前项目,跨项目只读)')
524
+ }, async (args) => {
525
+ try {
526
+ const files = args.targetProject
527
+ ? await storage.crossListFiles(args.targetProject, args.dir)
528
+ : await storage.listFiles(args.dir);
529
+ if (files.length === 0) {
530
+ return wrap('目录为空');
531
+ }
532
+ const lines = files.map(f => {
533
+ const icon = f.isDir ? '📁' : '📄';
534
+ const size = f.isDir ? '' : ` (${formatFileSize(f.size)})`;
535
+ return `${icon} ${f.name}${size}`;
536
+ });
537
+ const prefix = args.targetProject ? `[跨项目: ${args.targetProject}]\n\n` : '';
538
+ const dirLabel = args.dir || '/';
539
+ return wrap(`${prefix}📂 ${dirLabel} (${files.length} 项)\n\n${lines.join('\n')}`);
540
+ }
541
+ catch (e) {
542
+ return wrap(`❌ ${String(e)}`);
543
+ }
544
+ });
545
+ // 读取项目文件
546
+ server.tool('project_read_file', '读取项目源码文件内容(文本,限制1MB)。需要先上传文件或项目已关联源码目录', {
547
+ path: z.string().describe('文件路径(如"src/main.ts")'),
548
+ targetProject: z.string().optional().describe('目标项目ID或名称(不填=当前项目,跨项目只读)')
549
+ }, async (args) => {
550
+ try {
551
+ const content = args.targetProject
552
+ ? await storage.crossReadFile(args.targetProject, args.path)
553
+ : await storage.readFile(args.path);
554
+ const prefix = args.targetProject ? `[跨项目: ${args.targetProject}]\n\n` : '';
555
+ return wrap(`${prefix}📄 ${args.path}\n\n\`\`\`\n${content}\n\`\`\``);
556
+ }
557
+ catch (e) {
558
+ return wrap(`❌ ${String(e)}`);
559
+ }
560
+ });
561
+ // 下载项目目录 (zip → 自动解压)
562
+ server.tool('project_download_dir', '下载项目源码目录(自动打包zip并解压到本地临时目录)。需要先上传文件或项目已关联源码目录', {
563
+ dir: z.string().describe('目录路径(如"src")'),
564
+ targetProject: z.string().optional().describe('目标项目ID或名称(不填=当前项目,跨项目只读)')
565
+ }, async (args) => {
566
+ try {
567
+ const result = args.targetProject
568
+ ? await storage.crossDownloadDir(args.targetProject, args.dir)
569
+ : await storage.downloadDir(args.dir);
570
+ const prefix = args.targetProject ? `[跨项目: ${args.targetProject}]\n\n` : '';
571
+ return wrap(`${prefix}✅ 目录已下载并解压\n\n- 本地路径: ${result.localPath}\n- 文件数量: ${result.fileCount}\n\n可直接读取该路径下的文件`);
572
+ }
573
+ catch (e) {
574
+ return wrap(`❌ ${String(e)}`);
575
+ }
576
+ });
396
577
  }
package/dist/utils.d.ts CHANGED
@@ -1,12 +1,12 @@
1
1
  /**
2
2
  * MCP Server 工具函数
3
3
  */
4
- export type RuleType = 'userStyles' | 'testRules' | 'reviewRules' | 'codeStyle' | 'unitTests';
5
- export declare const RULE_TYPE_LABELS: Record<RuleType, string>;
4
+ export type RuleType = string;
5
+ export declare const RULE_TYPE_LABELS: Record<string, string>;
6
6
  /**
7
- * 获取指定类型的规则 (从独立文件读取)
7
+ * 获取指定类型的规则 (动态: 从 meta 获取标签)
8
8
  */
9
- export declare function getRules(projectId: string, ruleType?: RuleType): Promise<string>;
9
+ export declare function getRules(projectId: string, ruleType?: string): Promise<string>;
10
10
  /**
11
11
  * 解码 Unicode 转义序列
12
12
  * 将 \uXXXX 格式的转义序列转换为实际字符
package/dist/utils.js CHANGED
@@ -2,14 +2,16 @@
2
2
  * MCP Server 工具函数
3
3
  */
4
4
  import * as storage from './storage/httpClient.js';
5
- // 规则类型显示名
6
- export const RULE_TYPE_LABELS = {
5
+ // 默认规则类型标签 (fallback)
6
+ const DEFAULT_LABELS = {
7
7
  userStyles: '用户沟通规则',
8
8
  codeStyle: '编码风格规则',
9
9
  reviewRules: '代码审查规则',
10
10
  testRules: '错误分析规则',
11
11
  unitTests: '代码测试规则',
12
12
  };
13
+ // 兼容导出
14
+ export const RULE_TYPE_LABELS = DEFAULT_LABELS;
13
15
  /**
14
16
  * 将字符串数组格式化为 Markdown 列表
15
17
  */
@@ -19,22 +21,25 @@ function formatRulesList(rules) {
19
21
  return rules.map((s, i) => `${i + 1}. ${s}`).join('\n\n');
20
22
  }
21
23
  /**
22
- * 获取指定类型的规则 (从独立文件读取)
24
+ * 获取指定类型的规则 (动态: 从 meta 获取标签)
23
25
  */
24
26
  export async function getRules(projectId, ruleType) {
25
- // 如果指定了类型,只返回该类型
27
+ const meta = await storage.getRulesMeta();
26
28
  if (ruleType) {
27
29
  const rules = await storage.getRules(projectId, ruleType);
28
30
  if (!rules || rules.length === 0)
29
31
  return '';
30
- return `[${RULE_TYPE_LABELS[ruleType]}]\n${formatRulesList(rules)}`;
32
+ const label = meta[ruleType]?.label || DEFAULT_LABELS[ruleType] || ruleType;
33
+ return `[${label}]\n${formatRulesList(rules)}`;
31
34
  }
32
- // 返回所有规则
35
+ // 返回所有规则 (从 meta 获取所有类型)
36
+ const types = Object.keys(meta).length > 0 ? Object.keys(meta) : Object.keys(DEFAULT_LABELS);
33
37
  const allRules = [];
34
- for (const type of Object.keys(RULE_TYPE_LABELS)) {
38
+ for (const type of types) {
35
39
  const rules = await storage.getRules(projectId, type);
36
40
  if (rules && rules.length > 0) {
37
- allRules.push(`[${RULE_TYPE_LABELS[type]}]\n${formatRulesList(rules)}`);
41
+ const label = meta[type]?.label || DEFAULT_LABELS[type] || type;
42
+ allRules.push(`[${label}]\n${formatRulesList(rules)}`);
38
43
  }
39
44
  }
40
45
  return allRules.join('\n\n---\n\n');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppdocs/mcp",
3
- "version": "2.9.0",
3
+ "version": "3.0.1",
4
4
  "description": "ppdocs MCP Server - Knowledge Graph for Claude",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -30,12 +30,12 @@
30
30
  ],
31
31
  "dependencies": {
32
32
  "@modelcontextprotocol/sdk": "^1.0.0",
33
- "proper-lockfile": "^4.1.2",
33
+ "adm-zip": "^0.5.16",
34
34
  "zod": "^4.1.13"
35
35
  },
36
36
  "devDependencies": {
37
+ "@types/adm-zip": "^0.5.7",
37
38
  "@types/node": "^22.0.0",
38
- "@types/proper-lockfile": "^4.1.4",
39
39
  "typescript": "^5.7.0"
40
40
  }
41
41
  }
@@ -1,71 +1,46 @@
1
1
  #!/usr/bin/env python
2
2
  # -*- coding: utf-8 -*-
3
3
  """
4
- Claude Code Hook - 关键词触发 + API 动态获取规则
4
+ Claude Code Hook - 动态规则触发
5
+ 从服务器获取触发配置 (rules-meta) → 关键词匹配 → 叠加获取规则
6
+ 兼容 Python 2.7+ / 3.x,支持 Windows / macOS / Linux
5
7
  """
6
8
 
9
+ from __future__ import print_function, unicode_literals
10
+
7
11
  import io
8
12
  import json
9
13
  import os
10
14
  import sys
11
- import urllib.request
12
15
 
13
- # Windows 编码修复
14
- sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
15
- sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
16
- sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
16
+ # HTTP 库兼容
17
+ try:
18
+ from urllib.request import Request, urlopen
19
+ except ImportError:
20
+ from urllib2 import Request, urlopen
21
+
22
+ # Windows 编码修复 (仅 Windows + Python 3 + 有 buffer 属性时)
23
+ if sys.platform == "win32" and hasattr(sys.stdin, "buffer"):
24
+ try:
25
+ sys.stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
26
+ sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
27
+ sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
28
+ except Exception:
29
+ pass
17
30
 
18
31
 
19
32
  # ╔══════════════════════════════════════════════════════════════╗
20
- # ║ 配置区
33
+ # ║ 绕过词
21
34
  # ╚══════════════════════════════════════════════════════════════╝
22
35
 
23
- # 关键词规则 (触发关键词 → 请求对应规则类型)
24
- RULES = [
25
- {
26
- "keywords": ["bug","修复","错误","报错","失败","异常","严重","存在","还是","有"],
27
- "min_hits": 2,
28
- "rule_type": "testRules", # 错误分析规则
29
- "label": "错误分析规则",
30
- },
31
- {
32
- "keywords": ["审查", "审核", "review", "检查"],
33
- "min_hits": 1,
34
- "rule_type": "reviewRules", # 审查规则
35
- "label": "代码审查规则",
36
- },
37
- {
38
- "keywords": ["编码", "风格", "格式", "命名", "编写", "开始", "代码"],
39
- "min_hits": 2,
40
- "rule_type": "codeStyle", # 编码风格
41
- "label": "编码风格规则",
42
- },
43
- {
44
- "keywords": ["开始", "进行", "准备", "测试", "单元", "用例", "test", "覆盖率"],
45
- "min_hits": 2,
46
- "rule_type": "unitTests", # 代码测试
47
- "label": "代码测试规则",
48
- },
49
- ]
50
-
51
- # 绕过词列表
52
36
  BYPASS = [
53
- "补充",
54
- "确定",
55
- "确认",
56
- "继续",
57
- "好的",
58
- "可以",
59
- "ok",
60
- "yes",
61
- "hi",
62
- "hello",
63
- "你好",
37
+ "补充", "确定", "确认", "继续", "好的", "可以",
38
+ "ok", "yes", "hi", "hello", "你好",
64
39
  ]
65
40
 
66
41
 
67
42
  # ╔══════════════════════════════════════════════════════════════╗
68
- # ║ API 请求函数
43
+ # ║ API 请求
69
44
  # ╚══════════════════════════════════════════════════════════════╝
70
45
 
71
46
 
@@ -75,25 +50,42 @@ def load_ppdocs_config():
75
50
  if not os.path.exists(config_path):
76
51
  return None
77
52
  try:
78
- with open(config_path, "r", encoding="utf-8") as f:
53
+ with io.open(config_path, "r", encoding="utf-8") as f:
79
54
  return json.load(f)
80
- except:
55
+ except Exception:
81
56
  return None
82
57
 
83
58
 
84
- def fetch_rules(api_base: str, project_id: str, key: str, rule_type: str) -> list:
85
- """通过 HTTP API 获取指定类型的规则"""
86
- url = f"{api_base}/api/{project_id}/{key}/rules/{rule_type}"
59
+ def api_get(api_base, project_id, key, path):
60
+ """通用 GET 请求"""
61
+ url = "%s/api/%s/%s%s" % (api_base, project_id, key, path)
62
+ resp = None
87
63
  try:
88
- req = urllib.request.Request(url, method="GET")
64
+ req = Request(url)
89
65
  req.add_header("Content-Type", "application/json")
90
- with urllib.request.urlopen(req, timeout=3) as resp:
91
- data = json.loads(resp.read().decode("utf-8"))
92
- if data.get("success") and data.get("data"):
93
- return data["data"]
94
- except:
66
+ resp = urlopen(req, timeout=3)
67
+ data = json.loads(resp.read().decode("utf-8"))
68
+ if data.get("success") and data.get("data") is not None:
69
+ return data["data"]
70
+ except Exception:
95
71
  pass
96
- return []
72
+ finally:
73
+ if resp is not None:
74
+ try:
75
+ resp.close()
76
+ except Exception:
77
+ pass
78
+ return None
79
+
80
+
81
+ def fetch_rules_meta(api_base, project_id, key):
82
+ """获取规则触发配置"""
83
+ return api_get(api_base, project_id, key, "/rules-meta") or {}
84
+
85
+
86
+ def fetch_rules(api_base, project_id, key, rule_type):
87
+ """获取指定类型的规则内容"""
88
+ return api_get(api_base, project_id, key, "/rules/" + rule_type) or []
97
89
 
98
90
 
99
91
  # ╔══════════════════════════════════════════════════════════════╗
@@ -101,27 +93,32 @@ def fetch_rules(api_base: str, project_id: str, key: str, rule_type: str) -> lis
101
93
  # ╚══════════════════════════════════════════════════════════════╝
102
94
 
103
95
 
104
- def count_hits(text: str, keywords: list) -> int:
96
+ def count_hits(text, keywords):
105
97
  """计算关键词命中数量"""
106
98
  return sum(1 for kw in keywords if kw.lower() in text)
107
99
 
108
100
 
109
- def match_rule(text: str):
110
- """匹配规则,返回 (rule, matched)"""
111
- for rule in RULES:
112
- hits = count_hits(text, rule["keywords"])
113
- if hits >= rule.get("min_hits", 1):
114
- return rule, True
115
- return None, False
101
+ def match_all(text, meta):
102
+ """匹配所有触发的规则类型,返回 [(rule_type, label), ...]"""
103
+ matched = []
104
+ for rule_type, config in meta.items():
105
+ if config.get("always"):
106
+ matched.append((rule_type, config.get("label", rule_type)))
107
+ continue
108
+ keywords = config.get("keywords", [])
109
+ min_hits = config.get("min_hits", 1)
110
+ if keywords and count_hits(text, keywords) >= min_hits:
111
+ matched.append((rule_type, config.get("label", rule_type)))
112
+ return matched
116
113
 
117
114
 
118
- def format_rules(items: list, label: str) -> str:
115
+ def format_rules(items, label):
119
116
  """格式化规则输出"""
120
117
  if not items:
121
118
  return ""
122
- lines = [f"# {label}\n"]
119
+ lines = ["# %s\n" % label]
123
120
  for item in items:
124
- lines.append(f"- {item}")
121
+ lines.append("- %s" % item)
125
122
  return "\n".join(lines)
126
123
 
127
124
 
@@ -132,8 +129,9 @@ def format_rules(items: list, label: str) -> str:
132
129
 
133
130
  def main():
134
131
  try:
135
- data = json.load(sys.stdin)
136
- except:
132
+ raw = sys.stdin.read()
133
+ data = json.loads(raw)
134
+ except Exception:
137
135
  return
138
136
 
139
137
  if data.get("hook_event_name") != "UserPromptSubmit":
@@ -163,19 +161,22 @@ def main():
163
161
  if not project_id or not key:
164
162
  return
165
163
 
166
- output_parts = []
164
+ # 从服务器获取触发配置
165
+ meta = fetch_rules_meta(api_base, project_id, key)
166
+ if not meta:
167
+ return
167
168
 
168
- # 1. 强制获取 userStyles (用户沟通规则)
169
- user_styles = fetch_rules(api_base, project_id, key, "userStyles")
170
- if user_styles:
171
- output_parts.append(format_rules(user_styles, "用户沟通规则"))
172
-
173
- # 2. 根据关键词匹配额外规则
174
- rule, matched = match_rule(user_input_lower)
175
- if matched and rule["rule_type"] != "userStyles":
176
- extra_rules = fetch_rules(api_base, project_id, key, rule["rule_type"])
177
- if extra_rules:
178
- output_parts.append(format_rules(extra_rules, rule["label"]))
169
+ # 匹配所有触发的规则
170
+ matched = match_all(user_input_lower, meta)
171
+ if not matched:
172
+ return
173
+
174
+ # 叠加获取所有命中的规则内容
175
+ output_parts = []
176
+ for rule_type, label in matched:
177
+ rules = fetch_rules(api_base, project_id, key, rule_type)
178
+ if rules:
179
+ output_parts.append(format_rules(rules, label))
179
180
 
180
181
  # 输出
181
182
  if output_parts: