install.js
View source (106 lines)
#!/usr/bin/env node
// CC Status Board — Installer for Claude Code
// Installs: statusline + asset suggest hook + index builder
const fs = require('fs');
const path = require('path');
const os = require('os');
const HOME = os.homedir();
const CLAUDE_DIR = path.join(HOME, '.claude');
const HOOKS_DIR = path.join(CLAUDE_DIR, 'hooks');
const CACHE_DIR = path.join(CLAUDE_DIR, 'cache');
const SETTINGS_PATH = path.join(CLAUDE_DIR, 'settings.json');
const SRC_DIR = path.join(__dirname, 'src');
const GREEN = '\x1b[32m';
const CYAN = '\x1b[36m';
const YELLOW = '\x1b[33m';
const DIM = '\x1b[2m';
const RESET = '\x1b[0m';
console.log(`
${CYAN}╔══════════════════════════════════════════════╗
║ CC Status Board v1.0.0 ║
║ Smart status bar for Claude Code ║
╚══════════════════════════════════════════════╝${RESET}
`);
// Ensure directories exist
for (const dir of [CLAUDE_DIR, HOOKS_DIR, CACHE_DIR]) {
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
}
// 1. Install hooks
const files = {
'cc-statusline.js': 'statusline.js',
'cc-asset-suggest.js': 'asset-suggest.js',
'cc-build-index.js': 'build-index.js',
};
for (const [target, source] of Object.entries(files)) {
const src = path.join(SRC_DIR, source);
const dst = path.join(HOOKS_DIR, target);
fs.copyFileSync(src, dst);
console.log(` ${GREEN}✓${RESET} Installed ${CYAN}${target}${RESET} -> hooks/`);
}
// 2. Update settings.json
let settings = {};
if (fs.existsSync(SETTINGS_PATH)) {
try { settings = JSON.parse(fs.readFileSync(SETTINGS_PATH, 'utf8')); } catch (e) {}
}
// Statusline
const statuslineCmd = `node "${path.join(HOOKS_DIR, 'cc-statusline.js')}"`;
if (!settings.statusLine || !settings.statusLine.command?.includes('cc-statusline')) {
// Don't overwrite if user has a custom statusline, just inform
if (settings.statusLine && !settings.statusLine.command?.includes('gsd-statusline') && !settings.statusLine.command?.includes('cc-statusline')) {
console.log(` ${YELLOW}⚠${RESET} Existing statusline detected, not overwriting.`);
console.log(` ${DIM}To use CC Status Board's statusline, set manually:${RESET}`);
console.log(` ${DIM}"statusLine": {"type":"command","command":"${statuslineCmd}"}${RESET}`);
} else {
settings.statusLine = { type: 'command', command: statuslineCmd };
console.log(` ${GREEN}✓${RESET} Configured statusline`);
}
}
// UserPromptSubmit hook
if (!settings.hooks) settings.hooks = {};
if (!settings.hooks.UserPromptSubmit) settings.hooks.UserPromptSubmit = [];
const hookCmd = `node "${path.join(HOOKS_DIR, 'cc-asset-suggest.js')}"`;
const hasAssetHook = settings.hooks.UserPromptSubmit.some(h =>
h.hooks?.some(hh => hh.command?.includes('cc-asset-suggest') || hh.command?.includes('asset-suggest'))
);
if (!hasAssetHook) {
settings.hooks.UserPromptSubmit.push({
hooks: [{ type: 'command', command: hookCmd, timeout: 3 }]
});
console.log(` ${GREEN}✓${RESET} Configured asset suggest hook`);
} else {
console.log(` ${DIM} Asset suggest hook already configured${RESET}`);
}
fs.writeFileSync(SETTINGS_PATH, JSON.stringify(settings, null, 2));
console.log(` ${GREEN}✓${RESET} Updated settings.json`);
// 3. Build initial asset index
console.log(`\n Building asset index...`);
try {
const { execSync } = require('child_process');
execSync(`node "${path.join(HOOKS_DIR, 'cc-build-index.js')}"`, { stdio: 'inherit', timeout: 120000 });
} catch (e) {
console.log(` ${YELLOW}⚠${RESET} Index build failed: ${e.message}`);
console.log(` ${DIM}Run manually: node ~/.claude/hooks/cc-build-index.js${RESET}`);
}
console.log(`
${GREEN}Done!${RESET} Restart Claude Code to see:
${CYAN}Opus 4.6 (1M context) │ myproject █░░░░░░░░░ 12% │ 💡 /pdf /xlsx${RESET}
${DIM}• Context meter — see how much context window you've used
• Asset suggest — relevant AI assets appear as you type
• Rebuild index: node ~/.claude/hooks/cc-build-index.js${RESET}
`);src/build-index.js
View source (453 lines)
#!/usr/bin/env node
// Build a searchable index of ALL installed AI assets
// Sources: skills, agents, commands, plugins, MCP servers, hooks
// Output: ~/.claude/cache/asset-index.json
//
// v2: Intent-based classification + TF-IDF weights
const fs = require('fs');
const path = require('path');
const os = require('os');
const HOME = os.homedir();
const CLAUDE_DIR = path.join(HOME, '.claude');
const OUTPUT = path.join(CLAUDE_DIR, 'cache', 'asset-index.json');
const cacheDir = path.join(CLAUDE_DIR, 'cache');
if (!fs.existsSync(cacheDir)) fs.mkdirSync(cacheDir, { recursive: true });
// ── Intent taxonomy ──────────────────────────────────────────────
// Each intent has trigger patterns (checked against name+desc) and
// query aliases (what users might type to mean this intent).
const INTENT_TAXONOMY = {
'seo': {
triggers: ['seo', 'search engine', 'keyword research', 'on-page', 'meta tag', 'sitemap', 'indexing', 'serp'],
aliases: ['seo', '搜索引擎', '关键词', '排名', 'ranking', 'keyword', 'serp', 'sitemap', '收录']
},
'backlink': {
triggers: ['backlink', 'link building', 'link-building', 'outreach', 'guest post', 'external link'],
aliases: ['backlink', '外链', 'link building', '反链', 'outreach', '链接建设']
},
'content-marketing': {
triggers: ['content creation', 'content marketing', 'blog', 'newsletter', 'copywriting', 'draft content', 'draft-content'],
aliases: ['content', '内容', 'blog', 'copywriting', '文案', 'newsletter', '写作']
},
'competitive-analysis': {
triggers: ['competitor', 'competitive', 'market positioning', 'battlecard', 'competitive-'],
aliases: ['competitor', '竞品', '竞争', 'competitive', '对手', 'battlecard', '竞分']
},
'scraping': {
triggers: ['scrape', 'scraping', 'crawl', 'extract data', 'web scraper', 'spider'],
aliases: ['scrape', '爬虫', 'crawl', '抓取', '采集', 'spider', 'extract']
},
'pdf': {
triggers: ['pdf', '.pdf'],
aliases: ['pdf']
},
'spreadsheet': {
triggers: ['xlsx', 'excel', 'spreadsheet', '.csv', 'tsv', 'tabular'],
aliases: ['excel', 'xlsx', '表格', 'spreadsheet', 'csv']
},
'slides': {
triggers: ['pptx', 'slide', 'presentation', 'deck', 'pitch'],
aliases: ['ppt', 'pptx', '幻灯片', 'slide', 'presentation', 'deck', '演示']
},
'document': {
triggers: ['docx', 'word document', '.docx'],
aliases: ['docx', 'word', '文档']
},
'frontend': {
triggers: ['frontend', 'front-end', 'ui design', 'web component', 'react', 'css', 'html', 'landing page', 'dashboard'],
aliases: ['frontend', '前端', 'ui', 'design', '页面', 'component', 'landing', 'dashboard', '界面']
},
'database': {
triggers: ['database', 'mysql', 'sql', 'query', 'postgresql', 'sqlite', 'migration', 'schema'],
aliases: ['database', '数据库', 'sql', 'mysql', 'query', '查询', 'db']
},
'deploy': {
triggers: ['deploy', 'deployment', 'production', 'ci/cd', 'pm2', 'server', 'hosting'],
aliases: ['deploy', '部署', 'production', '上线', '发布', 'server', '服务器']
},
'debug': {
triggers: ['debug', 'debugging', 'troubleshoot', 'diagnose', 'error', 'bug fix'],
aliases: ['debug', '调试', 'bug', '报错', 'error', 'fix', '修复', 'troubleshoot']
},
'testing': {
triggers: ['test', 'testing', 'qa', 'unit test', 'integration test', 'e2e', 'playwright'],
aliases: ['test', '测试', 'qa', 'unittest', 'e2e', '自动化测试']
},
'code-review': {
triggers: ['code review', 'code-review', 'review code', 'pull request review', 'adversarial review'],
aliases: ['review', '代码审查', 'code review', 'pr review']
},
'video': {
triggers: ['video', 'youtube', 'tiktok', 'remotion', 'gif', 'animation'],
aliases: ['video', '视频', 'youtube', 'tiktok', '短视频', 'gif', '动画']
},
'email': {
triggers: ['email', 'mail', 'outreach', 'newsletter', 'email sequence', 'drip'],
aliases: ['email', '邮件', 'mail', 'outreach', 'newsletter']
},
'sales': {
triggers: ['sales', 'pipeline', 'forecast', 'deal', 'prospect', 'crm', 'call prep'],
aliases: ['sales', '销售', 'pipeline', 'forecast', 'prospect', 'crm', '客户']
},
'project-management': {
triggers: ['project', 'roadmap', 'sprint', 'milestone', 'epic', 'story', 'kanban', 'scrum'],
aliases: ['project', '项目', 'roadmap', 'sprint', '规划', 'milestone', '里程碑']
},
'planning': {
triggers: ['plan', 'planning', 'architect', 'design doc', 'spec', 'prd', 'requirement'],
aliases: ['plan', '计划', '规划', 'spec', 'prd', '需求', 'architecture', '架构']
},
'research': {
triggers: ['research', 'investigate', 'discovery', 'literature', 'study', 'explore'],
aliases: ['research', '研究', '调研', 'investigate', 'explore', '探索']
},
'analytics': {
triggers: ['analytics', 'metrics', 'data analysis', 'statistics', 'performance report', 'kpi'],
aliases: ['analytics', '分析', 'metrics', '数据', 'kpi', '报表', 'data', '统计']
},
'social-media': {
triggers: ['social media', 'twitter', 'instagram', 'facebook', 'tiktok', 'influencer', 'brand'],
aliases: ['social', '社交', '社媒', 'twitter', 'instagram', 'tiktok', '小红书', '抖音']
},
'automation': {
triggers: ['automat', 'workflow', 'n8n', 'zapier', 'script', 'cron', 'schedule'],
aliases: ['automation', '自动化', 'workflow', 'script', '脚本', 'cron', '定时']
},
'browser': {
triggers: ['chrome', 'browser', 'playwright', 'puppeteer', 'selenium', 'web automation'],
aliases: ['chrome', '浏览器', 'browser', 'playwright', '网页自动化']
},
'mcp-server': {
triggers: ['mcp', 'model context protocol', 'mcp server', 'mcp-'],
aliases: ['mcp', '协议', 'server', 'integration']
},
'ai-agent': {
triggers: ['agent', 'multi-agent', 'autonomous', 'crew', 'langgraph', 'autogen'],
aliases: ['agent', '智能体', 'autonomous', '多agent', 'crew']
},
'prompt-engineering': {
triggers: ['prompt', 'system prompt', 'instruction', 'cursorrule', '.cursorrules'],
aliases: ['prompt', '提示词', 'system prompt', 'instruction']
},
'finance': {
triggers: ['finance', 'accounting', 'journal entry', 'reconciliation', 'sox', 'income statement', 'variance'],
aliases: ['finance', '财务', '会计', 'accounting', 'journal', '对账']
},
'legal': {
triggers: ['legal', 'contract', 'nda', 'compliance', 'gdpr', 'privacy'],
aliases: ['legal', '法务', 'contract', '合同', 'nda', 'compliance', '合规']
},
'customer-support': {
triggers: ['support', 'ticket', 'triage', 'escalat', 'knowledge base', 'kb article'],
aliases: ['support', '客服', 'ticket', '工单', 'triage', 'escalation']
},
'product-management': {
triggers: ['product', 'prd', 'feature spec', 'roadmap', 'stakeholder', 'user research'],
aliases: ['product', '产品', 'prd', 'feature', 'roadmap', 'stakeholder']
},
'documentation': {
triggers: ['documentation', 'docs', 'technical writing', 'readme', 'api doc'],
aliases: ['docs', '文档', 'documentation', 'readme', '写文档']
},
'bio-research': {
triggers: ['bio', 'clinical', 'preprint', 'chembl', 'drug', 'genomic', 'single-cell', 'rna'],
aliases: ['bio', '生物', 'clinical', '临床', 'drug', '药物', 'genomic']
},
'trading': {
triggers: ['trade', 'trading', 'prediction', 'market', 'forecast', 'macro'],
aliases: ['trade', '交易', 'trading', 'market', '预测', 'macro', '宏观']
},
'health': {
triggers: ['body', 'health', 'track', 'fitness', 'weight', 'diet'],
aliases: ['health', '健康', 'body', '身体', 'fitness', '体重', '饮食']
},
'art-design': {
triggers: ['art', 'design', 'creative', 'visual', 'illustration', 'canvas', 'algorithmic'],
aliases: ['art', '艺术', 'design', '设计', 'creative', '创意', 'visual']
},
'promotion': {
triggers: ['promot', 'marketing', 'growth', 'lead gen', 'campaign', 'nurture'],
aliases: ['promote', '推广', 'marketing', '营销', 'growth', 'campaign', '活动']
},
'security': {
triggers: ['security', 'secure', 'threat', 'vulnerability', 'pentest'],
aliases: ['security', '安全', 'secure', 'threat', '漏洞']
}
};
function extractFrontmatter(content) {
const m = content.match(/^---\n([\s\S]*?)\n---/);
if (!m) return {};
const fm = m[1];
const name = fm.match(/^name:\s*["']?(.+?)["']?\s*$/m);
const desc = fm.match(/^description:\s*["']?([\s\S]*?)["']?\s*$/m);
let d = desc ? desc[1].trim().replace(/["']$/, '').trim() : '';
if (d.length > 300) d = d.substring(0, 300);
return { name: name ? name[1].trim() : null, desc: d };
}
// Classify asset into intents
function classifyIntents(name, desc) {
const text = `${name || ''} ${desc || ''}`.toLowerCase();
const intents = [];
for (const [intent, { triggers }] of Object.entries(INTENT_TAXONOMY)) {
for (const t of triggers) {
if (text.includes(t)) {
intents.push(intent);
break;
}
}
}
return intents.length > 0 ? intents : ['general'];
}
function buildKeywords(name, desc) {
const stop = new Set(['a','an','the','is','are','was','were','be','been','being',
'have','has','had','do','does','did','will','would','shall','should','may',
'might','must','can','could','this','that','these','those','it','its',
'for','and','or','but','in','on','at','to','of','with','by','from','as',
'into','through','during','before','after','above','below','between',
'use','when','user','wants','any','all','time','also','not','do','if',
'about','using','used','such','like','including','includes','your','you',
'they','them','their','more','most','other','than','then','just','only',
'very','too','so','up','out','no','each','every','both','few','own','same',
'how','what','which','who','where','why','need','needs','based','via',
'tool','tools','skill','command','server','create','run','trigger',
'should','provide','provides','available','specific','type']);
const text = `${name || ''} ${desc || ''}`.toLowerCase();
const words = text.match(/[a-z][a-z0-9-]*[a-z0-9]/g) || [];
return [...new Set(words.filter(w => !stop.has(w) && w.length >= 3))];
}
// ── Bilingual keyword expansion via Claude Code CLI ─────────────
// Uses `claude -p` (print mode) to generate Chinese keywords for
// English assets. Zero config — every CC user has the CLI available.
const { execSync } = require('child_process');
function findClaudeCLI() {
// execSync doesn't see shell aliases, so resolve the real binary path
const candidates = [
path.join(HOME, '.npm-global/bin/claude'),
'/usr/local/bin/claude',
'/opt/homebrew/bin/claude',
];
for (const p of candidates) {
if (fs.existsSync(p)) return p;
}
// Fallback: ask shell to resolve
try {
return execSync('command -v claude', { encoding: 'utf8', stdio: ['pipe','pipe','ignore'] }).trim();
} catch { return null; }
}
function expandBilingualKeywords(assets) {
if (process.env.CC_SKIP_BILINGUAL === '1') return;
const claudeBin = findClaudeCLI();
if (!claudeBin) {
console.log(' \x1b[33m⚠\x1b[0m claude CLI not found. Skipping bilingual keywords.');
return;
}
const nonHook = assets.filter(a => a.type !== 'hook');
if (nonHook.length === 0) return;
console.log(` Generating bilingual keywords for ${nonHook.length} assets via Claude CLI...`);
const BATCH = 40;
let expanded = 0;
for (let i = 0; i < nonHook.length; i += BATCH) {
const batch = nonHook.slice(i, i + BATCH);
const toolList = batch.map((a, idx) =>
`${idx + 1}. ${a.name}: ${(a.desc || '').substring(0, 100)}`
).join('\n');
const prompt = `For each AI tool below, generate 3-8 Chinese search keywords a Chinese-speaking developer would type to find it. Output ONLY a JSON object mapping the exact tool name to an array of Chinese keywords. No markdown, no explanation, no code fences.
Tools:
${toolList}`;
try {
const raw = execSync(
`${JSON.stringify(claudeBin)} -p --model haiku`,
{ input: prompt, encoding: 'utf8', timeout: 90000, stdio: ['pipe', 'pipe', 'ignore'] }
);
const jsonMatch = raw.match(/\{[\s\S]*\}/);
if (!jsonMatch) continue;
const zhMap = JSON.parse(jsonMatch[0]);
for (const asset of batch) {
const zhList = zhMap[asset.name];
if (!Array.isArray(zhList)) continue;
const extra = [];
for (const kw of zhList) {
const lower = kw.toLowerCase();
extra.push(lower);
// Generate CJK bigrams so asset-suggest tokenizer can match
const cjk = lower.match(/[\u4e00-\u9fff]+/g) || [];
for (const seg of cjk) {
for (let j = 0; j < seg.length - 1; j++) extra.push(seg.slice(j, j + 2));
}
}
asset.keywords = [...new Set([...asset.keywords, ...extra])];
expanded++;
}
console.log(` \x1b[32m✓\x1b[0m Bilingual keywords: batch ${Math.floor(i / BATCH) + 1} (${batch.length} assets)`);
} catch (e) {
console.log(` \x1b[33m⚠\x1b[0m Claude CLI call failed for batch ${Math.floor(i / BATCH) + 1}: ${e.message.split('\n')[0]}`);
}
}
if (expanded > 0) {
console.log(` \x1b[32m✓\x1b[0m Bilingual keywords added for ${expanded}/${nonHook.length} assets`);
}
}
// ── Main ────────────────────────────────────────────────────────
function main() {
const assets = [];
// ── Scan all asset sources ───────────────────────────────────────
// 1. Skills
const skillsDir = path.join(CLAUDE_DIR, 'skills');
if (fs.existsSync(skillsDir)) {
for (const d of fs.readdirSync(skillsDir)) {
const fp = path.join(skillsDir, d, 'SKILL.md');
if (!fs.existsSync(fp)) continue;
const { name, desc } = extractFrontmatter(fs.readFileSync(fp, 'utf8'));
if (!name) continue;
assets.push({ type: 'skill', name, invoke: `/${name}`, desc,
intents: classifyIntents(name, desc), keywords: buildKeywords(name, desc) });
}
}
// 2. Agents
const agentsDir = path.join(CLAUDE_DIR, 'agents');
if (fs.existsSync(agentsDir)) {
for (const f of fs.readdirSync(agentsDir).filter(f => f.endsWith('.md'))) {
const { name, desc } = extractFrontmatter(fs.readFileSync(path.join(agentsDir, f), 'utf8'));
if (!name) continue;
assets.push({ type: 'agent', name, invoke: `Agent("${name}")`, desc,
intents: classifyIntents(name, desc), keywords: buildKeywords(name, desc) });
}
}
// 3. Commands
const cmdsDir = path.join(CLAUDE_DIR, 'commands');
if (fs.existsSync(cmdsDir)) {
for (const f of fs.readdirSync(cmdsDir).filter(f => f.endsWith('.md'))) {
const { name, desc } = extractFrontmatter(fs.readFileSync(path.join(cmdsDir, f), 'utf8'));
if (!name) continue;
assets.push({ type: 'command', name, invoke: `/${name}`, desc,
intents: classifyIntents(name, desc), keywords: buildKeywords(name, desc) });
}
}
// 4. Plugin Skills & Commands
const pluginsDir = path.join(CLAUDE_DIR, 'plugins');
if (fs.existsSync(pluginsDir)) {
for (const sub of ['cache', 'marketplaces']) {
const baseDir = path.join(pluginsDir, sub);
if (!fs.existsSync(baseDir)) continue;
const walk = (dir, depth) => {
if (depth > 6) return;
try {
for (const f of fs.readdirSync(dir)) {
const fp = path.join(dir, f);
if (fs.statSync(fp).isDirectory()) { walk(fp, depth + 1); continue; }
if (!f.endsWith('.md') || !(dir.includes('/commands') || dir.includes('/skills/'))) continue;
const isSkillFile = f === 'SKILL.md';
let { name: cmdName, desc } = extractFrontmatter(fs.readFileSync(fp, 'utf8'));
if (!cmdName) cmdName = isSkillFile ? path.basename(path.dirname(fp)) : f.replace(/\.md$/, '');
if (!cmdName || cmdName === 'commands' || cmdName === 'skills') continue;
const parts = fp.split('/');
let pluginName = 'plugin';
const anchor = isSkillFile ? 'skills' : 'commands';
const anchorIdx = parts.lastIndexOf(anchor);
if (anchorIdx >= 1) {
for (let i = anchorIdx - 1; i >= 0; i--) {
if (!/^\d+\.\d+/.test(parts[i]) && parts[i] !== sub) { pluginName = parts[i]; break; }
}
}
const fullName = `${pluginName}:${cmdName}`;
if (assets.some(a => a.name === fullName || a.name === cmdName)) continue;
assets.push({ type: 'plugin', name: fullName, invoke: `/${fullName}`, desc: desc || '',
intents: classifyIntents(fullName, desc), keywords: buildKeywords(fullName, desc) });
}
} catch (e) {}
};
walk(baseDir, 0);
}
}
// 5. MCP Servers
for (const mcpPath of [path.join(HOME, '.mcp.json'), path.join(CLAUDE_DIR, '.mcp.json')]) {
if (!fs.existsSync(mcpPath)) continue;
try {
const servers = JSON.parse(fs.readFileSync(mcpPath, 'utf8')).mcpServers || {};
for (const [name, config] of Object.entries(servers)) {
const desc = `MCP: ${name} (${config.command || ''})`;
assets.push({ type: 'mcp', name, invoke: `mcp__${name}__*`, desc,
intents: classifyIntents(name, desc), keywords: buildKeywords(name, desc) });
}
} catch (e) {}
}
// 6. Hooks (informational, not suggested)
const hooksDir = path.join(CLAUDE_DIR, 'hooks');
if (fs.existsSync(hooksDir)) {
for (const f of fs.readdirSync(hooksDir).filter(f => f.endsWith('.js') || f.endsWith('.sh'))) {
const name = f.replace(/\.(js|sh)$/, '');
assets.push({ type: 'hook', name, invoke: '(auto)', desc: `Hook: ${name}`,
intents: ['general'], keywords: [name] });
}
}
// ── Bilingual expansion (Claude CLI) ────────────────────────────
expandBilingualKeywords(assets);
// ── Compute IDF weights ──────────────────────────────────────────
const docCount = assets.length;
const df = {}; // document frequency per keyword
for (const a of assets) {
const unique = new Set(a.keywords);
for (const kw of unique) {
df[kw] = (df[kw] || 0) + 1;
}
}
const idf = {};
for (const [kw, freq] of Object.entries(df)) {
idf[kw] = Math.log(docCount / freq);
}
// ── Write index ──────────────────────────────────────────────────
const index = {
version: 2,
built_at: new Date().toISOString(),
total: assets.length,
intent_taxonomy: Object.fromEntries(
Object.entries(INTENT_TAXONOMY).map(([k, v]) => [k, v.aliases])
),
idf,
assets
};
fs.writeFileSync(OUTPUT, JSON.stringify(index));
const types = {};
assets.forEach(a => { types[a.type] = (types[a.type] || 0) + 1; });
console.log(`Asset index v2 built: ${assets.length} assets -> ${OUTPUT}`);
for (const [t, c] of Object.entries(types)) console.log(` ${t}: ${c}`);
} // end main
main();src/asset-suggest.js
View source (157 lines)
#!/usr/bin/env node
// Asset Suggest Hook v3 (UserPromptSubmit)
// Intent matching + TF-IDF + specificity bonus + full-text search
// Writes top 3 matches to bridge file for statusline display
const fs = require('fs');
const path = require('path');
const os = require('os');
const INDEX_PATH = path.join(os.homedir(), '.claude/cache/asset-index.json');
const BRIDGE_PATH = path.join(os.tmpdir(), 'claude-asset-suggest.json');
const MAX_SUGGESTIONS = 3;
const MIN_SCORE = 5;
let input = '';
const timeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(timeout);
try {
const data = JSON.parse(input);
const userMessage = (data.message || data.prompt || '').toLowerCase();
if (userMessage.length < 3 || userMessage.startsWith('/')) process.exit(0);
if (!fs.existsSync(INDEX_PATH)) process.exit(0);
const index = JSON.parse(fs.readFileSync(INDEX_PATH, 'utf8'));
if (index.version !== 2) process.exit(0);
// ── Step 1: Tokenize ─────────────────────────────────────────
const latinWords = userMessage.match(/[a-z][a-z0-9-]*[a-z0-9]/g) || [];
const cjkChars = userMessage.match(/[\u4e00-\u9fff]+/g) || [];
const cjkTokens = [];
for (const seg of cjkChars) {
cjkTokens.push(seg);
for (let i = 0; i < seg.length - 1; i++) cjkTokens.push(seg.slice(i, i + 2));
if (seg.length >= 3) {
for (let i = 0; i < seg.length - 2; i++) cjkTokens.push(seg.slice(i, i + 3));
}
}
const queryTokens = new Set([...latinWords, ...cjkTokens]);
// ── Step 2: Detect user intents ──────────────────────────────
const userIntents = new Set();
const intentAliases = index.intent_taxonomy || {};
for (const [intent, aliases] of Object.entries(intentAliases)) {
for (const alias of aliases) {
if (queryTokens.has(alias) || userMessage.includes(alias)) {
userIntents.add(intent);
break;
}
}
}
// ── Step 3: Score each asset ─────────────────────────────────
const idf = index.idf || {};
const scored = index.assets
.filter(a => a.type !== 'hook')
.map(asset => {
let score = 0;
const assetIntents = asset.intents || [];
const intentCount = assetIntents.length || 1;
// A) Intent match with SPECIFICITY bonus
// An asset with 2 intents matching 1 = 10 * (1 / sqrt(2)) = 7.07
// An asset with 8 intents matching 1 = 10 * (1 / sqrt(8)) = 3.54
// Specialist assets rank higher than generalists
let intentOverlap = 0;
for (const ui of userIntents) {
if (assetIntents.includes(ui)) intentOverlap++;
}
if (intentOverlap > 0) {
const specificityMultiplier = 1 / Math.sqrt(intentCount);
score += intentOverlap * 10 * specificityMultiplier;
// Extra bonus for multiple intent matches (rare, high precision)
if (intentOverlap >= 2) score += intentOverlap * 5;
}
// B) TF-IDF keyword match
const assetKwSet = new Set(asset.keywords || []);
for (const qt of queryTokens) {
if (assetKwSet.has(qt)) {
score += (idf[qt] || 1) * 1.5;
}
}
// C) Direct name match — strongest precision signal
const nameLower = asset.name.toLowerCase().replace(/[:-]/g, ' ');
const nameParts = nameLower.split(/\s+/);
for (const qt of queryTokens) {
if (qt.length < 3) continue;
// Exact part match (e.g., "seo" matches "seo-audit")
if (nameParts.some(p => p === qt)) {
score += 12;
} else if (nameLower.includes(qt)) {
score += 6;
}
}
// D) Full-text description search
const descLower = (asset.desc || '').toLowerCase();
for (const qt of queryTokens) {
if (qt.length < 3) continue;
// Count occurrences for density signal
const regex = new RegExp(qt.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'g');
const matches = descLower.match(regex);
if (matches) {
score += Math.min(matches.length * 0.8, 3); // cap at 3
}
}
// E) Penalize assets with no intent overlap when user has clear intents
if (userIntents.size > 0 && intentOverlap === 0) {
score *= 0.2;
}
// F) Dedup penalty: if asset type is 'plugin' and a skill/command
// with same base name exists, slightly prefer the non-plugin
if (asset.type === 'plugin') {
score *= 0.95;
}
return { name: asset.name, type: asset.type, invoke: asset.invoke,
desc: asset.desc, score };
})
.filter(a => a.score >= MIN_SCORE)
.sort((a, b) => b.score - a.score);
// ── Step 4: Diversity filter — no more than 2 from same namespace ──
const result = [];
const nsCounts = {};
for (const a of scored) {
const ns = a.name.split(':')[0] || a.name.split('-')[0];
nsCounts[ns] = (nsCounts[ns] || 0) + 1;
if (nsCounts[ns] <= 2) result.push(a);
if (result.length >= MAX_SUGGESTIONS) break;
}
if (result.length === 0) {
try { fs.unlinkSync(BRIDGE_PATH); } catch (e) {}
process.exit(0);
}
fs.writeFileSync(BRIDGE_PATH, JSON.stringify({
ts: Date.now(),
top: result.map(a => ({
type: a.type, name: a.name, invoke: a.invoke,
desc: (a.desc || '').length > 60 ? (a.desc || '').substring(0, 57) + '...' : (a.desc || ''),
score: Math.round(a.score * 10) / 10
}))
}));
} catch (e) {
process.exit(0);
}
});src/statusline.js
View source (84 lines)
#!/usr/bin/env node
// CC Status Board — Statusline
// Shows: model │ directory │ context bar │ asset suggestions
const fs = require('fs');
const path = require('path');
const os = require('os');
let input = '';
const stdinTimeout = setTimeout(() => process.exit(0), 3000);
process.stdin.setEncoding('utf8');
process.stdin.on('data', chunk => input += chunk);
process.stdin.on('end', () => {
clearTimeout(stdinTimeout);
try {
const data = JSON.parse(input);
const model = data.model?.display_name || 'Claude';
const dir = data.workspace?.current_dir || process.cwd();
const remaining = data.context_window?.remaining_percentage;
const ctxSize = data.context_window?.context_window_size;
// ── Context meter ────────────────────────────────────────────
// Claude Code reserves ~16.5% for autocompact buffer.
// We normalize so 100% = point where autocompact triggers.
const AUTO_COMPACT_BUFFER_PCT = 16.5;
let ctx = '';
if (remaining != null) {
const usableRemaining = Math.max(0,
((remaining - AUTO_COMPACT_BUFFER_PCT) / (100 - AUTO_COMPACT_BUFFER_PCT)) * 100);
const used = Math.max(0, Math.min(100, Math.round(100 - usableRemaining)));
const filled = Math.floor(used / 10);
const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
// Smart tips based on context usage
let tip = '';
if (used >= 90) {
tip = ' \x1b[5;31m⚠ /compact now or start new chat\x1b[0m';
} else if (used >= 75) {
tip = ' \x1b[38;5;208m→ /compact recommended\x1b[0m';
} else if (used >= 60) {
tip = ' \x1b[33m→ wrap up or /compact soon\x1b[0m';
} else if (used >= 40) {
tip = ' \x1b[2m→ /commit before context fills\x1b[0m';
}
if (used < 50) {
ctx = ` \x1b[32m${bar} ${used}%\x1b[0m${tip}`;
} else if (used < 65) {
ctx = ` \x1b[33m${bar} ${used}%\x1b[0m${tip}`;
} else if (used < 80) {
ctx = ` \x1b[38;5;208m${bar} ${used}%\x1b[0m${tip}`;
} else {
ctx = ` \x1b[5;31m${bar} ${used}%\x1b[0m${tip}`;
}
}
// ── Model label with context size ────────────────────────────
let modelLabel = model;
if (ctxSize) {
const sizeLabel = ctxSize >= 1000000 ? `${Math.round(ctxSize / 1000000)}M` : `${Math.round(ctxSize / 1000)}K`;
modelLabel = `${model} (${sizeLabel} context)`;
}
// ── Asset suggestion from bridge file ────────────────────────
let assetHint = '';
try {
const bridgePath = path.join(os.tmpdir(), 'claude-asset-suggest.json');
if (fs.existsSync(bridgePath)) {
const bridge = JSON.parse(fs.readFileSync(bridgePath, 'utf8'));
if (Date.now() - bridge.ts < 30000 && bridge.top && bridge.top.length > 0) {
const names = bridge.top.slice(0, 3).map(a => a.invoke).join(' ');
assetHint = ` \x1b[2m\u2502\x1b[0m \x1b[36m\ud83d\udca1 ${names}\x1b[0m`;
}
}
} catch (e) {}
// ── Output ───────────────────────────────────────────────────
const dirname = path.basename(dir);
process.stdout.write(
`\x1b[2m${modelLabel}\x1b[0m \u2502 \x1b[2m${dirname}\x1b[0m${ctx}${assetHint}`
);
} catch (e) {}
});package.json
View source (35 lines)
{
"name": "cc-status-board",
"version": "1.1.0",
"description": "Smart status bar for Claude Code — context meter, AI asset discovery, and session info at a glance",
"main": "install.js",
"bin": {
"cc-status-board": "install.js"
},
"scripts": {
"install-global": "node install.js",
"build-index": "node src/build-index.js",
"test": "node test/test.js"
},
"keywords": [
"claude-code",
"statusline",
"ai-assets",
"context-window",
"developer-tools",
"claude",
"mcp",
"skills",
"hooks"
],
"author": "William Wang <williamwangai@gmail.com>",
"license": "MIT",
"repository": {
"type": "git",
"url": "git+https://github.com/WilliamWangAI/cc-status-board.git"
},
"homepage": "https://github.com/WilliamWangAI/cc-status-board",
"engines": {
"node": ">=18.0.0"
}
}CC Status Board
Smart status bar for Claude Code — context meter, AI asset discovery, and session info at a glance.
Install from TokRepo — the original publishing platform for AI assets.
What It Does
CC Status Board adds three features to your Claude Code status bar:
1. Context Meter
See how much of your context window you've used — at a glance. Color-coded: green (< 50%) → yellow (50-65%) → orange (65-80%) → blinking red (80%+).
2. AI Asset Discovery
As you type, the most relevant installed AI assets appear in your status bar. No extra tokens consumed — matching runs 100% locally using intent classification + TF-IDF scoring.
Scans all your installed assets:
- Skills (
~/.claude/skills/) - Agents (
~/.claude/agents/) - Commands (
~/.claude/commands/) - Plugins (Claude Code built-in plugins)
- MCP Servers (
.mcp.json)
Smart matching (not just keyword search):
- 35+ intent categories with CJK + English aliases
- Bilingual keywords — LLM generates Chinese keywords for English assets at index time, so Chinese queries match English tools
- TF-IDF weighting — rare keywords matter more
- Specificity bonus — specialist tools rank above generalists
- Diversity filter — no more than 2 results from the same namespace
3. Model & Directory Info
Always shows your current model, context window size, and working directory.
Opus 4.6 (1M context) │ myproject █░░░░░░░░░ 12%
Sonnet 4.6 (200K context) │ webapp ███░░░░░░░ 28%Install
Recommended: Install from TokRepo for one-click setup.
Or install from source:
git clone https://github.com/henu-wang/cc-status-board.git
cd cc-status-board
node install.jsThen restart Claude Code.
How It Works
┌─────────────────────────────────────────────────┐
│ You type a message │
│ │ │
│ ▼ │
│ UserPromptSubmit hook │
│ → Tokenize (CJK bigrams + Latin words) │
│ → Detect intents (35+ categories) │
│ → Score assets (TF-IDF + specificity) │
│ → Write top 3 to temp bridge file │
│ │ │
│ ▼ │
│ Statusline reads bridge file │
│ → Display: model │ dir │ context% │ 💡 assets │
└─────────────────────────────────────────────────┘Zero token consumption. Everything runs as local Node.js processes.
Rebuild Asset Index
When you install new skills, MCP servers, or plugins:
node ~/.claude/hooks/cc-build-index.jsUninstall
Remove from ~/.claude/settings.json:
- Delete the
statusLineentry - Remove the
cc-asset-suggestentry fromhooks.UserPromptSubmit - Delete the hook files:
rm ~/.claude/hooks/cc-statusline.js
rm ~/.claude/hooks/cc-asset-suggest.js
rm ~/.claude/hooks/cc-build-index.js
rm ~/.claude/cache/asset-index.jsonBilingual Matching (v1.1)
Asset names and descriptions are typically in English. When you type in Chinese, only intent detection would match — keyword and description scoring paths wouldn't fire.
v1.1 fixes this automatically. During index build, the claude CLI generates Chinese keywords for every asset. No API key needed — it uses the same Claude that powers your Claude Code session.
node ~/.claude/hooks/cc-build-index.js # rebuilds with bilingual keywordsThe Chinese keywords are baked into the local index — zero extra cost at query time.
Configuration
The status bar works out of the box. To customize:
| What | How |
|---|---|
| Rebuild bilingual index | node ~/.claude/hooks/cc-build-index.js |
| Change bar width | Edit statusline.js, change Math.floor(used / 10) denominator |
| Add intent categories | Edit build-index.js, add to INTENT_TAXONOMY |
| Change max suggestions | Edit asset-suggest.js, change MAX_SUGGESTIONS |
| Adjust match threshold | Edit asset-suggest.js, change MIN_SCORE |
Requirements
- Claude Code (CLI, desktop, or IDE extension)
- Node.js >= 18
License
MIT
Acknowledgments
Inspired by Get Shit Done (GSD) — the statusline hook architecture and context monitoring patterns originated from this project. If you're building non-trivial projects with Claude Code, GSD is a game changer.
Author
Built by William Wang — founder of TokRepo, GEOScore AI, and KeepRule.