@viren/claude-code-dashboard 0.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.
@@ -0,0 +1,262 @@
1
+ import { readdirSync, readFileSync } from "fs";
2
+ import { join } from "path";
3
+ import { STACK_FILES } from "./constants.mjs";
4
+ import { gitCmd } from "./helpers.mjs";
5
+
6
+ export function computeHealthScore(repo) {
7
+ let score = 0;
8
+ const reasons = [];
9
+
10
+ // Has AGENTS.md/CLAUDE.md (30 points)
11
+ if (repo.hasAgentsFile) {
12
+ score += 30;
13
+ } else {
14
+ reasons.push("add CLAUDE.md");
15
+ }
16
+
17
+ // Has description (10 points)
18
+ if (repo.desc && repo.desc.length > 0) {
19
+ score += 10;
20
+ } else {
21
+ reasons.push("add project description");
22
+ }
23
+
24
+ // Has commands (20 points)
25
+ if (repo.commandCount > 0) {
26
+ score += Math.min(20, repo.commandCount * 10);
27
+ } else {
28
+ reasons.push("add commands");
29
+ }
30
+
31
+ // Has rules (20 points)
32
+ if (repo.ruleCount > 0) {
33
+ score += Math.min(20, repo.ruleCount * 10);
34
+ } else {
35
+ reasons.push("add rules");
36
+ }
37
+
38
+ // Has sections / structured config (10 points)
39
+ if (repo.sectionCount > 0) {
40
+ score += Math.min(10, repo.sectionCount * 2);
41
+ } else {
42
+ reasons.push("add structured sections");
43
+ }
44
+
45
+ // Freshness (10 points)
46
+ if (repo.freshnessClass === "fresh") {
47
+ score += 10;
48
+ } else if (repo.freshnessClass === "aging") {
49
+ score += 5;
50
+ reasons.push("update config (aging)");
51
+ } else {
52
+ reasons.push("update config (stale)");
53
+ }
54
+
55
+ return { score: Math.min(100, score), reasons };
56
+ }
57
+
58
+ export function detectTechStack(repoDir) {
59
+ const stacks = new Set();
60
+
61
+ try {
62
+ const entries = new Set(readdirSync(repoDir));
63
+
64
+ for (const [file, stack] of Object.entries(STACK_FILES)) {
65
+ if (entries.has(file)) stacks.add(stack);
66
+ }
67
+
68
+ if (entries.has("package.json")) {
69
+ // Check for React/Expo in package.json if no framework detected yet
70
+ if (!stacks.has("next") && !stacks.has("expo")) {
71
+ try {
72
+ const pkg = JSON.parse(readFileSync(join(repoDir, "package.json"), "utf8"));
73
+ const allDeps = {
74
+ ...(pkg.dependencies || {}),
75
+ ...(pkg.devDependencies || {}),
76
+ };
77
+ if (allDeps["expo"]) stacks.add("expo");
78
+ else if (allDeps["next"]) stacks.add("next");
79
+ else if (allDeps["react"]) stacks.add("react");
80
+ } catch {
81
+ /* malformed package.json */
82
+ }
83
+ }
84
+ if (stacks.size === 0) stacks.add("node");
85
+ }
86
+ } catch {
87
+ /* unreadable dir */
88
+ }
89
+
90
+ return { stacks: [...stacks] };
91
+ }
92
+
93
+ export function computeDrift(repoDir, configTimestamp) {
94
+ if (!configTimestamp) return { level: "unknown", commitsSince: 0 };
95
+
96
+ // Count commits since the config was last updated
97
+ const countStr = gitCmd(repoDir, "rev-list", "--count", `--since=${configTimestamp}`, "HEAD");
98
+ if (!countStr) return { level: "unknown", commitsSince: 0 };
99
+
100
+ const parsed = Number(countStr);
101
+ if (!Number.isFinite(parsed)) return { level: "unknown", commitsSince: 0 };
102
+
103
+ const commitsSince = Math.max(0, parsed - 1); // -1 to exclude the config commit itself
104
+
105
+ if (commitsSince === 0) return { level: "synced", commitsSince: 0 };
106
+ if (commitsSince <= 5) return { level: "low", commitsSince };
107
+ if (commitsSince <= 20) return { level: "medium", commitsSince };
108
+ return { level: "high", commitsSince };
109
+ }
110
+
111
+ export function findExemplar(stack, configuredRepos) {
112
+ if (!stack || stack.length === 0) return null;
113
+ let best = null;
114
+ let bestScore = -1;
115
+ for (const repo of configuredRepos) {
116
+ const repoStacks = repo.techStack || [];
117
+ const overlap = stack.filter((s) => repoStacks.includes(s)).length;
118
+ const score = overlap * 100 + (repo.healthScore || 0);
119
+ if (overlap > 0 && score > bestScore) {
120
+ bestScore = score;
121
+ best = repo;
122
+ }
123
+ }
124
+ return best;
125
+ }
126
+
127
+ export function generateSuggestions(exemplar) {
128
+ if (!exemplar) return [];
129
+ const suggestions = [];
130
+ if (exemplar.hasAgentsFile) suggestions.push("add CLAUDE.md");
131
+ if (exemplar.commands?.length > 0)
132
+ suggestions.push(`add commands (${exemplar.name} has ${exemplar.commands.length})`);
133
+ if (exemplar.rules?.length > 0)
134
+ suggestions.push(`add rules (${exemplar.name} has ${exemplar.rules.length})`);
135
+ return suggestions;
136
+ }
137
+
138
+ export function detectConfigPattern(repo) {
139
+ if (repo.rules.length >= 3) return "modular";
140
+ if (repo.sections.length >= 3) return "monolithic";
141
+ if (repo.commands.length >= 2 && repo.sections.length === 0) return "command-heavy";
142
+ return "minimal";
143
+ }
144
+
145
+ export function computeConfigSimilarity(repoA, repoB) {
146
+ if (!repoA || !repoB) return 0;
147
+ let matches = 0;
148
+ let total = 0;
149
+
150
+ // Section name overlap (Jaccard similarity)
151
+ const sectionsA = new Set((repoA.sections || []).map((s) => s.name || s));
152
+ const sectionsB = new Set((repoB.sections || []).map((s) => s.name || s));
153
+ if (sectionsA.size > 0 || sectionsB.size > 0) {
154
+ const intersection = [...sectionsA].filter((s) => sectionsB.has(s)).length;
155
+ const union = new Set([...sectionsA, ...sectionsB]).size;
156
+ matches += intersection;
157
+ total += union;
158
+ }
159
+
160
+ // Stack overlap
161
+ const stackA = new Set(repoA.techStack || []);
162
+ const stackB = new Set(repoB.techStack || []);
163
+ if (stackA.size > 0 || stackB.size > 0) {
164
+ const intersection = [...stackA].filter((s) => stackB.has(s)).length;
165
+ const union = new Set([...stackA, ...stackB]).size;
166
+ matches += intersection;
167
+ total += union;
168
+ }
169
+
170
+ // Same config pattern bonus
171
+ if (repoA.configPattern && repoA.configPattern === repoB.configPattern) {
172
+ matches += 1;
173
+ total += 1;
174
+ } else {
175
+ total += 1;
176
+ }
177
+
178
+ return total > 0 ? Math.round((matches / total) * 100) : 0;
179
+ }
180
+
181
+ export function matchSkillsToRepo(repo, skills) {
182
+ if (!repo || !skills || skills.length === 0) return [];
183
+ const repoTokens = new Set();
184
+ for (const word of repo.name.toLowerCase().split(/[-_./]/)) {
185
+ if (word.length > 1) repoTokens.add(word);
186
+ }
187
+ for (const s of repo.techStack || []) {
188
+ repoTokens.add(s.toLowerCase());
189
+ }
190
+ for (const sec of repo.sections || []) {
191
+ const name = (sec.name || sec || "").toLowerCase();
192
+ for (const word of name.split(/\s+/)) {
193
+ if (word.length > 2) repoTokens.add(word);
194
+ }
195
+ }
196
+ const matched = [];
197
+ for (const skill of skills) {
198
+ const skillTokens = skill.name
199
+ .toLowerCase()
200
+ .split(/[-_./]/)
201
+ .filter((t) => t.length > 1);
202
+ const hits = skillTokens.filter((t) => repoTokens.has(t)).length;
203
+ if (hits > 0) matched.push({ name: skill.name, relevance: hits });
204
+ }
205
+ return matched.sort((a, b) => b.relevance - a.relevance).slice(0, 5);
206
+ }
207
+
208
+ export function lintConfig(repo) {
209
+ const issues = [];
210
+ if (repo.sections) {
211
+ for (const sec of repo.sections) {
212
+ const name = sec.name || sec || "";
213
+ if (/TODO|FIXME|HACK/i.test(name)) {
214
+ issues.push({ level: "warn", message: `Section "${name}" contains TODO/FIXME marker` });
215
+ }
216
+ }
217
+ }
218
+ if (!repo.hasAgentsFile && (repo.commands.length > 0 || repo.rules.length > 0)) {
219
+ issues.push({ level: "info", message: "Has commands/rules but no CLAUDE.md" });
220
+ }
221
+ if (
222
+ repo.hasAgentsFile &&
223
+ repo.commands.length === 0 &&
224
+ repo.rules.length === 0 &&
225
+ (repo.sections || []).length === 0
226
+ ) {
227
+ issues.push({
228
+ level: "warn",
229
+ message: "CLAUDE.md exists but has no commands, rules, or sections",
230
+ });
231
+ }
232
+ return issues;
233
+ }
234
+
235
+ export function computeDashboardDiff(prev, current) {
236
+ const diff = { added: [], removed: [], changed: [] };
237
+ if (!prev || !current) return diff;
238
+ const prevNames = new Set((prev.configuredRepos || []).map((r) => r.name));
239
+ const currNames = new Set((current.configuredRepos || []).map((r) => r.name));
240
+ for (const name of currNames) {
241
+ if (!prevNames.has(name)) diff.added.push(name);
242
+ }
243
+ for (const name of prevNames) {
244
+ if (!currNames.has(name)) diff.removed.push(name);
245
+ }
246
+ const prevMap = Object.fromEntries((prev.configuredRepos || []).map((r) => [r.name, r]));
247
+ const currMap = Object.fromEntries((current.configuredRepos || []).map((r) => [r.name, r]));
248
+ for (const name of currNames) {
249
+ if (
250
+ prevNames.has(name) &&
251
+ (prevMap[name].healthScore || 0) !== (currMap[name].healthScore || 0)
252
+ ) {
253
+ diff.changed.push({
254
+ name,
255
+ field: "healthScore",
256
+ from: prevMap[name].healthScore || 0,
257
+ to: currMap[name].healthScore || 0,
258
+ });
259
+ }
260
+ }
261
+ return diff;
262
+ }
package/src/cli.mjs ADDED
@@ -0,0 +1,135 @@
1
+ import { VERSION, DEFAULT_OUTPUT, HOME } from "./constants.mjs";
2
+
3
+ export function parseArgs(argv) {
4
+ const args = {
5
+ output: DEFAULT_OUTPUT,
6
+ open: false,
7
+ json: false,
8
+ catalog: false,
9
+ command: null,
10
+ template: null,
11
+ dryRun: false,
12
+ quiet: false,
13
+ watch: false,
14
+ diff: false,
15
+ anonymize: false,
16
+ completions: false,
17
+ };
18
+ let i = 2; // skip node + script
19
+ if (argv[2] === "init") {
20
+ args.command = "init";
21
+ i = 3;
22
+ } else if (argv[2] === "lint") {
23
+ args.command = "lint";
24
+ i = 3;
25
+ }
26
+ while (i < argv.length) {
27
+ switch (argv[i]) {
28
+ case "--help":
29
+ case "-h":
30
+ console.log(`claude-code-dashboard v${VERSION}
31
+
32
+ Scans your home directory for git repos with Claude Code configuration
33
+ and generates a self-contained HTML dashboard.
34
+
35
+ Usage:
36
+ claude-code-dashboard [options]
37
+
38
+ Options:
39
+ --output, -o <path> Output path (default: ~/.claude/dashboard.html)
40
+ --json Output full data model as JSON instead of HTML
41
+ --catalog Generate a shareable skill catalog HTML page
42
+ --open Open the dashboard in your default browser after generating
43
+ --quiet Suppress output, just write file
44
+ --watch Regenerate on file changes
45
+ --diff Show changes since last generation
46
+ --anonymize Anonymize paths for shareable export
47
+ --completions Output shell completion script for bash/zsh
48
+ --version, -v Show version
49
+ --help, -h Show this help
50
+
51
+ Subcommands:
52
+ init Scaffold Claude Code config for current directory
53
+ --template, -t <stack> Override auto-detected stack (next, react, python, etc.)
54
+ --dry-run Preview what would be created without writing files
55
+ lint Check all repos for config issues
56
+
57
+ Config file: ~/.claude/dashboard.conf
58
+ Add directories (one per line) to restrict scanning scope.
59
+ Define dependency chains: chain: A -> B -> C
60
+ Lines starting with # are comments.`);
61
+ process.exit(0);
62
+ case "--version":
63
+ case "-v":
64
+ console.log(VERSION);
65
+ process.exit(0);
66
+ case "--output":
67
+ case "-o":
68
+ args.output = argv[++i];
69
+ if (!args.output) {
70
+ console.error("Error: --output requires a path argument");
71
+ process.exit(1);
72
+ }
73
+ // Expand ~ at the start of the path
74
+ if (args.output.startsWith("~")) {
75
+ args.output = args.output.replace(/^~/, HOME);
76
+ }
77
+ break;
78
+ case "--json":
79
+ args.json = true;
80
+ break;
81
+ case "--catalog":
82
+ args.catalog = true;
83
+ break;
84
+ case "--open":
85
+ args.open = true;
86
+ break;
87
+ case "--template":
88
+ case "-t":
89
+ args.template = argv[++i];
90
+ if (!args.template) {
91
+ console.error("Error: --template requires a stack argument");
92
+ process.exit(1);
93
+ }
94
+ break;
95
+ case "--dry-run":
96
+ args.dryRun = true;
97
+ break;
98
+ case "--quiet":
99
+ args.quiet = true;
100
+ break;
101
+ case "--watch":
102
+ args.watch = true;
103
+ break;
104
+ case "--diff":
105
+ args.diff = true;
106
+ break;
107
+ case "--anonymize":
108
+ args.anonymize = true;
109
+ break;
110
+ case "--completions":
111
+ args.completions = true;
112
+ break;
113
+ default:
114
+ console.error(`Unknown option: ${argv[i]}\nRun with --help for usage.`);
115
+ process.exit(1);
116
+ }
117
+ i++;
118
+ }
119
+ return args;
120
+ }
121
+
122
+ export function generateCompletions() {
123
+ console.log(`# claude-code-dashboard completions
124
+ # eval "$(claude-code-dashboard --completions)"
125
+ if [ -n "$ZSH_VERSION" ]; then
126
+ _claude_code_dashboard() {
127
+ local -a opts; opts=(init lint --output --open --json --catalog --quiet --watch --diff --anonymize --completions --help --version)
128
+ if (( CURRENT == 2 )); then _describe 'option' opts; fi
129
+ }; compdef _claude_code_dashboard claude-code-dashboard
130
+ elif [ -n "$BASH_VERSION" ]; then
131
+ _claude_code_dashboard() { COMPREPLY=( $(compgen -W "init lint --output --open --json --catalog --quiet --watch --diff --anonymize --completions --help --version" -- "\${COMP_WORDS[COMP_CWORD]}") ); }
132
+ complete -F _claude_code_dashboard claude-code-dashboard
133
+ fi`);
134
+ process.exit(0);
135
+ }
@@ -0,0 +1,150 @@
1
+ import { join } from "path";
2
+ import { homedir } from "os";
3
+
4
+ export const VERSION = "0.0.1";
5
+
6
+ export const HOME = homedir();
7
+ export const CLAUDE_DIR = join(HOME, ".claude");
8
+ export const DEFAULT_OUTPUT = join(CLAUDE_DIR, "dashboard.html");
9
+ export const CONF = join(CLAUDE_DIR, "dashboard.conf");
10
+ export const MAX_DEPTH = 5;
11
+
12
+ // Freshness thresholds (seconds)
13
+ export const ONE_DAY = 86_400;
14
+ export const TWO_DAYS = 172_800;
15
+ export const THIRTY_DAYS = 2_592_000;
16
+ export const NINETY_DAYS = 7_776_000;
17
+ export const ONE_YEAR = 31_536_000;
18
+
19
+ // Directories to skip during repo discovery
20
+ export const PRUNE = new Set([
21
+ "node_modules",
22
+ ".Trash",
23
+ "Library",
24
+ ".cache",
25
+ ".npm",
26
+ ".yarn",
27
+ ".pnpm",
28
+ ".local",
29
+ ".cargo",
30
+ ".rustup",
31
+ ".gradle",
32
+ ".m2",
33
+ ".cocoapods",
34
+ ".android",
35
+ "Caches",
36
+ ".virtualenvs",
37
+ ".pyenv",
38
+ ".nvm",
39
+ ".rbenv",
40
+ ".gem",
41
+ ".docker",
42
+ ".orbstack",
43
+ "go",
44
+ "venv",
45
+ "__pycache__",
46
+ ".tox",
47
+ ".git",
48
+ ]);
49
+
50
+ // Lines matching these patterns are skipped when extracting project descriptions.
51
+ // Override by adding a YAML frontmatter `description:` field to your CLAUDE.md.
52
+ const BOILERPLATE_PATTERNS = [
53
+ "This file provides guidance",
54
+ "CLAUDE.md.*symlink",
55
+ "AGENTS.md.*should contain",
56
+ "Always-loaded guidance",
57
+ "Guidance for coding agents",
58
+ "Rules are split into focused files",
59
+ "Detailed implementation guidance lives in",
60
+ ];
61
+ export const BOILERPLATE_RE = new RegExp(BOILERPLATE_PATTERNS.join("|"));
62
+
63
+ export const STACK_FILES = {
64
+ "next.config.js": "next",
65
+ "next.config.mjs": "next",
66
+ "next.config.ts": "next",
67
+ "Cargo.toml": "rust",
68
+ "go.mod": "go",
69
+ "requirements.txt": "python",
70
+ "pyproject.toml": "python",
71
+ "setup.py": "python",
72
+ "Package.swift": "swift",
73
+ Gemfile: "ruby",
74
+ "pom.xml": "java",
75
+ "build.gradle": "java",
76
+ "build.gradle.kts": "java",
77
+ };
78
+
79
+ export const SKILL_CATEGORIES = {
80
+ workflow: ["plan", "workflow", "branch", "commit", "pr-", "review", "ship", "deploy", "execute"],
81
+ "code-quality": ["lint", "test-", "quality", "format", "refactor", "clean", "verify", "tdd"],
82
+ debugging: ["debug", "diagnose", "troubleshoot", "ci-fix", "stack-trace", "breakpoint"],
83
+ research: [
84
+ "research",
85
+ "search",
86
+ "analyze",
87
+ "explore",
88
+ "investigate",
89
+ "compare",
90
+ "competitive",
91
+ "audit",
92
+ "find",
93
+ ],
94
+ integrations: ["slack", "github", "figma", "linear", "jira", "notion", "snowflake", "api", "mcp"],
95
+ "project-specific": ["storybook", "react-native"],
96
+ };
97
+
98
+ export const CATEGORY_ORDER = [
99
+ "workflow",
100
+ "code-quality",
101
+ "debugging",
102
+ "research",
103
+ "integrations",
104
+ "project-specific",
105
+ ];
106
+
107
+ // Last verified against Claude Code v2.1.72 (March 2026)
108
+ export const QUICK_REFERENCE = {
109
+ essentialCommands: [
110
+ { cmd: "/help", desc: "Show help and available commands" },
111
+ { cmd: "/compact", desc: "Compact conversation to free context" },
112
+ { cmd: "/model", desc: "Switch AI model" },
113
+ { cmd: "/diff", desc: "Interactive diff viewer for changes" },
114
+ { cmd: "/status", desc: "Version, model, account info" },
115
+ { cmd: "/cost", desc: "Show token usage statistics" },
116
+ { cmd: "/plan", desc: "Enter plan mode for complex tasks" },
117
+ { cmd: "/config", desc: "Open settings interface" },
118
+ { cmd: "/mcp", desc: "Manage MCP server connections" },
119
+ { cmd: "/memory", desc: "Edit CLAUDE.md, toggle auto-memory" },
120
+ { cmd: "/permissions", desc: "View or update tool permissions" },
121
+ { cmd: "/init", desc: "Initialize project with CLAUDE.md" },
122
+ { cmd: "/insights", desc: "Generate usage analytics report" },
123
+ { cmd: "/export", desc: "Export conversation as plain text" },
124
+ { cmd: "/pr-comments", desc: "Fetch GitHub PR review comments" },
125
+ { cmd: "/doctor", desc: "Diagnose installation issues" },
126
+ ],
127
+ tools: [
128
+ { name: "Bash", desc: "Execute shell commands" },
129
+ { name: "Read", desc: "Read files (text, images, PDFs, notebooks)" },
130
+ { name: "Write", desc: "Create new files" },
131
+ { name: "Edit", desc: "Modify files via exact string replacement" },
132
+ { name: "Grep", desc: "Search file contents with regex" },
133
+ { name: "Glob", desc: "Find files by pattern" },
134
+ { name: "Agent", desc: "Launch specialized sub-agents" },
135
+ { name: "WebSearch", desc: "Search the web" },
136
+ { name: "WebFetch", desc: "Fetch URL content" },
137
+ { name: "LSP", desc: "Code intelligence (go-to-def, references)" },
138
+ ],
139
+ shortcuts: [
140
+ { keys: "/", desc: "Quick command search" },
141
+ { keys: "!", desc: "Bash mode (run directly)" },
142
+ { keys: "@", desc: "File path autocomplete" },
143
+ { keys: "Ctrl+C", desc: "Cancel generation" },
144
+ { keys: "Ctrl+L", desc: "Clear screen" },
145
+ { keys: "Ctrl+R", desc: "Search history" },
146
+ { keys: "Shift+Tab", desc: "Toggle permission mode" },
147
+ { keys: "Esc Esc", desc: "Rewind conversation" },
148
+ { keys: "Tab", desc: "Toggle thinking" },
149
+ ],
150
+ };
@@ -0,0 +1,46 @@
1
+ import { existsSync, readdirSync, readFileSync, statSync } from "fs";
2
+ import { join } from "path";
3
+ import { PRUNE, CONF, HOME } from "./constants.mjs";
4
+
5
+ export function findGitRepos(roots, maxDepth) {
6
+ const repos = [];
7
+ function walk(dir, depth) {
8
+ if (depth > maxDepth) return;
9
+ let entries;
10
+ try {
11
+ entries = readdirSync(dir);
12
+ } catch {
13
+ return;
14
+ }
15
+ for (const entry of entries) {
16
+ if (entry === ".git") {
17
+ repos.push(dir);
18
+ return; // don't recurse inside a git repo's subdirs
19
+ }
20
+ if (PRUNE.has(entry)) continue;
21
+ const full = join(dir, entry);
22
+ try {
23
+ if (statSync(full).isDirectory()) walk(full, depth + 1);
24
+ } catch {
25
+ /* permission denied, symlink loops, etc */
26
+ }
27
+ }
28
+ }
29
+ for (const root of roots) {
30
+ if (existsSync(root)) walk(root, 0);
31
+ }
32
+ return repos;
33
+ }
34
+
35
+ export function getScanRoots() {
36
+ if (existsSync(CONF)) {
37
+ const dirs = readFileSync(CONF, "utf8")
38
+ .split("\n")
39
+ .map((l) => l.replace(/#.*/, "").trim())
40
+ .filter((l) => l && !l.startsWith("chain:"))
41
+ .map((l) => l.replace(/^~/, HOME))
42
+ .filter((d) => existsSync(d));
43
+ if (dirs.length) return dirs;
44
+ }
45
+ return [HOME];
46
+ }
@@ -0,0 +1,35 @@
1
+ import { ONE_DAY, TWO_DAYS, THIRTY_DAYS, NINETY_DAYS, ONE_YEAR } from "./constants.mjs";
2
+ import { gitCmd } from "./helpers.mjs";
3
+
4
+ export function getFreshness(repoDir) {
5
+ const ts = gitCmd(
6
+ repoDir,
7
+ "log",
8
+ "-1",
9
+ "--format=%ct",
10
+ "--",
11
+ "CLAUDE.md",
12
+ "AGENTS.md",
13
+ ".claude/",
14
+ );
15
+ const parsed = Number(ts);
16
+ return Number.isFinite(parsed) ? parsed : 0;
17
+ }
18
+
19
+ export function relativeTime(ts) {
20
+ if (!ts) return "unknown";
21
+ const diff = Math.floor(Date.now() / 1000) - ts;
22
+ if (diff < ONE_DAY) return "today";
23
+ if (diff < TWO_DAYS) return "yesterday";
24
+ if (diff < THIRTY_DAYS) return `${Math.floor(diff / ONE_DAY)}d ago`;
25
+ if (diff < ONE_YEAR) return `${Math.floor(diff / THIRTY_DAYS)}mo ago`;
26
+ return `${Math.floor(diff / ONE_YEAR)}y ago`;
27
+ }
28
+
29
+ export function freshnessClass(ts) {
30
+ if (!ts) return "stale";
31
+ const diff = Math.floor(Date.now() / 1000) - ts;
32
+ if (diff < THIRTY_DAYS) return "fresh";
33
+ if (diff < NINETY_DAYS) return "aging";
34
+ return "stale";
35
+ }
@@ -0,0 +1,42 @@
1
+ import { execFileSync } from "child_process";
2
+ import { HOME } from "./constants.mjs";
3
+
4
+ export const esc = (s) =>
5
+ s
6
+ .replace(/&/g, "&amp;")
7
+ .replace(/</g, "&lt;")
8
+ .replace(/>/g, "&gt;")
9
+ .replace(/"/g, "&quot;")
10
+ .replace(/'/g, "&#39;");
11
+
12
+ export const shortPath = (p) => p.replace(HOME, "~");
13
+
14
+ /** Format large token counts as MM/BB shorthand. Guards against undefined/NaN. */
15
+ export function formatTokens(n) {
16
+ n = Number(n) || 0;
17
+ if (n >= 1_000_000_000) return `${(n / 1_000_000_000).toFixed(1)}B tokens`;
18
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M tokens`;
19
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K tokens`;
20
+ return `${n} tokens`;
21
+ }
22
+
23
+ /** Run a git command safely using execFileSync (no shell injection). */
24
+ export function gitCmd(repoDir, ...args) {
25
+ try {
26
+ return execFileSync("git", ["-C", repoDir, ...args], {
27
+ encoding: "utf8",
28
+ timeout: 10_000,
29
+ stdio: ["pipe", "pipe", "pipe"],
30
+ }).trim();
31
+ } catch {
32
+ return "";
33
+ }
34
+ }
35
+
36
+ export function anonymizePath(p) {
37
+ return p
38
+ .replace(/^\/Users\/[^/]+\//, "~/")
39
+ .replace(/^\/home\/[^/]+\//, "~/")
40
+ .replace(/^C:\\Users\\[^\\]+\\/, "~\\")
41
+ .replace(/^C:\/Users\/[^/]+\//, "~/");
42
+ }