diff --git a/commands/disable.md b/commands/disable.md index 9e0919b..1f9d897 100644 --- a/commands/disable.md +++ b/commands/disable.md @@ -14,7 +14,7 @@ description: Disable a status bar module Disable a status bar module. Changes take effect immediately on the next status bar render. -Available module IDs: `model`, `context`, `git`, `accountUsage`, `cost`, `duration`, `workspace`, `turns`, `cacheRatio`, `rateLimits`, `weeklyQuota`, `mcpStatus`, `thinking`, `outputStyle`, `thirdPartyApi`, `toolTimeline`. +Available module IDs: `model`, `context`, `git`, `accountUsage`, `cost`, `duration`, `workspace`, `turns`, `cacheRatio`, `rateLimits`, `weeklyQuota`, `mcpStatus`, `thinking`, `outputStyle`, `thirdPartyApi`, `toolTimeline`, `rules`. ## Instructions diff --git a/commands/enable.md b/commands/enable.md index 54b2038..4ac254f 100644 --- a/commands/enable.md +++ b/commands/enable.md @@ -14,7 +14,7 @@ description: Enable a status bar module Enable a status bar module. Changes take effect immediately on the next status bar render. -Available module IDs: `model`, `context`, `git`, `accountUsage`, `cost`, `duration`, `workspace`, `turns`, `cacheRatio`, `rateLimits`, `weeklyQuota`, `mcpStatus`, `thinking`, `outputStyle`, `thirdPartyApi`, `toolTimeline`. +Available module IDs: `model`, `context`, `git`, `accountUsage`, `cost`, `duration`, `workspace`, `turns`, `cacheRatio`, `rateLimits`, `weeklyQuota`, `mcpStatus`, `thinking`, `outputStyle`, `thirdPartyApi`, `toolTimeline`, `rules`. ## Instructions diff --git a/commands/rules.md b/commands/rules.md new file mode 100644 index 0000000..1cbc99f --- /dev/null +++ b/commands/rules.md @@ -0,0 +1,32 @@ +--- +description: Show project rules/config file summary and list +--- + +# Rules + +## Command + +``` +/pulse-line:rules +``` + +## Description + +Show a summary of project rules and config files detected by Pulse Line. This includes CLAUDE.md files, `.claude/` directory contents, and `skills/` directory contents. + +## Instructions + +Run the following shell command: + +```bash +npx -y pulse-line@latest rules +``` + +Useful variants: + +```bash +npx -y pulse-line@latest rules list +npx -y pulse-line@latest rules pattern add docs/ +npx -y pulse-line@latest rules pattern remove docs/ +npx -y pulse-line@latest rules pattern add-exclude vendor/ +``` diff --git a/docs/superpowers/plans/2026-05-16-rules-module-implementation.md b/docs/superpowers/plans/2026-05-16-rules-module-implementation.md new file mode 100644 index 0000000..3739edf --- /dev/null +++ b/docs/superpowers/plans/2026-05-16-rules-module-implementation.md @@ -0,0 +1,772 @@ +# 规则模块 — 详细实施方案 + +## 概述 + +为 pulse-line 状态栏新增"规则"模块,统计项目中的 CLAUDE.md 文件、`.claude/` 目录下的配置文件以及 `skills/` 目录下的技能文件数量。同时提供 CLI 命令用于查看详细文件列表和管理自定义扫描模式。 + +**状态栏显示效果:** + +``` +[规则] 15 规则:12 Skill:3 +``` + +- `[规则] 15` — 图标 + 总数,使用主题 info 色 +- `规则:12 Skill:3` — 分类明细,使用主题 muted 色(暗色备注样式) +- 某分类为 0 时省略该分类;总数为 0 时不显示明细 + +--- + +## 变更文件总览 + +| 文件 | 操作 | 复杂度 | +|---|---|---| +| `src/types/pulse-config.ts` | 修改 | 低 | +| `src/types/theme.ts` | 修改 | 低 | +| `src/extractors/rules.ts` | **新建** | 中 | +| `src/extractors/index.ts` | 修改 | 低 | +| `src/index.ts` | 修改 | 低 | +| `src/i18n/locales/zh.ts` | 修改 | 低 | +| `src/i18n/locales/en.ts` | 修改 | 低 | +| `src/cli.ts` | 修改 | 中 | +| `src/config/migrate-config.ts` | 修改 | 低 | +| `src/config/loader.ts` | 修改 | 低 | +| `src/utils/display-sanitize.ts` | 修改 | 低 | +| `src/themes/index.ts` | 修改 | 低 | +| `src/themes/icon-sets/nerd.ts` | 修改 | 低 | +| `src/themes/icon-sets/text.ts` | 修改 | 低 | +| `src/themes/builtin/dark.ts` | 修改 | 低 | +| `src/themes/builtin/light.ts` | 修改 | 低 | +| `src/themes/builtin/cyberpunk.ts` | 修改 | 低 | +| `src/themes/builtin/forest.ts` | 修改 | 低 | +| `src/themes/builtin/ocean.ts` | 修改 | 低 | +| `commands/rules.md` | **新建** | 低 | +| `commands/enable.md` | 修改 | 低 | +| `commands/disable.md` | 修改 | 低 | +| `test/extractors.test.ts` | 修改 | 中 | +| `README.md` | 修改 | 低 | +| `README_EN.md` | 修改 | 低 | + +**合计:2 个新建文件,24 个修改文件** + +--- + +## 步骤 1:类型系统 + +### 1.1 `src/types/pulse-config.ts` + +**添加 `RulesModuleConfig` 接口**(在 `ToolTimelineModuleConfig` 之后): + +```typescript +export interface RulesModuleConfig extends ModuleConfig { + includePatterns?: string[]; + excludePatterns?: string[]; +} +``` + +**在 `PulseConfig.modules` 中添加 `rules` 字段**: + +```typescript +modules: { + // ... 现有模块 ... + rules: RulesModuleConfig; +}; +``` + +**更新 `DEFAULT_CONFIG`**: + +- `schemaVersion` 从 `6` 改为 `7` +- 在 `modules` 中插入(放在 `toolTimeline` 之前,order=15): + +```typescript +rules: { + enabled: true, + order: 15, + icon: '[规则]', + includePatterns: [], + excludePatterns: [] +}, +``` + +### 1.2 `src/types/theme.ts` + +**在 `Theme.components` 中添加 `rules`**(在 `toolTimeline` 之后): + +```typescript +rules: ComponentStyle; +``` + +### 1.3 `src/themes/icon-sets/nerd.ts` 和 `src/themes/icon-sets/text.ts` + +两个文件都定义了 `IconSet` 接口和具体的图标对象。**都需要修改:** + +**`IconSet` 接口添加:** + +```typescript +rules: string; +``` + +**`nerdIconSet` 对象添加:** + +```typescript +rules: "", // Nerd Font 中暂无合适的规则图标,保持空串,回退到配置中的文本图标 +``` + +**`textIconSet` 对象添加:** + +```typescript +rules: '[R]' +``` + +### 1.4 `src/themes/index.ts` + +**`overlayNerdIcons` 函数**中添加 `rules` 映射(在 `toolTimeline` 之后): + +```typescript +theme.components.rules.icon = n.rules; +``` + +--- + +## 步骤 2:核心提取器 + +### 2.1 `src/extractors/rules.ts`(新建) + +```typescript +import * as fs from 'fs'; +import * as path from 'path'; +import { loadSessionCache, saveSessionCache } from '../utils/cache'; + +export interface RulesFileEntry { + relativePath: string; + category: 'rule' | 'skill'; +} + +export interface RulesSegment { + total: number; + rulesCount: number; + skillsCount: number; + files: RulesFileEntry[]; +} + +/** 默认跳过的目录名 */ +const DEFAULT_EXCLUDES = new Set([ + 'node_modules', '.git', 'dist', '.worktrees', + '.next', '.nuxt', 'coverage', '__pycache__', '.turbo' +]); + +/** 搜索 CLAUDE.md 时的最大递归深度 */ +const MAX_DEPTH = 10; + +const CACHE_TTL = 60_000; // 60 秒 + +/** 构建缓存键——包含 cwd 和 pattern 参数以避免过期 */ +function buildCacheKey(cwd: string, includes: string[], excludes: string[]): string { + const payload = `${cwd}::${includes.join(',')}::${excludes.join(',')}`; + return `rules-scan:${payload}`; +} + +function shouldExcludeDir(name: string, excludePatterns: string[]): boolean { + if (DEFAULT_EXCLUDES.has(name)) return true; + for (const pat of excludePatterns) { + if (globMatch(name, pat)) return true; + } + return false; +} + +/** 极简 glob 匹配:仅支持 * 和 ? 通配符 */ +function globMatch(value: string, pattern: string): boolean { + const re = '^' + pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') // 转义正则特殊字符 + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + '$'; + return new RegExp(re).test(value); +} + +/** 递归遍历目录,收集所有文件 */ +function walkDir( + dir: string, + cwd: string, + excludePatterns: string[], + category: 'rule' | 'skill' +): RulesFileEntry[] { + const results: RulesFileEntry[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (shouldExcludeDir(entry.name, excludePatterns)) continue; + results.push(...walkDir(path.join(dir, entry.name), cwd, excludePatterns, category)); + } else if (entry.isFile()) { + results.push({ + relativePath: path.relative(cwd, path.join(dir, entry.name)), + category + }); + } + } + } catch { + // 目录不存在或无权限——跳过 + } + return results; +} + +/** 递归搜索所有 CLAUDE.md 文件 */ +function findClaudeMdFiles( + dir: string, + cwd: string, + excludePatterns: string[], + depth: number +): RulesFileEntry[] { + if (depth > MAX_DEPTH) return []; + const results: RulesFileEntry[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (shouldExcludeDir(entry.name, excludePatterns)) continue; + results.push(...findClaudeMdFiles( + path.join(dir, entry.name), cwd, excludePatterns, depth + 1 + )); + } else if (entry.name === 'CLAUDE.md') { + results.push({ + relativePath: path.relative(cwd, path.join(dir, entry.name)), + category: 'rule' + }); + } + } + } catch { + // 跳过 + } + return results; +} + +/** 处理用户自定义的 includePatterns */ +function scanIncludePatterns( + cwd: string, + patterns: string[], + excludePatterns: string[], + existing: RulesFileEntry[] +): RulesFileEntry[] { + const results: RulesFileEntry[] = []; + const existingPaths = new Set(existing.map(f => f.relativePath)); + + for (const pattern of patterns) { + // 尝试解析为字面路径 + const resolved = path.resolve(cwd, pattern); + try { + const stat = fs.statSync(resolved); + if (stat.isFile()) { + const rel = path.relative(cwd, resolved); + if (!existingPaths.has(rel)) { + results.push({ relativePath: rel, category: 'rule' }); + } + } else if (stat.isDirectory()) { + const entries = walkDir(resolved, cwd, excludePatterns, 'rule'); + for (const e of entries) { + if (!existingPaths.has(e.relativePath)) { + results.push(e); + } + } + } + } catch { + // 路径不存在——跳过(支持 glob 模式需后续迭代) + } + } + return results; +} + +/** 去重 */ +function dedup(files: RulesFileEntry[]): RulesFileEntry[] { + const seen = new Set(); + const result: RulesFileEntry[] = []; + for (const f of files) { + if (!seen.has(f.relativePath)) { + seen.add(f.relativePath); + result.push(f); + } + } + return result; +} + +export function extractRules( + cwd: string, + includePatterns: string[] = [], + excludePatterns: string[] = [] +): RulesSegment { + // 检查缓存(包含 pattern 参数以避免配置变更后缓存过期) + const cacheKey = buildCacheKey(cwd, includePatterns, excludePatterns); + const cached = loadSessionCache('global', cacheKey); + if (cached) return cached; + + const allFiles: RulesFileEntry[] = []; + + // 1. 扫描 CLAUDE.md 文件(递归,有深度限制) + allFiles.push(...findClaudeMdFiles(cwd, cwd, excludePatterns, 0)); + + // 2. 扫描 .claude/ 目录 + const claudeDir = path.join(cwd, '.claude'); + if (fs.existsSync(claudeDir)) { + allFiles.push(...walkDir(claudeDir, cwd, excludePatterns, 'rule')); + } + + // 3. 扫描 skills/ 目录 + const skillsDir = path.join(cwd, 'skills'); + if (fs.existsSync(skillsDir)) { + allFiles.push(...walkDir(skillsDir, cwd, excludePatterns, 'skill')); + } + + // 4. 处理用户自定义 includePatterns + const extras = scanIncludePatterns(cwd, includePatterns, excludePatterns, allFiles); + allFiles.push(...extras); + + // 去重 + const files = dedup(allFiles); + + const segment: RulesSegment = { + total: files.length, + rulesCount: files.filter(f => f.category === 'rule').length, + skillsCount: files.filter(f => f.category === 'skill').length, + files + }; + + // 缓存结果 + saveSessionCache('global', cacheKey, segment, CACHE_TTL); + return segment; +} +``` + +**设计要点:** + +- **零外部依赖**:glob 匹配使用极简正则实现,不引入 minimatch/picomatch +- **深度限制**:CLAUDE.md 搜索限制 10 层,防止大项目卡顿 +- **缓存键包含参数**:`cwd + includePatterns + excludePatterns` 组合为键,配置变更自动失效 +- **默认排除列表**:涵盖常见的非项目文件目录(node_modules, .git, dist, .worktrees 等) + +--- + +## 步骤 3:接入渲染管线 + +### 3.1 `src/extractors/index.ts` + +添加导出: + +```typescript +export { extractRules } from './rules'; +export type { RulesSegment, RulesFileEntry } from './rules'; +``` + +### 3.2 `src/index.ts` + +**添加导入**(在文件顶部的导入区域): + +```typescript +import { extractRules } from './extractors'; +import { getLabel } from './i18n'; +``` + +**在模块渲染区域添加规则模块**(位置无关,最终按 `segment.order` 排序): + +```typescript +// Rules +if (modules.rules.enabled) { + const rules = extractRules( + input.cwd, + modules.rules.includePatterns ?? [], + modules.rules.excludePatterns ?? [] + ); + const icon = modules.rules.icon ?? theme.components.rules.icon ?? ''; + const label = rules.total.toString(); + const line = icon.length > 0 ? `${icon} ${label}` : label; + + const parts: string[] = []; + if (rules.rulesCount > 0) { + parts.push(`${getLabel(config.language, 'rulesFiles')}:${rules.rulesCount}`); + } + if (rules.skillsCount > 0) { + parts.push(`${getLabel(config.language, 'rulesSkills')}:${rules.skillsCount}`); + } + const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : ''; + + segments.push({ + order: modules.rules.order, + text: colorize(theme.colors.info, line) + + (suffix ? colorize(theme.colors.muted, suffix) : '') + }); +} +``` + +--- + +## 步骤 4:国际化标签 + +### 4.1 `src/i18n/locales/zh.ts` + +在 `zhLabels` 对象末尾添加: + +```typescript +rules: '规则', +rulesFiles: '规则', +rulesSkills: 'Skill', +rulesTitle: '规则文件', +rulesCategory: '规则文件', +skillsCategory: 'Skills', +rulesTotal: '总计', +``` + +### 4.2 `src/i18n/locales/en.ts` + +在 `enLabels` 对象末尾添加: + +```typescript +rules: 'Rules', +rulesFiles: 'Rules', +rulesSkills: 'Skill', +rulesTitle: 'Rules Files', +rulesCategory: 'Rules', +skillsCategory: 'Skills', +rulesTotal: 'Total', +``` + +--- + +## 步骤 5:CLI 命令 + +### 5.1 `src/cli.ts` + +**添加导入**(文件顶部): + +```typescript +import { extractRules } from './extractors/rules'; +``` + +**在 `timeline` 命令组之后、`program.command('language')` 之前,添加 `rules` 命令组:** + +```typescript +// ── Rules 命令组 ────────────────────────────────── +const rulesCmd = program + .command('rules') + .description('Show project rules/config file count and list'); + +rulesCmd.action(() => { + const cwd = process.cwd(); + const config = loadConfig(); + const mod = config.modules.rules; + const result = extractRules(cwd, mod.includePatterns ?? [], mod.excludePatterns ?? []); + const labels = getLabels(config.language); + + console.log(`${labels.rulesTitle || 'Rules Files'} Summary`); + console.log(` ${labels.rulesCategory || 'Rules'}: ${result.rulesCount}`); + console.log(` ${labels.skillsCategory || 'Skills'}: ${result.skillsCount}`); + console.log(` ${labels.rulesTotal || 'Total'}: ${result.total}`); +}); + +rulesCmd + .command('list') + .description('List all detected rules/config files') + .action(() => { + const cwd = process.cwd(); + const config = loadConfig(); + const mod = config.modules.rules; + const result = extractRules(cwd, mod.includePatterns ?? [], mod.excludePatterns ?? []); + + if (result.total === 0) { + console.log('No rules/config files found in current project.'); + return; + } + + const labels = getLabels(config.language); + + if (result.rulesCount > 0) { + console.log(`=== ${labels.rulesCategory || 'Rules'} (${result.rulesCount}) ===`); + for (const f of result.files.filter(f => f.category === 'rule')) { + console.log(` ${f.relativePath}`); + } + console.log(''); + } + + if (result.skillsCount > 0) { + console.log(`=== ${labels.skillsCategory || 'Skills'} (${result.skillsCount}) ===`); + for (const f of result.files.filter(f => f.category === 'skill')) { + console.log(` ${f.relativePath}`); + } + console.log(''); + } + + console.log(`${labels.rulesTotal || 'Total'}: ${result.total}`); + }); + +rulesCmd + .command('pattern') + .description('Manage custom file patterns (include/exlose)') + .argument('', 'add | remove | add-exclude | remove-exclude') + .argument('', 'File or directory pattern') + .action((action: string, pattern: string) => { + const validActions = ['add', 'remove', 'add-exclude', 'remove-exclude']; + if (!validActions.includes(action)) { + console.error(`[ERROR] Action must be one of: ${validActions.join(', ')}`); + process.exit(1); + } + + const config = loadConfig(); + const mod = config.modules.rules; + + if (action === 'add' || action === 'remove') { + const patterns = mod.includePatterns ?? []; + if (action === 'add') { + if (patterns.includes(pattern)) { + console.log(`Pattern already exists: ${pattern}`); + return; + } + mod.includePatterns = [...patterns, pattern]; + saveAndInvalidate(config); + console.log(`[OK] Include pattern added: ${pattern}`); + } else { + if (!patterns.includes(pattern)) { + console.log(`Pattern not found: ${pattern}`); + return; + } + mod.includePatterns = patterns.filter(p => p !== pattern); + saveAndInvalidate(config); + console.log(`[OK] Include pattern removed: ${pattern}`); + } + } else { + // add-exclude / remove-exclude + const patterns = mod.excludePatterns ?? []; + if (action === 'add-exclude') { + if (patterns.includes(pattern)) { + console.log(`Exclude pattern already exists: ${pattern}`); + return; + } + mod.excludePatterns = [...patterns, pattern]; + saveAndInvalidate(config); + console.log(`[OK] Exclude pattern added: ${pattern}`); + } else { + if (!patterns.includes(pattern)) { + console.log(`Exclude pattern not found: ${pattern}`); + return; + } + mod.excludePatterns = patterns.filter(p => p !== pattern); + saveAndInvalidate(config); + console.log(`[OK] Exclude pattern removed: ${pattern}`); + } + } + }); +``` + +**更新 `language` 命令**中的 `moduleKeyMap`(添加 `rules` 条目): + +```typescript +const moduleKeyMap: Record = { + // ... 现有条目 ... + toolTimeline: 'toolTimeline', + rules: 'rules' // 新增 +}; +``` + +**更新 `CONFIG_CACHE_KEY`**(在 `saveAndInvalidate` 函数附近): + +```typescript +const CONFIG_CACHE_KEY = 'pulse-config-v7'; +``` + +--- + +## 步骤 6:配置迁移(schema v6 → v7) + +### 6.1 `src/config/migrate-config.ts` + +- `CURRENT_SCHEMA` 从 `6` 改为 `7` +- 添加 v7 迁移块(在 `if (v < 6)` 块之后): + +```typescript +if (v < 7) { + if (!(config.modules as any).rules) { + (config.modules as any).rules = JSON.parse( + JSON.stringify(DEFAULT_CONFIG.modules.rules) + ); + } +} +``` + +### 6.2 `src/config/loader.ts` + +- 缓存键从 `'pulse-config-v6'` 改为 `'pulse-config-v7'` + +--- + +## 步骤 7:主题组件 + +### 7.1 所有 5 个内置主题 + +每个主题的 `components` 对象中,在 `toolTimeline` 之后添加 `rules` 组件。 + +**dark.ts:** +```typescript +rules: { fg: '#7dcfff', icon: '[R]', showIcon: true }, +``` + +**light.ts:** +```typescript +rules: { fg: '#0891b2', icon: '[R]', showIcon: true }, +``` + +**cyberpunk.ts:** +```typescript +rules: { fg: '#8be9fd', icon: '[R]', showIcon: true }, +``` + +**forest.ts:** +```typescript +rules: { fg: '#95d5b2', icon: '[R]', showIcon: true }, +``` + +**ocean.ts:** +```typescript +rules: { fg: '#67e8f9', icon: '[R]', showIcon: true }, +``` + +颜色选取逻辑:每个主题中 `rules` 复用该主题的 `info`/`git` 色系(与同类信息型模块一致)。 + +--- + +## 步骤 8:显示清理 + +### 8.1 `src/utils/display-sanitize.ts` + +在 `MODULE_KEYS` 数组末尾添加 `'rules'`: + +```typescript +const MODULE_KEYS = [ + 'model', 'context', 'git', 'cost', 'duration', 'workspace', + 'turns', 'cacheRatio', 'rateLimits', 'weeklyQuota', 'mcpStatus', + 'thinking', 'outputStyle', 'thirdPartyApi', 'accountUsage', + 'toolTimeline', 'rules' +] as const; +``` + +--- + +## 步骤 9:命令文档 + +### 9.1 `commands/rules.md`(新建) + +```markdown +--- +description: Show project rules/config file summary and list +--- + +# Rules + +## Command + +``` +/pulse-line:rules +``` + +## Description + +Show a summary of project rules and config files detected by Pulse Line. This +includes CLAUDE.md files, `.claude/` directory contents, and `skills/` directory +contents. + +## Instructions + +Run the following shell command: + +```bash +npx -y pulse-line@latest rules +``` + +Useful variants: + +```bash +npx -y pulse-line@latest rules list +npx -y pulse-line@latest rules pattern add docs/ +npx -y pulse-line@latest rules pattern remove docs/ +npx -y pulse-line@latest rules pattern add-exclude vendor/ +``` +``` + +### 9.2 `commands/enable.md` 和 `commands/disable.md` + +在可用模块列表中添加 `rules`: + +``` +Available module IDs: `model`, `context`, `git`, `accountUsage`, `cost`, `duration`, `workspace`, `turns`, `cacheRatio`, `rateLimits`, `weeklyQuota`, `mcpStatus`, `thinking`, `outputStyle`, `thirdPartyApi`, `toolTimeline`, `rules`. +``` + +--- + +## 步骤 10:测试 + +### 10.1 `test/extractors.test.ts` + +新增 `extractRules` 测试用例: + +1. **空目录** — 无 CLAUDE.md、无 .claude/、无 skills/ → total=0, rulesCount=0, skillsCount=0 +2. **根目录 CLAUDE.md** — rulesCount=1, total=1 +3. **嵌套 CLAUDE.md**(如 `packages/lib/CLAUDE.md`) → rulesCount=2 +4. **`.claude/settings.json` + `.claude/agents/reviewer.md`** → rulesCount=3(含根目录 CLAUDE.md) +5. **`skills/my-skill/SKILL.md`** → skillsCount=1 +6. **去重** — 不会重复计数 +7. **includePatterns** — 添加额外目录后被统计 +8. **excludePatterns** — 匹配的目录被排除 +9. **缓存** — 第二次调用返回缓存结果(相同参数) +10. **缓存失效** — 修改 patterns 后缓存未命中 + +测试使用临时目录(`os.tmpdir()` + `fs.mkdirSync`),每个用例结束后清理。 + +--- + +## 步骤 11:文档更新 + +### 11.1 `README.md` 和 `README_EN.md` + +在模块参考表中添加 `rules` 行: + +中文 README: + +``` +| rules | 规则 | [规则] | 15 | 统计项目中的规则/配置文件数量 | +``` + +英文 README: + +``` +| rules | Rules | [规则] | 15 | Count project rules and config files | +``` + +--- + +## 实施顺序 + +``` +步骤 1 类型系统 ← 基础,所有后续步骤依赖 +步骤 2 核心提取器 ← 核心逻辑 +步骤 3 接入渲染管线 ← 状态栏开始渲染 +步骤 4 国际化标签 ← 显示正确的中文/英文 +步骤 5 CLI 命令 ← 交互功能 +步骤 6 配置迁移 ← 老用户平滑升级 +步骤 7 主题组件 ← 全部 5 个主题 +步骤 8 显示清理 ← icon 安全性 +步骤 9 命令文档 ← slash 命令可用 +步骤 10 测试 ← 验证正确性 +步骤 11 文档更新 ← 用户可见的说明 +``` + +步骤 1-4 为核心链路,完成后状态栏即可正常显示新模块。步骤 5-9 为周边功能,可并行开发。步骤 10-11 为收尾工作。 + +--- + +## 与原方案的主要改进点 + +| # | 改进内容 | 原因 | +|---|---|---| +| 1 | 补充 `IconSet` 接口和 `overlayNerdIcons` 更新 | 遗漏会导致 TypeScript 编译失败 | +| 2 | 移除 `minimatch` 残留导入,统一使用内联 glob | 避免自相矛盾 | +| 3 | 缓存键包含 pattern 参数 | 配置变更后缓存自动失效 | +| 4 | CLAUDE.md 搜索添加 10 层深度限制 | 防止大型项目性能问题 | +| 5 | CLI pattern 命令支持 `add-exclude`/`remove-exclude` | 用户可通过命令行管理排除规则 | +| 6 | 补充 `language` 命令的 `moduleKeyMap` 更新 | 切换语言时图标正确更新 | +| 7 | 补充 `enable.md`/`disable.md` 模块列表更新 | 新模块可被 enable/disable 命令发现 | +| 8 | 默认排除列表增加 `.next`、`.turbo` 等 | 覆盖更多常见构建产物目录 | +| 9 | 全部用中文重写 | 与项目主要用户语言一致 | diff --git a/docs/superpowers/specs/2026-05-16-rules-module-design.md b/docs/superpowers/specs/2026-05-16-rules-module-design.md new file mode 100644 index 0000000..117b9fd --- /dev/null +++ b/docs/superpowers/specs/2026-05-16-rules-module-design.md @@ -0,0 +1,83 @@ +# 规则模块 — 设计规格 + +## 概述 + +为 pulse-line 状态栏新增"规则"模块,统计项目中的 CLAUDE.md 文件、`.claude/` 目录下的配置文件以及 `skills/` 目录下的技能文件数量。同时提供 CLI 命令用于查看详细文件列表和管理自定义扫描模式。 + +## 显示格式 + +``` +[规则] 15 规则:12 Skill:3 +``` + +- `[规则] 15` — 图标 + 总数,使用主题 info 色 +- `规则:12 Skill:3` — 分类明细,使用主题 muted 色(暗色备注样式) +- 某分类为 0 时省略该分类 +- 总数为 0 时不显示明细 + +## 默认扫描目标 + +| 分类 | 模式 | 标签(中/英) | +|---|---|---| +| 规则 | `**/CLAUDE.md`、`.claude/**` | 规则 / Rules | +| 技能 | `skills/**` | Skill / Skill | + +默认排除目录:`node_modules`、`.git`、`dist`、`.worktrees`、`.next`、`.turbo` + +## 配置结构 + +```json +{ + "modules": { + "rules": { + "enabled": true, + "order": 15, + "icon": "[规则]", + "includePatterns": [], + "excludePatterns": [] + } + } +} +``` + +## CLI 命令 + +| 命令 | 输出 | +|---|---| +| `pulse-line rules` | 摘要:总数 + 分类明细 | +| `pulse-line rules list` | 按分类列出所有检测到的文件(相对路径) | +| `pulse-line rules pattern add ` | 添加 include 模式 | +| `pulse-line rules pattern remove ` | 移除 include 模式 | +| `pulse-line rules pattern add-exclude ` | 添加 exclude 模式 | +| `pulse-line rules pattern remove-exclude ` | 移除 exclude 模式 | + +## 架构 + +``` +src/ +├── extractors/rules.ts (新建) — 扫描 + 计数逻辑 +├── extractors/index.ts (修改) — 导出新提取器 +├── types/pulse-config.ts (修改) — RulesModuleConfig + 模块入口 +├── types/theme.ts (修改) — rules 组件样式 +├── themes/icon-sets/nerd.ts (修改) — IconSet 接口 + 图标 +├── themes/icon-sets/text.ts (修改) — IconSet 接口 + 图标 +├── themes/index.ts (修改) — overlayNerdIcons 映射 +├── themes/builtin/*.ts (修改) — 5 个主题添加 rules 组件 +├── index.ts (修改) — 接入渲染管线 +├── cli.ts (修改) — rules 命令组 +├── config/migrate-config.ts (修改) — schema v7 迁移 +├── config/loader.ts (修改) — 更新缓存键 +├── i18n/locales/zh.ts (修改) — 中文标签 +├── i18n/locales/en.ts (修改) — 英文标签 +├── utils/display-sanitize.ts (修改) — MODULE_KEYS 添加 rules +``` + +## 缓存策略 + +使用 `loadSessionCache` / `saveSessionCache`,缓存键由 `cwd + includePatterns + excludePatterns` 组合,TTL 60 秒。配置变更后缓存自动失效。 + +## 约束 + +- **零外部依赖**:不引入 minimatch 等库,glob 匹配使用内联正则实现 +- **深度限制**:CLAUDE.md 搜索限制 10 层递归深度 +- **同步 I/O**:提取器使用 `fs.readdirSync`,与现有提取器模式一致 diff --git a/src/cli.ts b/src/cli.ts index 6b0809c..4a053e4 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -25,9 +25,10 @@ import { upsertToolTimelineAgentMeta } from './tool-timeline/cache'; import { normalizeClaudeSubagentStopHook, normalizeClaudeToolHook } from './extractors/tool-timeline'; +import { extractRules } from './extractors/rules'; import type { ToolTimelineProvider, ToolTimelineEvent, ToolTimelineStats } from './types/tool-timeline'; -const CONFIG_CACHE_KEY = 'pulse-config-v6'; +const CONFIG_CACHE_KEY = 'pulse-config-v7'; function saveAndInvalidate(config: import('./types/pulse-config').PulseConfig): void { saveConfig(config); @@ -433,6 +434,115 @@ timeline.action((options: { renderTimelineTable(cache.sessionId, events, stats); }); +// ── Rules 命令组 ────────────────────────────────── +const rulesCmd = program + .command('rules') + .description('Show project rules/config file count and list'); + +rulesCmd.action(() => { + const cwd = process.cwd(); + const config = loadConfig(); + const mod = config.modules.rules; + const result = extractRules(cwd, mod.includePatterns ?? [], mod.excludePatterns ?? []); + const labels = getLabels(config.language); + + console.log(`${labels.rulesTitle || 'Rules Files'} Summary`); + console.log(` ${labels.rulesCategory || 'Rules'}: ${result.rulesCount}`); + console.log(` ${labels.skillsCategory || 'Skills'}: ${result.skillsCount}`); + console.log(` ${labels.rulesTotal || 'Total'}: ${result.total}`); +}); + +rulesCmd + .command('list') + .description('List all detected rules/config files') + .action(() => { + const cwd = process.cwd(); + const config = loadConfig(); + const mod = config.modules.rules; + const result = extractRules(cwd, mod.includePatterns ?? [], mod.excludePatterns ?? []); + + if (result.total === 0) { + console.log('No rules/config files found in current project.'); + return; + } + + const labels = getLabels(config.language); + + if (result.rulesCount > 0) { + console.log(`=== ${labels.rulesCategory || 'Rules'} (${result.rulesCount}) ===`); + for (const f of result.files.filter(f => f.category === 'rule')) { + console.log(` ${f.relativePath}`); + } + console.log(''); + } + + if (result.skillsCount > 0) { + console.log(`=== ${labels.skillsCategory || 'Skills'} (${result.skillsCount}) ===`); + for (const f of result.files.filter(f => f.category === 'skill')) { + console.log(` ${f.relativePath}`); + } + console.log(''); + } + + console.log(`${labels.rulesTotal || 'Total'}: ${result.total}`); + }); + +rulesCmd + .command('pattern') + .description('Manage custom file patterns (include/exclude)') + .argument('', 'add | remove | add-exclude | remove-exclude') + .argument('', 'File or directory pattern') + .action((action: string, patternStr: string) => { + const validActions = ['add', 'remove', 'add-exclude', 'remove-exclude']; + if (!validActions.includes(action)) { + console.error(`[ERROR] Action must be one of: ${validActions.join(', ')}`); + process.exit(1); + } + + const config = loadConfig(); + const mod = config.modules.rules; + + if (action === 'add' || action === 'remove') { + const patterns = mod.includePatterns ?? []; + if (action === 'add') { + if (patterns.includes(patternStr)) { + console.log(`Pattern already exists: ${patternStr}`); + return; + } + mod.includePatterns = [...patterns, patternStr]; + saveAndInvalidate(config); + console.log(`[OK] Include pattern added: ${patternStr}`); + } else { + if (!patterns.includes(patternStr)) { + console.log(`Pattern not found: ${patternStr}`); + return; + } + mod.includePatterns = patterns.filter(p => p !== patternStr); + saveAndInvalidate(config); + console.log(`[OK] Include pattern removed: ${patternStr}`); + } + } else { + const patterns = mod.excludePatterns ?? []; + if (action === 'add-exclude') { + if (patterns.includes(patternStr)) { + console.log(`Exclude pattern already exists: ${patternStr}`); + return; + } + mod.excludePatterns = [...patterns, patternStr]; + saveAndInvalidate(config); + console.log(`[OK] Exclude pattern added: ${patternStr}`); + } else { + if (!patterns.includes(patternStr)) { + console.log(`Exclude pattern not found: ${patternStr}`); + return; + } + mod.excludePatterns = patterns.filter(p => p !== patternStr); + saveAndInvalidate(config); + console.log(`[OK] Exclude pattern removed: ${patternStr}`); + } + } + }); + program .command('language ') .description('Switch display language (zh, en)') @@ -463,7 +573,8 @@ program outputStyle: 'outputStyle', accountUsage: 'accountUsage', thirdPartyApi: 'thirdPartyApi', - toolTimeline: 'toolTimeline' + toolTimeline: 'toolTimeline', + rules: 'rules' }; for (const [modKey, labelKey] of Object.entries(moduleKeyMap)) { diff --git a/src/config/loader.ts b/src/config/loader.ts index b638fc9..58304a2 100644 --- a/src/config/loader.ts +++ b/src/config/loader.ts @@ -21,7 +21,7 @@ function persistIfMigrated( export function loadConfig(): PulseConfig { const configPath = getConfigPath(); - const cacheKey = 'pulse-config-v6'; + const cacheKey = 'pulse-config-v7'; const cached = loadSessionCache('global', cacheKey); if (cached) { diff --git a/src/config/migrate-config.ts b/src/config/migrate-config.ts index f3bd589..ef1099f 100644 --- a/src/config/migrate-config.ts +++ b/src/config/migrate-config.ts @@ -3,7 +3,7 @@ import type { PulseConfig } from '../types/pulse-config'; import { DEFAULT_CONFIG } from '../types/pulse-config'; -const CURRENT_SCHEMA = 6; +const CURRENT_SCHEMA = 7; export function upgradePulseSchemaIfNeeded(config: PulseConfig): boolean { const v = config.schemaVersion ?? 0; @@ -30,6 +30,14 @@ export function upgradePulseSchemaIfNeeded(config: PulseConfig): boolean { ensureToolTimelineAnalyticsDefaults(config); } + if (v < 7) { + if (!(config.modules as any).rules) { + (config.modules as any).rules = JSON.parse( + JSON.stringify(DEFAULT_CONFIG.modules.rules) + ); + } + } + config.schemaVersion = CURRENT_SCHEMA; return true; } diff --git a/src/extractors/index.ts b/src/extractors/index.ts index 3eb9066..47c90e2 100644 --- a/src/extractors/index.ts +++ b/src/extractors/index.ts @@ -49,3 +49,6 @@ export { relativeToCwd } from './tool-timeline'; export type { ToolAnalyticsPanel, ToolTimelineSegment } from './tool-timeline'; + +export { extractRules } from './rules'; +export type { RulesSegment, RulesFileEntry } from './rules'; diff --git a/src/extractors/rules.ts b/src/extractors/rules.ts new file mode 100644 index 0000000..3f236c7 --- /dev/null +++ b/src/extractors/rules.ts @@ -0,0 +1,196 @@ +// src/extractors/rules.ts + +import * as fs from 'fs'; +import * as path from 'path'; +import { loadSessionCache, saveSessionCache } from '../utils/cache'; + +export interface RulesFileEntry { + relativePath: string; + category: 'rule' | 'skill'; +} + +export interface RulesSegment { + total: number; + rulesCount: number; + skillsCount: number; + files: RulesFileEntry[]; +} + +const DEFAULT_EXCLUDES = new Set([ + 'node_modules', '.git', 'dist', '.worktrees', + '.next', '.nuxt', 'coverage', '__pycache__', '.turbo' +]); + +/** Directories excluded from CLAUDE.md search but NOT from walkDir */ +const CLAUDE_MD_EXCLUDES = new Set([ + ...DEFAULT_EXCLUDES, '.claude', 'skills' +]); + +const MAX_DEPTH = 10; +const CACHE_TTL = 60_000; + +function buildCacheKey(cwd: string, includes: string[], excludes: string[]): string { + return `rules-scan:${cwd}::${includes.join(',')}::${excludes.join(',')}`; +} + +function shouldExcludeDir(name: string, excludePatterns: string[]): boolean { + if (DEFAULT_EXCLUDES.has(name)) return true; + for (const pat of excludePatterns) { + if (globMatch(name, pat)) return true; + } + return false; +} + +function globMatch(value: string, pattern: string): boolean { + const re = '^' + pattern + .replace(/[.+^${}()|[\]\\]/g, '\\$&') + .replace(/\*/g, '.*') + .replace(/\?/g, '.') + '$'; + return new RegExp(re).test(value); +} + +function walkDir( + dir: string, + cwd: string, + excludePatterns: string[], + category: 'rule' | 'skill', + depth: number = 0 +): RulesFileEntry[] { + if (depth > MAX_DEPTH) return []; + const results: RulesFileEntry[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + if (shouldExcludeDir(entry.name, excludePatterns)) continue; + results.push(...walkDir(path.join(dir, entry.name), cwd, excludePatterns, category, depth + 1)); + } else if (entry.isFile()) { + results.push({ + relativePath: path.relative(cwd, path.join(dir, entry.name)), + category + }); + } + } + } catch { + // Directory doesn't exist or permission denied — skip + } + return results; +} + +function findClaudeMdFiles( + dir: string, + cwd: string, + excludePatterns: string[], + depth: number +): RulesFileEntry[] { + if (depth > MAX_DEPTH) return []; + const results: RulesFileEntry[] = []; + try { + const entries = fs.readdirSync(dir, { withFileTypes: true }); + for (const entry of entries) { + if (entry.isDirectory()) { + // Use broader exclude set to avoid overlap with .claude/ and skills/ scans + if (CLAUDE_MD_EXCLUDES.has(entry.name) || shouldExcludeDir(entry.name, excludePatterns)) continue; + results.push(...findClaudeMdFiles( + path.join(dir, entry.name), cwd, excludePatterns, depth + 1 + )); + } else if (entry.name === 'CLAUDE.md') { + results.push({ + relativePath: path.relative(cwd, path.join(dir, entry.name)), + category: 'rule' + }); + } + } + } catch { + // skip + } + return results; +} + +function scanIncludePatterns( + cwd: string, + patterns: string[], + excludePatterns: string[], + existing: RulesFileEntry[] +): RulesFileEntry[] { + const results: RulesFileEntry[] = []; + const existingPaths = new Set(existing.map(f => f.relativePath)); + + for (const pattern of patterns) { + const resolved = path.resolve(cwd, pattern); + try { + const stat = fs.statSync(resolved); + if (stat.isFile()) { + const rel = path.relative(cwd, resolved); + if (!existingPaths.has(rel)) { + results.push({ relativePath: rel, category: 'rule' }); + } + } else if (stat.isDirectory()) { + const entries = walkDir(resolved, cwd, excludePatterns, 'rule'); + for (const e of entries) { + if (!existingPaths.has(e.relativePath)) { + results.push(e); + } + } + } + } catch { + // Path doesn't exist — skip + } + } + return results; +} + +function dedup(files: RulesFileEntry[]): RulesFileEntry[] { + const seen = new Set(); + const result: RulesFileEntry[] = []; + for (const f of files) { + if (!seen.has(f.relativePath)) { + seen.add(f.relativePath); + result.push(f); + } + } + return result; +} + +export function extractRules( + cwd: string, + includePatterns: string[] = [], + excludePatterns: string[] = [] +): RulesSegment { + const cacheKey = buildCacheKey(cwd, includePatterns, excludePatterns); + const cached = loadSessionCache('global', cacheKey); + if (cached) return cached; + + const allFiles: RulesFileEntry[] = []; + + // 1. Scan CLAUDE.md files (recursive, depth-limited) + allFiles.push(...findClaudeMdFiles(cwd, cwd, excludePatterns, 0)); + + // 2. Scan .claude/ directory + const claudeDir = path.join(cwd, '.claude'); + if (fs.existsSync(claudeDir)) { + allFiles.push(...walkDir(claudeDir, cwd, excludePatterns, 'rule')); + } + + // 3. Scan skills/ directory + const skillsDir = path.join(cwd, 'skills'); + if (fs.existsSync(skillsDir)) { + allFiles.push(...walkDir(skillsDir, cwd, excludePatterns, 'skill')); + } + + // 4. User-defined includePatterns + const extras = scanIncludePatterns(cwd, includePatterns, excludePatterns, allFiles); + allFiles.push(...extras); + + const files = dedup(allFiles); + + const segment: RulesSegment = { + total: files.length, + rulesCount: files.filter(f => f.category === 'rule').length, + skillsCount: files.filter(f => f.category === 'skill').length, + files + }; + + saveSessionCache('global', cacheKey, segment, CACHE_TTL); + return segment; +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f0ab88e..19eb4d1 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -34,4 +34,11 @@ export const enLabels: Record = { toolAnalyticsRecent: 'Recent', toolAnalyticsNone: 'No tool calls', toolAnalyticsUnknownAgent: 'Unknown agent', + rules: 'Rules', + rulesFiles: 'Rules', + rulesSkills: 'Skill', + rulesTitle: 'Rules Files', + rulesCategory: 'Rules', + skillsCategory: 'Skills', + rulesTotal: 'Total', }; diff --git a/src/i18n/locales/zh.ts b/src/i18n/locales/zh.ts index a3423ef..c38c5d3 100644 --- a/src/i18n/locales/zh.ts +++ b/src/i18n/locales/zh.ts @@ -34,4 +34,11 @@ export const zhLabels: Record = { toolAnalyticsRecent: '\u6700\u8fd1', toolAnalyticsNone: '\u6682\u65e0\u5de5\u5177\u8c03\u7528', toolAnalyticsUnknownAgent: '\u672a\u77e5 agent', + rules: '\u89c4\u5219', + rulesFiles: '\u89c4\u5219', + rulesSkills: 'Skill', + rulesTitle: '\u89c4\u5219\u6587\u4ef6', + rulesCategory: '\u89c4\u5219\u6587\u4ef6', + skillsCategory: 'Skills', + rulesTotal: '\u603b\u8ba1', }; diff --git a/src/index.ts b/src/index.ts index 55c3eff..856ba83 100644 --- a/src/index.ts +++ b/src/index.ts @@ -20,7 +20,8 @@ import { extractAccountUsageSync, refreshAccountUsage, extractToolTimeline, - renderToolAnalyticsPanel + renderToolAnalyticsPanel, + extractRules } from './extractors'; import { renderLayout, @@ -30,6 +31,7 @@ import { renderProgressBar } from './formatters/progress-bar'; import { colorize } from './utils/ansi'; import { debug } from './utils/logger'; import { getTerminalWidth } from './utils/terminal-width'; +import { getLabel } from './i18n'; interface OrderedSegment { order: number; @@ -244,6 +246,33 @@ async function main() { } } + // Rules + if (modules.rules.enabled) { + const rules = extractRules( + input.cwd, + modules.rules.includePatterns ?? [], + modules.rules.excludePatterns ?? [] + ); + const icon = modules.rules.icon ?? theme.components.rules.icon ?? ''; + const label = rules.total.toString(); + const line = icon.length > 0 ? `${icon} ${label}` : label; + + const parts: string[] = []; + if (rules.rulesCount > 0) { + parts.push(`${getLabel(config.language, 'rulesFiles')}:${rules.rulesCount}`); + } + if (rules.skillsCount > 0) { + parts.push(`${getLabel(config.language, 'rulesSkills')}:${rules.skillsCount}`); + } + const suffix = parts.length > 0 ? ` ${parts.join(' ')}` : ''; + + segments.push({ + order: modules.rules.order, + text: colorize(theme.colors.info, line) + + (suffix ? colorize(theme.colors.muted, suffix) : '') + }); + } + // Third-party API usage (async) if (modules.thirdPartyApi.enabled) { const providers = modules.thirdPartyApi.providers || []; diff --git a/src/themes/builtin/cyberpunk.ts b/src/themes/builtin/cyberpunk.ts index 3aba367..5472a83 100644 --- a/src/themes/builtin/cyberpunk.ts +++ b/src/themes/builtin/cyberpunk.ts @@ -42,6 +42,7 @@ export const cyberpunkTheme: Theme = { thinking: { fg: '#ff79c6', icon: '[Think]', showIcon: true }, outputStyle: { fg: '#bd93f9', icon: '[S]', showIcon: true }, toolTimeline: { fg: '#8be9fd', icon: '[Tool]', showIcon: true }, + rules: { fg: '#8be9fd', icon: '[R]', showIcon: true }, separator: { fg: '#6272a4', icon: '', showIcon: false, dim: true } } }; diff --git a/src/themes/builtin/dark.ts b/src/themes/builtin/dark.ts index 78f2d7f..3a4f6f3 100644 --- a/src/themes/builtin/dark.ts +++ b/src/themes/builtin/dark.ts @@ -42,6 +42,7 @@ export const darkTheme: Theme = { thinking: { fg: '#bb9af7', icon: '[Think]', showIcon: true }, outputStyle: { fg: '#a9b1d6', icon: '[S]', showIcon: true }, toolTimeline: { fg: '#7dcfff', icon: '[Tool]', showIcon: true }, + rules: { fg: '#7dcfff', icon: '[R]', showIcon: true }, separator: { fg: '#565f89', dim: true, icon: '', showIcon: false } } }; diff --git a/src/themes/builtin/forest.ts b/src/themes/builtin/forest.ts index 3e85a67..acce4b4 100644 --- a/src/themes/builtin/forest.ts +++ b/src/themes/builtin/forest.ts @@ -42,6 +42,7 @@ export const forestTheme: Theme = { thinking: { fg: '#b7e4c7', icon: '[Think]', showIcon: true }, outputStyle: { fg: '#d8f3dc', icon: '[S]', showIcon: true }, toolTimeline: { fg: '#95d5b2', icon: '[Tool]', showIcon: true }, + rules: { fg: '#95d5b2', icon: '[R]', showIcon: true }, separator: { fg: '#74c69d', icon: '', showIcon: false, dim: true } } }; diff --git a/src/themes/builtin/light.ts b/src/themes/builtin/light.ts index bfb5a89..a991f3a 100644 --- a/src/themes/builtin/light.ts +++ b/src/themes/builtin/light.ts @@ -42,6 +42,7 @@ export const lightTheme: Theme = { thinking: { fg: '#7c3aed', icon: '[Think]', showIcon: true }, outputStyle: { fg: '#71717a', icon: '[S]', showIcon: true }, toolTimeline: { fg: '#0891b2', icon: '[Tool]', showIcon: true }, + rules: { fg: '#0891b2', icon: '[R]', showIcon: true }, separator: { fg: '#a1a1aa', icon: '', showIcon: false, dim: true } } }; diff --git a/src/themes/builtin/ocean.ts b/src/themes/builtin/ocean.ts index 9f12559..82854d4 100644 --- a/src/themes/builtin/ocean.ts +++ b/src/themes/builtin/ocean.ts @@ -42,6 +42,7 @@ export const oceanTheme: Theme = { thinking: { fg: '#818cf8', icon: '[Think]', showIcon: true }, outputStyle: { fg: '#e0f2fe', icon: '[S]', showIcon: true }, toolTimeline: { fg: '#67e8f9', icon: '[Tool]', showIcon: true }, + rules: { fg: '#67e8f9', icon: '[R]', showIcon: true }, separator: { fg: '#7dd3fc', icon: '', showIcon: false, dim: true } } }; diff --git a/src/themes/icon-sets/nerd.ts b/src/themes/icon-sets/nerd.ts index 4d67edb..fec492d 100644 --- a/src/themes/icon-sets/nerd.ts +++ b/src/themes/icon-sets/nerd.ts @@ -15,6 +15,7 @@ export interface IconSet { outputStyle: string; accountUsage: string; toolTimeline: string; + rules: string; } export const nerdIconSet: IconSet = { @@ -32,5 +33,6 @@ export const nerdIconSet: IconSet = { thinking: "󰂲", outputStyle: "󰀺", accountUsage: "󰃦", - toolTimeline: "[Tool]" + toolTimeline: "[Tool]", + rules: "" }; diff --git a/src/themes/icon-sets/text.ts b/src/themes/icon-sets/text.ts index 908f246..ee11114 100644 --- a/src/themes/icon-sets/text.ts +++ b/src/themes/icon-sets/text.ts @@ -16,6 +16,7 @@ export interface IconSet { outputStyle: string; accountUsage: string; toolTimeline: string; + rules: string; } export const textIconSet: IconSet = { @@ -33,5 +34,6 @@ export const textIconSet: IconSet = { thinking: '[Think]', outputStyle: '[S]', accountUsage: '[A]', - toolTimeline: '[Tool]' + toolTimeline: '[Tool]', + rules: '[R]' }; diff --git a/src/themes/index.ts b/src/themes/index.ts index 1e34b5a..116deb3 100644 --- a/src/themes/index.ts +++ b/src/themes/index.ts @@ -37,6 +37,7 @@ function overlayNerdIcons(theme: Theme): Theme { theme.components.thinking.icon = n.thinking; theme.components.outputStyle.icon = n.outputStyle; theme.components.toolTimeline.icon = n.toolTimeline; + theme.components.rules.icon = n.rules; theme.separator.left = '\u{e0b0}'; return theme; } diff --git a/src/types/pulse-config.ts b/src/types/pulse-config.ts index d5776cd..90629e7 100644 --- a/src/types/pulse-config.ts +++ b/src/types/pulse-config.ts @@ -29,6 +29,7 @@ export interface PulseConfig { thirdPartyApi: ThirdPartyApiConfig; accountUsage: AccountUsageModuleConfig; toolTimeline: ToolTimelineModuleConfig; + rules: RulesModuleConfig; }; advanced: { cacheEnabled: boolean; @@ -84,6 +85,11 @@ export interface ToolTimelineModuleConfig extends ModuleConfig { summaryMaxLength?: number; } +export interface RulesModuleConfig extends ModuleConfig { + includePatterns?: string[]; + excludePatterns?: string[]; +} + export const DEFAULT_CONFIG: PulseConfig = { theme: 'dark', separator: ' │ ', @@ -92,7 +98,7 @@ export const DEFAULT_CONFIG: PulseConfig = { refreshInterval: 5, iconSet: 'text', language: 'en', - schemaVersion: 6, + schemaVersion: 7, modules: { model: { enabled: true, order: 1, icon: '[当前模型]' }, git: { @@ -121,9 +127,16 @@ export const DEFAULT_CONFIG: PulseConfig = { weeklyQuota: { enabled: false, order: 13, icon: '[配额]', showCountdown: true }, outputStyle: { enabled: false, order: 14, icon: '[风格]' }, thirdPartyApi: { enabled: false, order: 15, icon: '[API]', providers: [] }, + rules: { + enabled: true, + order: 16, + icon: '[规则]', + includePatterns: [], + excludePatterns: [] + }, toolTimeline: { enabled: false, - order: 16, + order: 17, icon: '[工具]', displayMode: 'analytics-panel', mode: 'summary', diff --git a/src/types/theme.ts b/src/types/theme.ts index 2e2d3bc..d8c8c6e 100644 --- a/src/types/theme.ts +++ b/src/types/theme.ts @@ -40,6 +40,7 @@ export interface Theme { outputStyle: ComponentStyle; accountUsage: ComponentStyle; toolTimeline: ComponentStyle; + rules: ComponentStyle; separator: ComponentStyle; }; } diff --git a/src/utils/display-sanitize.ts b/src/utils/display-sanitize.ts index f6af42a..be29bc6 100644 --- a/src/utils/display-sanitize.ts +++ b/src/utils/display-sanitize.ts @@ -41,7 +41,8 @@ const MODULE_KEYS = [ 'outputStyle', 'thirdPartyApi', 'accountUsage', - 'toolTimeline' + 'toolTimeline', + 'rules' ] as const; /** diff --git a/test/migrate-config.test.ts b/test/migrate-config.test.ts index e4a10eb..453b92f 100644 --- a/test/migrate-config.test.ts +++ b/test/migrate-config.test.ts @@ -16,7 +16,7 @@ test('upgradePulseSchemaIfNeeded coerces nerd to text once for old schema', () = assert.strictEqual(c.modules.toolTimeline.enabled, false); assert.strictEqual(c.modules.toolTimeline.displayMode, 'analytics-panel'); assert.strictEqual(c.modules.toolTimeline.maxDisplayEvents, 5); - assert.strictEqual(c.schemaVersion, 6); + assert.strictEqual(c.schemaVersion, 7); }); test('upgradePulseSchemaIfNeeded skips current schema', () => { @@ -31,9 +31,9 @@ test('upgradePulseSchemaIfNeeded adds toolTimeline to v4 config', () => { assert.strictEqual(upgradePulseSchemaIfNeeded(c), true); assert.ok(c.modules.toolTimeline); - assert.strictEqual(c.modules.toolTimeline.order, 16); + assert.strictEqual(c.modules.toolTimeline.order, 17); assert.strictEqual(c.modules.toolTimeline.displayMode, 'analytics-panel'); - assert.strictEqual(c.schemaVersion, 6); + assert.strictEqual(c.schemaVersion, 7); }); test('upgradePulseSchemaIfNeeded does not coerce nerd for v4 config', () => { @@ -46,7 +46,7 @@ test('upgradePulseSchemaIfNeeded does not coerce nerd for v4 config', () => { assert.strictEqual(c.iconSet, 'nerd'); assert.ok(c.modules.toolTimeline); assert.strictEqual(c.modules.toolTimeline.displayMode, 'analytics-panel'); - assert.strictEqual(c.schemaVersion, 6); + assert.strictEqual(c.schemaVersion, 7); }); test('upgradePulseSchemaIfNeeded fills v5 analytics panel defaults', () => { @@ -60,5 +60,19 @@ test('upgradePulseSchemaIfNeeded fills v5 analytics panel defaults', () => { assert.strictEqual(c.modules.toolTimeline.displayMode, 'analytics-panel'); assert.strictEqual(c.modules.toolTimeline.maxDisplayEvents, 5); assert.strictEqual(c.modules.toolTimeline.panelWidth, 59); - assert.strictEqual(c.schemaVersion, 6); + assert.strictEqual(c.schemaVersion, 7); +}); + +test('upgradePulseSchemaIfNeeded adds rules module for v6 config', () => { + const c = JSON.parse(JSON.stringify(DEFAULT_CONFIG)) as PulseConfig; + delete (c.modules as any).rules; + c.schemaVersion = 6; + + assert.strictEqual(upgradePulseSchemaIfNeeded(c), true); + assert.ok(c.modules.rules); + assert.strictEqual(c.modules.rules.enabled, true); + assert.strictEqual(c.modules.rules.order, 16); + assert.deepStrictEqual(c.modules.rules.includePatterns, []); + assert.deepStrictEqual(c.modules.rules.excludePatterns, []); + assert.strictEqual(c.schemaVersion, 7); }); diff --git a/test/rules.test.ts b/test/rules.test.ts new file mode 100644 index 0000000..8e164b1 --- /dev/null +++ b/test/rules.test.ts @@ -0,0 +1,231 @@ +// test/rules.test.ts + +import { test } from 'node:test'; +import assert from 'node:assert'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { extractRules } from '../src/extractors/rules'; + +function makeTmpDir(): string { + return fs.mkdtempSync(path.join(os.tmpdir(), 'pulse-rules-')); +} + +function cleanup(dir: string): void { + try { + fs.rmSync(dir, { recursive: true, force: true }); + } catch { + // ignore + } +} + +test('extractRules returns zeros for empty directory', () => { + const dir = makeTmpDir(); + try { + const result = extractRules(dir); + assert.strictEqual(result.total, 0); + assert.strictEqual(result.rulesCount, 0); + assert.strictEqual(result.skillsCount, 0); + assert.strictEqual(result.files.length, 0); + } finally { + cleanup(dir); + } +}); + +test('extractRules finds CLAUDE.md at root', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Rules'); + const result = extractRules(dir); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.rulesCount, 1); + assert.strictEqual(result.skillsCount, 0); + assert.strictEqual(result.files[0].relativePath, 'CLAUDE.md'); + } finally { + cleanup(dir); + } +}); + +test('extractRules finds nested CLAUDE.md', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Root'); + const sub = path.join(dir, 'packages', 'lib'); + fs.mkdirSync(sub, { recursive: true }); + fs.writeFileSync(path.join(sub, 'CLAUDE.md'), '# Lib'); + + const result = extractRules(dir); + assert.strictEqual(result.rulesCount, 2); + assert.strictEqual(result.total, 2); + + const paths = result.files.map(f => f.relativePath); + assert.ok(paths.includes('CLAUDE.md')); + assert.ok(paths.includes(path.join('packages', 'lib', 'CLAUDE.md'))); + } finally { + cleanup(dir); + } +}); + +test('extractRules scans .claude/ directory', () => { + const dir = makeTmpDir(); + try { + const claudeDir = path.join(dir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}'); + const agentsDir = path.join(claudeDir, 'agents'); + fs.mkdirSync(agentsDir, { recursive: true }); + fs.writeFileSync(path.join(agentsDir, 'reviewer.md'), '# Agent'); + + const result = extractRules(dir); + assert.strictEqual(result.rulesCount, 2); + assert.strictEqual(result.total, 2); + + const paths = result.files.map(f => f.relativePath); + assert.ok(paths.includes(path.join('.claude', 'settings.json'))); + assert.ok(paths.includes(path.join('.claude', 'agents', 'reviewer.md'))); + } finally { + cleanup(dir); + } +}); + +test('extractRules scans skills/ directory', () => { + const dir = makeTmpDir(); + try { + const skillsDir = path.join(dir, 'skills', 'my-skill'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '# Skill'); + + const result = extractRules(dir); + assert.strictEqual(result.skillsCount, 1); + assert.strictEqual(result.rulesCount, 0); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.files[0].category, 'skill'); + assert.strictEqual(result.files[0].relativePath, path.join('skills', 'my-skill', 'SKILL.md')); + } finally { + cleanup(dir); + } +}); + +test('extractRules combines all categories', () => { + const dir = makeTmpDir(); + try { + // CLAUDE.md at root + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Root'); + + // .claude/ files + const claudeDir = path.join(dir, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.json'), '{}'); + + // skills/ files + const skillsDir = path.join(dir, 'skills', 'test'); + fs.mkdirSync(skillsDir, { recursive: true }); + fs.writeFileSync(path.join(skillsDir, 'SKILL.md'), '# Test'); + + const result = extractRules(dir); + assert.strictEqual(result.rulesCount, 2); // CLAUDE.md + settings.json + assert.strictEqual(result.skillsCount, 1); + assert.strictEqual(result.total, 3); + } finally { + cleanup(dir); + } +}); + +test('extractRules deduplicates files', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Rules'); + + // Call twice with same args — cache returns same result + const r1 = extractRules(dir); + const r2 = extractRules(dir); + assert.strictEqual(r1.total, r2.total); + assert.strictEqual(r1.files.length, r2.files.length); + } finally { + cleanup(dir); + } +}); + +test('extractRules excludes default directories', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Root'); + + // node_modules with a CLAUDE.md inside should be excluded + const nm = path.join(dir, 'node_modules', 'pkg'); + fs.mkdirSync(nm, { recursive: true }); + fs.writeFileSync(path.join(nm, 'CLAUDE.md'), '# Should not appear'); + + // .git should be excluded + const git = path.join(dir, '.git'); + fs.mkdirSync(git, { recursive: true }); + fs.writeFileSync(path.join(git, 'config'), '[core]'); + + const result = extractRules(dir); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.files[0].relativePath, 'CLAUDE.md'); + } finally { + cleanup(dir); + } +}); + +test('extractRules respects excludePatterns', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'CLAUDE.md'), '# Root'); + const vendor = path.join(dir, 'vendor', 'lib'); + fs.mkdirSync(vendor, { recursive: true }); + fs.writeFileSync(path.join(vendor, 'CLAUDE.md'), '# Vendor'); + + const result = extractRules(dir, [], ['vendor']); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.files[0].relativePath, 'CLAUDE.md'); + } finally { + cleanup(dir); + } +}); + +test('extractRules respects includePatterns for directories', () => { + const dir = makeTmpDir(); + try { + const docs = path.join(dir, 'docs', 'rules'); + fs.mkdirSync(docs, { recursive: true }); + fs.writeFileSync(path.join(docs, 'guide.md'), '# Guide'); + + const result = extractRules(dir, ['docs']); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.files[0].relativePath, path.join('docs', 'rules', 'guide.md')); + } finally { + cleanup(dir); + } +}); + +test('extractRules respects includePatterns for files', () => { + const dir = makeTmpDir(); + try { + fs.writeFileSync(path.join(dir, 'custom-config.yml'), 'key: value'); + + const result = extractRules(dir, ['custom-config.yml']); + assert.strictEqual(result.total, 1); + assert.strictEqual(result.files[0].relativePath, 'custom-config.yml'); + } finally { + cleanup(dir); + } +}); + +test('extractRules cache invalidates when patterns change', () => { + const dir = makeTmpDir(); + try { + const docs = path.join(dir, 'docs'); + fs.mkdirSync(docs, { recursive: true }); + fs.writeFileSync(path.join(docs, 'guide.md'), '# Guide'); + + const r1 = extractRules(dir, [], []); + assert.strictEqual(r1.total, 0); + + const r2 = extractRules(dir, ['docs'], []); + assert.strictEqual(r2.total, 1); + } finally { + cleanup(dir); + } +});