@eltonssouza/development-utility-kit 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/agents/analyst.md +198 -0
- package/.claude/agents/backend-developer.md +126 -0
- package/.claude/agents/brain-keeper.md +229 -0
- package/.claude/agents/code-reviewer.md +181 -0
- package/.claude/agents/database-engineer.md +94 -0
- package/.claude/agents/devops-engineer.md +141 -0
- package/.claude/agents/frontend-developer.md +97 -0
- package/.claude/agents/gate-keeper.md +118 -0
- package/.claude/agents/migrator.md +291 -0
- package/.claude/agents/mobile-developer.md +80 -0
- package/.claude/agents/n8n-specialist.md +94 -0
- package/.claude/agents/product-owner.md +115 -0
- package/.claude/agents/qa-engineer.md +232 -0
- package/.claude/agents/release-engineer.md +204 -0
- package/.claude/agents/scaffold.md +87 -0
- package/.claude/agents/security-engineer.md +199 -0
- package/.claude/agents/sprint-runner.md +44 -0
- package/.claude/agents/stack-resolver.md +84 -0
- package/.claude/agents/tech-lead.md +182 -0
- package/.claude/agents/update-template.md +54 -0
- package/.claude/agents/ux-designer.md +118 -0
- package/.claude/settings.json +44 -0
- package/.claude/skills/README.md +332 -0
- package/.claude/skills/active-project/SKILL.md +129 -0
- package/.claude/skills/api-integration-test/SKILL.md +64 -0
- package/.claude/skills/auto-test-guard/SKILL.md +237 -0
- package/.claude/skills/auto-test-guard/resources/backend-tests.md +20 -0
- package/.claude/skills/auto-test-guard/resources/e2e-tests.md +24 -0
- package/.claude/skills/auto-test-guard/resources/execution-report.md +49 -0
- package/.claude/skills/auto-test-guard/resources/frontend-tests.md +18 -0
- package/.claude/skills/auto-test-guard/resources/initial-setup.md +108 -0
- package/.claude/skills/auto-test-guard/resources/run-suite.md +48 -0
- package/.claude/skills/auto-test-guard/resources/senior-gate.md +19 -0
- package/.claude/skills/brain-keeper/SKILL.md +60 -0
- package/.claude/skills/brain-keeper/obsidian/app.json +9 -0
- package/.claude/skills/brain-keeper/obsidian/appearance.json +4 -0
- package/.claude/skills/brain-keeper/obsidian/core-plugins.json +20 -0
- package/.claude/skills/brain-keeper/obsidian/daily-notes.json +5 -0
- package/.claude/skills/brain-keeper/obsidian/graph.json +32 -0
- package/.claude/skills/brain-keeper/obsidian/snippets/folder-colors.css +90 -0
- package/.claude/skills/brain-keeper/obsidian/templates.json +5 -0
- package/.claude/skills/brain-keeper/templates/README.md +51 -0
- package/.claude/skills/brain-keeper/templates/adr.md +40 -0
- package/.claude/skills/brain-keeper/templates/bug.md +35 -0
- package/.claude/skills/brain-keeper/templates/daily.md +38 -0
- package/.claude/skills/brain-keeper/templates/feature.md +62 -0
- package/.claude/skills/brain-keeper/templates/meeting.md +34 -0
- package/.claude/skills/brain-keeper/templates/tech-debt.md +21 -0
- package/.claude/skills/caveman/SKILL.md +187 -0
- package/.claude/skills/create-stack-pack/SKILL.md +281 -0
- package/.claude/skills/grill-me/SKILL.md +79 -0
- package/.claude/skills/honcho-memory/SKILL.md +207 -0
- package/.claude/skills/honcho-memory/docs/api-endpoints-verified.md +75 -0
- package/.claude/skills/honcho-memory/hooks/on-prompt-submit.js +221 -0
- package/.claude/skills/honcho-memory/hooks/on-stop.js +193 -0
- package/.claude/skills/honcho-memory/lib/honcho-client.js +363 -0
- package/.claude/skills/honcho-memory/lib/memory-injector.js +93 -0
- package/.claude/skills/honcho-memory/package.json +32 -0
- package/.claude/skills/honcho-memory/scripts/cli.js +370 -0
- package/.claude/skills/honcho-memory/scripts/setup.js +109 -0
- package/.claude/skills/honcho-memory/tests/t001-api-endpoints-verified.test.js +89 -0
- package/.claude/skills/honcho-memory/tests/t002-structure.test.js +97 -0
- package/.claude/skills/honcho-memory/tests/t003-honcho-client.test.js +162 -0
- package/.claude/skills/honcho-memory/tests/t004-soft-delete.test.js +259 -0
- package/.claude/skills/honcho-memory/tests/t005-memory-injector.test.js +175 -0
- package/.claude/skills/honcho-memory/tests/t006-on-prompt-submit.test.js +215 -0
- package/.claude/skills/honcho-memory/tests/t007-on-stop.test.js +165 -0
- package/.claude/skills/honcho-memory/tests/t008-cli.test.js +214 -0
- package/.claude/skills/honcho-memory/tests/t009-setup.test.js +232 -0
- package/.claude/skills/honcho-memory/tests/t010-skill-md.test.js +114 -0
- package/.claude/skills/honcho-memory/tests/t011-settings-hooks.test.js +105 -0
- package/.claude/skills/honcho-memory/tests/t012-docs-update.test.js +106 -0
- package/.claude/skills/honcho-memory/tests/t013-smoke-e2e.test.js +90 -0
- package/.claude/skills/pair-debug/SKILL.md +288 -0
- package/.claude/skills/prd-ready-check/SKILL.md +58 -0
- package/.claude/skills/project-manager/SKILL.md +167 -0
- package/.claude/skills/quality-standards/SKILL.md +201 -0
- package/.claude/skills/quick-feature/SKILL.md +264 -0
- package/.claude/skills/run-sprint/SKILL.md +342 -0
- package/.claude/skills/scaffold/SKILL.md +58 -0
- package/.claude/skills/stack-discovery/SKILL.md +159 -0
- package/.claude/skills/test-coverage-auditor/SKILL.md +59 -0
- package/.claude/skills/to-issues/SKILL.md +163 -0
- package/.claude/skills/to-prd/SKILL.md +130 -0
- package/.claude/skills/update-template/SKILL.md +254 -0
- package/.claude/stacks/CODEOWNERS +30 -0
- package/.claude/stacks/README.md +88 -0
- package/.claude/stacks/_template.md +116 -0
- package/.claude/stacks/java/spring-boot-3.md +376 -0
- package/.claude/stacks/java/spring-boot-4.md +438 -0
- package/.claude/stacks/typescript/angular-18.md +420 -0
- package/.claude/stacks/typescript/angular-19.md +397 -0
- package/.claude/stacks/typescript/angular-21.md +494 -0
- package/CLAUDE.md +453 -0
- package/README.md +391 -0
- package/bin/cli.js +773 -0
- package/bin/lib/backup.js +62 -0
- package/bin/lib/detect-stack.js +476 -0
- package/bin/lib/help.js +233 -0
- package/bin/lib/identity.js +108 -0
- package/bin/lib/local-dir.js +69 -0
- package/bin/lib/manifest.js +236 -0
- package/bin/lib/sync-all.js +394 -0
- package/bin/lib/version-check.js +398 -0
- package/dashboard/db.js +199 -0
- package/dashboard/package.json +22 -0
- package/dashboard/public/app.js +709 -0
- package/dashboard/public/content/docs/agents-reference.en.md +911 -0
- package/dashboard/public/content/docs/architecture-overview.en.md +260 -0
- package/dashboard/public/content/docs/autonomy-matrix.en.md +186 -0
- package/dashboard/public/content/docs/git-flow.en.md +525 -0
- package/dashboard/public/content/docs/honcho-memory.en.md +394 -0
- package/dashboard/public/content/docs/hooks-reference.en.md +420 -0
- package/dashboard/public/content/docs/pipeline.en.md +400 -0
- package/dashboard/public/content/docs/quality-gate.en.md +315 -0
- package/dashboard/public/content/docs/skills-reference.en.md +500 -0
- package/dashboard/public/content/docs/stack-rules.en.md +362 -0
- package/dashboard/public/content/docs/troubleshooting.en.md +637 -0
- package/dashboard/public/content/manifest.json +102 -0
- package/dashboard/public/content/manual/backend.en.md +1138 -0
- package/dashboard/public/content/manual/existing-project.en.md +831 -0
- package/dashboard/public/content/manual/frontend.en.md +1065 -0
- package/dashboard/public/content/manual/fullstack.en.md +1508 -0
- package/dashboard/public/content/manual/mobile.en.md +866 -0
- package/dashboard/public/index.html +108 -0
- package/dashboard/public/style.css +610 -0
- package/dashboard/public/vendor/marked.min.js +69 -0
- package/dashboard/rtk.js +143 -0
- package/dashboard/server-app.js +403 -0
- package/dashboard/server.js +104 -0
- package/dashboard/test/sprint1.test.js +406 -0
- package/dashboard/test/sprint2.test.js +571 -0
- package/dashboard/test/sprint3.test.js +560 -0
- package/package.json +33 -0
- package/scripts/hooks/subagent-telemetry.sh +14 -0
- package/scripts/hooks/telemetry-writer.js +250 -0
- package/scripts/latest-versions.json +56 -0
|
@@ -0,0 +1,403 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* server-app.js — Express application factory (no listen call).
|
|
5
|
+
* Extracted from server.js so tests can require the app without starting a real server.
|
|
6
|
+
* server.js requires this module and calls app.listen().
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
const path = require('path');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const express = require('express');
|
|
12
|
+
const { openDb, initSchema, queryProjects, queryModels } = require('./db');
|
|
13
|
+
const { getRtkStats, getRtkDaily } = require('./rtk');
|
|
14
|
+
|
|
15
|
+
// ── Repo root (BR-002): always relative to this file's __dirname ──────────────
|
|
16
|
+
// dashboard/server-app.js -> __dirname = dashboard/ -> .. = repo root
|
|
17
|
+
const REPO_ROOT = path.resolve(__dirname, '..');
|
|
18
|
+
|
|
19
|
+
// ── Whitelist prefixes for /api/docs/file (BR-001) ────────────────────────────
|
|
20
|
+
// All paths must use forward slashes after normalize.
|
|
21
|
+
const WHITELIST_PREFIXES = [
|
|
22
|
+
'.claude/',
|
|
23
|
+
'docs/brain/',
|
|
24
|
+
'docs/discovery/',
|
|
25
|
+
'docs/plans/',
|
|
26
|
+
'docs/prd/',
|
|
27
|
+
'docs/issues/',
|
|
28
|
+
'dashboard/public/content/',
|
|
29
|
+
];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Validate a client-supplied relative path against the whitelist.
|
|
33
|
+
* Returns the resolved absolute path on success, or null if rejected.
|
|
34
|
+
*
|
|
35
|
+
* Rejection criteria (BR-001):
|
|
36
|
+
* - contains ".." segment
|
|
37
|
+
* - starts with "/" (absolute Unix)
|
|
38
|
+
* - contains ":\\" or ":/" (Windows drive letter)
|
|
39
|
+
* - normalized path does not start with one of the WHITELIST_PREFIXES
|
|
40
|
+
* - resolved file is a symlink
|
|
41
|
+
*
|
|
42
|
+
* @param {string} rawPath — value from query string
|
|
43
|
+
* @returns {string|null} — absolute path or null
|
|
44
|
+
*/
|
|
45
|
+
function validateDocPath(rawPath) {
|
|
46
|
+
if (!rawPath || typeof rawPath !== 'string') return null;
|
|
47
|
+
|
|
48
|
+
// Reject obvious traversal / absolute / drive-letter patterns
|
|
49
|
+
if (rawPath.includes('..')) return null;
|
|
50
|
+
if (rawPath.startsWith('/')) return null;
|
|
51
|
+
if (/[a-zA-Z]:[/\\]/.test(rawPath)) return null;
|
|
52
|
+
|
|
53
|
+
// Normalize using posix separators (forward slash) for consistent prefix check
|
|
54
|
+
const normalised = path.normalize(rawPath).replace(/\\/g, '/');
|
|
55
|
+
|
|
56
|
+
// Reject if normalised form still contains ".."
|
|
57
|
+
if (normalised.includes('..')) return null;
|
|
58
|
+
|
|
59
|
+
// Check whitelist
|
|
60
|
+
const allowed = WHITELIST_PREFIXES.some((prefix) => normalised.startsWith(prefix));
|
|
61
|
+
if (!allowed) return null;
|
|
62
|
+
|
|
63
|
+
// Resolve to absolute path
|
|
64
|
+
const abs = path.resolve(REPO_ROOT, normalised);
|
|
65
|
+
|
|
66
|
+
// Symlink check
|
|
67
|
+
try {
|
|
68
|
+
const lstat = fs.lstatSync(abs);
|
|
69
|
+
if (lstat.isSymbolicLink()) return null;
|
|
70
|
+
} catch {
|
|
71
|
+
// File does not exist — let the read fail naturally (404 is fine; 403 only for policy)
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return abs;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Markdown parsers for SKILL.md / agent .md / ADR .md ──────────────────────
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Attempt to extract frontmatter key from a YAML block (--- ... ---).
|
|
81
|
+
* Falls back to null if not found.
|
|
82
|
+
* @param {string} content
|
|
83
|
+
* @param {string} key
|
|
84
|
+
* @returns {string|null}
|
|
85
|
+
*/
|
|
86
|
+
function extractFrontmatter(content, key) {
|
|
87
|
+
const fmMatch = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
88
|
+
if (!fmMatch) return null;
|
|
89
|
+
const fm = fmMatch[1];
|
|
90
|
+
const lineMatch = fm.match(new RegExp(`^${key}:\\s*(.+)$`, 'm'));
|
|
91
|
+
return lineMatch ? lineMatch[1].trim().replace(/^['"]|['"]$/g, '') : null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Extract h1 heading from markdown content.
|
|
96
|
+
* @param {string} content
|
|
97
|
+
* @returns {string|null}
|
|
98
|
+
*/
|
|
99
|
+
function extractH1(content) {
|
|
100
|
+
const m = content.match(/^#\s+(.+)$/m);
|
|
101
|
+
return m ? m[1].trim() : null;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Extract first non-empty paragraph (skipping frontmatter and headings).
|
|
106
|
+
* @param {string} content
|
|
107
|
+
* @returns {string}
|
|
108
|
+
*/
|
|
109
|
+
function extractFirstParagraph(content) {
|
|
110
|
+
// Remove frontmatter
|
|
111
|
+
const withoutFm = content.replace(/^---\s*\n[\s\S]*?\n---\s*\n?/, '');
|
|
112
|
+
// Find first non-empty, non-heading line
|
|
113
|
+
for (const line of withoutFm.split('\n')) {
|
|
114
|
+
const t = line.trim();
|
|
115
|
+
if (t && !t.startsWith('#') && !t.startsWith('```')) {
|
|
116
|
+
return t.substring(0, 200);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return '';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Parse a SKILL.md file into a SkillEntry.
|
|
124
|
+
* D-003: tolerant parser — tries frontmatter first, then markdown headings/paragraphs.
|
|
125
|
+
*
|
|
126
|
+
* @param {string} content — raw file contents
|
|
127
|
+
* @param {string} filePath — absolute path
|
|
128
|
+
* @param {string} relPath — repo-relative path (forward slashes)
|
|
129
|
+
* @returns {{name: string, description: string, triggers: string[], path: string}}
|
|
130
|
+
*/
|
|
131
|
+
function parseSkillMd(content, filePath, relPath) {
|
|
132
|
+
// Name: frontmatter "name:" > directory name (parent folder of SKILL.md)
|
|
133
|
+
const name =
|
|
134
|
+
extractFrontmatter(content, 'name') ||
|
|
135
|
+
extractH1(content) ||
|
|
136
|
+
path.basename(path.dirname(filePath));
|
|
137
|
+
|
|
138
|
+
// Description: frontmatter "description:" > first paragraph
|
|
139
|
+
const description =
|
|
140
|
+
extractFrontmatter(content, 'description') || extractFirstParagraph(content);
|
|
141
|
+
|
|
142
|
+
// Triggers: look for "## When it triggers" section or "triggers:" frontmatter
|
|
143
|
+
let triggers = [];
|
|
144
|
+
const triggersSection = content.match(/##\s+When it triggers\s*\n([\s\S]*?)(?=\n##|\n---|$)/i);
|
|
145
|
+
if (triggersSection) {
|
|
146
|
+
triggers = triggersSection[1]
|
|
147
|
+
.split('\n')
|
|
148
|
+
.map((l) => l.replace(/^[-*]\s*/, '').trim())
|
|
149
|
+
.filter(Boolean)
|
|
150
|
+
.slice(0, 5);
|
|
151
|
+
} else {
|
|
152
|
+
const fmTriggers = extractFrontmatter(content, 'triggers');
|
|
153
|
+
if (fmTriggers) triggers = [fmTriggers];
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return { name, description, triggers, path: relPath };
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Parse an agent .md file into an AgentEntry.
|
|
161
|
+
* @param {string} content
|
|
162
|
+
* @param {string} filePath
|
|
163
|
+
* @param {string} relPath
|
|
164
|
+
* @returns {{name: string, description: string, model: string, path: string}}
|
|
165
|
+
*/
|
|
166
|
+
function parseAgentMd(content, filePath, relPath) {
|
|
167
|
+
const name =
|
|
168
|
+
extractFrontmatter(content, 'name') ||
|
|
169
|
+
extractH1(content) ||
|
|
170
|
+
path.basename(filePath, '.md');
|
|
171
|
+
|
|
172
|
+
const description =
|
|
173
|
+
extractFrontmatter(content, 'description') || extractFirstParagraph(content);
|
|
174
|
+
|
|
175
|
+
// Default model per CLAUDE.md table (D-003)
|
|
176
|
+
const model = extractFrontmatter(content, 'model') || 'sonnet';
|
|
177
|
+
|
|
178
|
+
return { name, description, model, path: relPath };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Parse an ADR .md file into an AdrEntry.
|
|
183
|
+
* @param {string} content
|
|
184
|
+
* @param {string} filePath — absolute
|
|
185
|
+
* @param {string} relPath — repo-relative
|
|
186
|
+
* @returns {{number: number, slug: string, title: string, status: string, path: string}}
|
|
187
|
+
*/
|
|
188
|
+
function parseAdrMd(content, filePath, relPath) {
|
|
189
|
+
const filename = path.basename(filePath, '.md');
|
|
190
|
+
const numMatch = filename.match(/ADR-(\d+)/i);
|
|
191
|
+
const number = numMatch ? parseInt(numMatch[1], 10) : 0;
|
|
192
|
+
|
|
193
|
+
const slug = filename;
|
|
194
|
+
const title =
|
|
195
|
+
extractFrontmatter(content, 'title') ||
|
|
196
|
+
extractH1(content) ||
|
|
197
|
+
filename;
|
|
198
|
+
|
|
199
|
+
const status = extractFrontmatter(content, 'status') || 'Unknown';
|
|
200
|
+
|
|
201
|
+
return { number, slug, title, status, path: relPath };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Database setup ────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
let db;
|
|
207
|
+
try {
|
|
208
|
+
db = openDb();
|
|
209
|
+
initSchema(db);
|
|
210
|
+
} catch (err) {
|
|
211
|
+
process.stderr.write('Warning: could not open telemetry DB: ' + err.message + '\n');
|
|
212
|
+
db = null;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── Express app ───────────────────────────────────────────────────────────────
|
|
216
|
+
|
|
217
|
+
const app = express();
|
|
218
|
+
|
|
219
|
+
// CORS permissive for local dashboard
|
|
220
|
+
app.use((req, res, next) => {
|
|
221
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
222
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
|
|
223
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
|
224
|
+
next();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
// Static frontend
|
|
228
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
229
|
+
|
|
230
|
+
// ── Telemetry endpoints ───────────────────────────────────────────────────────
|
|
231
|
+
|
|
232
|
+
const THIRTY_DAYS_S = 30 * 24 * 60 * 60;
|
|
233
|
+
|
|
234
|
+
app.get('/api/projects', (req, res) => {
|
|
235
|
+
const sinceTs = Math.floor(Date.now() / 1000) - THIRTY_DAYS_S;
|
|
236
|
+
const projects = db ? queryProjects(db, sinceTs) : [];
|
|
237
|
+
res.json(projects);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
app.get('/api/rtk', async (req, res) => {
|
|
241
|
+
try {
|
|
242
|
+
const [stats, daily] = await Promise.all([getRtkStats(), getRtkDaily()]);
|
|
243
|
+
if (!stats && !daily) {
|
|
244
|
+
res.json(null);
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
res.json({
|
|
248
|
+
tokens_saved: stats ? stats.tokens_saved : 0,
|
|
249
|
+
savings_pct: stats ? stats.savings_pct : 0,
|
|
250
|
+
daily: daily || [],
|
|
251
|
+
});
|
|
252
|
+
} catch {
|
|
253
|
+
res.json(null);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
app.get('/api/stats', async (req, res) => {
|
|
258
|
+
try {
|
|
259
|
+
const sinceTs = Math.floor(Date.now() / 1000) - THIRTY_DAYS_S;
|
|
260
|
+
|
|
261
|
+
const projectRows = db ? queryProjects(db, sinceTs) : [];
|
|
262
|
+
const modelRows = db ? queryModels(db) : [];
|
|
263
|
+
|
|
264
|
+
const [rtkStats, rtkDaily] = await Promise.all([getRtkStats(), getRtkDaily()]);
|
|
265
|
+
|
|
266
|
+
const rtk =
|
|
267
|
+
rtkStats || rtkDaily
|
|
268
|
+
? {
|
|
269
|
+
tokens_saved: rtkStats ? rtkStats.tokens_saved : 0,
|
|
270
|
+
savings_pct: rtkStats ? rtkStats.savings_pct : 0,
|
|
271
|
+
daily: rtkDaily || [],
|
|
272
|
+
}
|
|
273
|
+
: null;
|
|
274
|
+
|
|
275
|
+
function buildModels(rows) {
|
|
276
|
+
const total = rows.reduce((s, r) => s + Number(r.count), 0);
|
|
277
|
+
const out = { opus: { count: 0, pct: 0 }, sonnet: { count: 0, pct: 0 }, haiku: { count: 0, pct: 0 } };
|
|
278
|
+
for (const row of rows) {
|
|
279
|
+
const key = ['opus', 'sonnet', 'haiku'].includes(row.model) ? row.model : null;
|
|
280
|
+
if (key) {
|
|
281
|
+
out[key].count = Number(row.count);
|
|
282
|
+
out[key].pct = total > 0 ? Math.round((Number(row.count) / total) * 1000) / 10 : 0;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return out;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
res.json({
|
|
289
|
+
projects: projectRows,
|
|
290
|
+
models: buildModels(modelRows),
|
|
291
|
+
rtk,
|
|
292
|
+
});
|
|
293
|
+
} catch (err) {
|
|
294
|
+
res.status(500).json({ error: err.message });
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
// ── Docs endpoints (/api/docs/*) ──────────────────────────────────────────────
|
|
299
|
+
|
|
300
|
+
/**
|
|
301
|
+
* GET /api/docs/skills
|
|
302
|
+
* Reads .claude/skills/{name}/SKILL.md files from repo root.
|
|
303
|
+
* Returns [{name, description, triggers, path}]
|
|
304
|
+
*/
|
|
305
|
+
app.get('/api/docs/skills', (req, res) => {
|
|
306
|
+
const skillsDir = path.join(REPO_ROOT, '.claude', 'skills');
|
|
307
|
+
const results = [];
|
|
308
|
+
|
|
309
|
+
try {
|
|
310
|
+
const entries = fs.readdirSync(skillsDir, { withFileTypes: true });
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
if (!entry.isDirectory()) continue;
|
|
313
|
+
const skillFile = path.join(skillsDir, entry.name, 'SKILL.md');
|
|
314
|
+
if (!fs.existsSync(skillFile)) continue;
|
|
315
|
+
const content = fs.readFileSync(skillFile, 'utf8');
|
|
316
|
+
const relPath = `.claude/skills/${entry.name}/SKILL.md`;
|
|
317
|
+
results.push(parseSkillMd(content, skillFile, relPath));
|
|
318
|
+
}
|
|
319
|
+
res.json(results);
|
|
320
|
+
} catch (err) {
|
|
321
|
+
res.status(500).json({ error: err.message });
|
|
322
|
+
}
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
/**
|
|
326
|
+
* GET /api/docs/agents
|
|
327
|
+
* Reads .claude/agents/*.md files from repo root.
|
|
328
|
+
* Returns [{name, description, model, path}]
|
|
329
|
+
*/
|
|
330
|
+
app.get('/api/docs/agents', (req, res) => {
|
|
331
|
+
const agentsDir = path.join(REPO_ROOT, '.claude', 'agents');
|
|
332
|
+
const results = [];
|
|
333
|
+
|
|
334
|
+
try {
|
|
335
|
+
const files = fs.readdirSync(agentsDir).filter((f) => f.endsWith('.md'));
|
|
336
|
+
for (const file of files) {
|
|
337
|
+
const abs = path.join(agentsDir, file);
|
|
338
|
+
const lstat = fs.lstatSync(abs);
|
|
339
|
+
if (lstat.isSymbolicLink()) continue; // skip symlinks
|
|
340
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
341
|
+
const relPath = `.claude/agents/${file}`;
|
|
342
|
+
results.push(parseAgentMd(content, abs, relPath));
|
|
343
|
+
}
|
|
344
|
+
res.json(results);
|
|
345
|
+
} catch (err) {
|
|
346
|
+
res.status(500).json({ error: err.message });
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* GET /api/docs/adrs
|
|
352
|
+
* Reads docs/brain/decisions/ADR-*.md, sorted by ADR number ascending.
|
|
353
|
+
* Returns [{number, slug, title, status, path}]
|
|
354
|
+
*/
|
|
355
|
+
app.get('/api/docs/adrs', (req, res) => {
|
|
356
|
+
const decisionsDir = path.join(REPO_ROOT, 'docs', 'brain', 'decisions');
|
|
357
|
+
const results = [];
|
|
358
|
+
|
|
359
|
+
try {
|
|
360
|
+
const files = fs.readdirSync(decisionsDir).filter((f) => /^ADR-\d+/i.test(f) && f.endsWith('.md'));
|
|
361
|
+
for (const file of files) {
|
|
362
|
+
const abs = path.join(decisionsDir, file);
|
|
363
|
+
const lstat = fs.lstatSync(abs);
|
|
364
|
+
if (lstat.isSymbolicLink()) continue;
|
|
365
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
366
|
+
const relPath = `docs/brain/decisions/${file}`;
|
|
367
|
+
results.push(parseAdrMd(content, abs, relPath));
|
|
368
|
+
}
|
|
369
|
+
results.sort((a, b) => a.number - b.number);
|
|
370
|
+
res.json(results);
|
|
371
|
+
} catch (err) {
|
|
372
|
+
res.status(500).json({ error: err.message });
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
|
|
376
|
+
/**
|
|
377
|
+
* GET /api/docs/file?path=<relative-path>
|
|
378
|
+
* Returns raw markdown content of a whitelisted file.
|
|
379
|
+
* Returns 403 for any path outside whitelist, containing "..", absolute, drive letters, or symlinks.
|
|
380
|
+
*/
|
|
381
|
+
app.get('/api/docs/file', (req, res) => {
|
|
382
|
+
const rawPath = req.query.path;
|
|
383
|
+
const absPath = validateDocPath(rawPath);
|
|
384
|
+
|
|
385
|
+
if (!absPath) {
|
|
386
|
+
return res.status(403).json({ error: 'Forbidden' });
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const content = fs.readFileSync(absPath, 'utf8');
|
|
391
|
+
res.type('text/plain').send(content);
|
|
392
|
+
} catch (err) {
|
|
393
|
+
if (err.code === 'ENOENT') {
|
|
394
|
+
res.status(404).json({ error: 'Not found' });
|
|
395
|
+
} else {
|
|
396
|
+
res.status(500).json({ error: err.message });
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
// ── Export for testing ────────────────────────────────────────────────────────
|
|
402
|
+
|
|
403
|
+
module.exports = { app };
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const net = require('net');
|
|
5
|
+
const { spawn } = require('child_process');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const { app } = require('./server-app');
|
|
8
|
+
|
|
9
|
+
// ── CLI args ──────────────────────────────────────────────────────────────────
|
|
10
|
+
|
|
11
|
+
const args = process.argv.slice(2);
|
|
12
|
+
let startPort = 4242;
|
|
13
|
+
let openBrowser = true;
|
|
14
|
+
|
|
15
|
+
for (let i = 0; i < args.length; i++) {
|
|
16
|
+
if (args[i] === '--port' && i + 1 < args.length) {
|
|
17
|
+
const n = parseInt(args[++i], 10);
|
|
18
|
+
if (!isNaN(n)) startPort = n;
|
|
19
|
+
} else if (args[i] === '--no-open') {
|
|
20
|
+
openBrowser = false;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Find a free TCP port starting from `start`.
|
|
28
|
+
* Tries up to 20 ports before giving up.
|
|
29
|
+
* @param {number} start
|
|
30
|
+
* @returns {Promise<number>}
|
|
31
|
+
*/
|
|
32
|
+
function findFreePort(start) {
|
|
33
|
+
return new Promise((resolve, reject) => {
|
|
34
|
+
let port = start;
|
|
35
|
+
const tryPort = () => {
|
|
36
|
+
const server = net.createServer();
|
|
37
|
+
server.once('error', (err) => {
|
|
38
|
+
if (err.code === 'EADDRINUSE') {
|
|
39
|
+
port += 1;
|
|
40
|
+
if (port > start + 20) {
|
|
41
|
+
reject(new Error('Could not find a free port in range ' + start + '–' + (start + 20)));
|
|
42
|
+
} else {
|
|
43
|
+
tryPort();
|
|
44
|
+
}
|
|
45
|
+
} else {
|
|
46
|
+
reject(err);
|
|
47
|
+
}
|
|
48
|
+
});
|
|
49
|
+
server.once('listening', () => {
|
|
50
|
+
server.close(() => resolve(port));
|
|
51
|
+
});
|
|
52
|
+
server.listen(port, '127.0.0.1');
|
|
53
|
+
};
|
|
54
|
+
tryPort();
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Open the default browser to the given URL.
|
|
60
|
+
*/
|
|
61
|
+
function openUrl(url) {
|
|
62
|
+
const platform = os.platform();
|
|
63
|
+
let cmd, cmdArgs;
|
|
64
|
+
if (platform === 'win32') {
|
|
65
|
+
cmd = 'cmd';
|
|
66
|
+
cmdArgs = ['/c', 'start', '', url];
|
|
67
|
+
} else if (platform === 'darwin') {
|
|
68
|
+
cmd = 'open';
|
|
69
|
+
cmdArgs = [url];
|
|
70
|
+
} else {
|
|
71
|
+
cmd = 'xdg-open';
|
|
72
|
+
cmdArgs = [url];
|
|
73
|
+
}
|
|
74
|
+
spawn(cmd, cmdArgs, { detached: true, stdio: 'ignore' }).unref();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ── Start ─────────────────────────────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
(async () => {
|
|
80
|
+
let port;
|
|
81
|
+
try {
|
|
82
|
+
port = await findFreePort(startPort);
|
|
83
|
+
} catch (err) {
|
|
84
|
+
process.stderr.write('Error: ' + err.message + '\n');
|
|
85
|
+
process.exit(1);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const server = app.listen(port, '127.0.0.1', () => {
|
|
89
|
+
const url = 'http://localhost:' + port;
|
|
90
|
+
process.stdout.write('Dashboard running at ' + url + '\n');
|
|
91
|
+
if (openBrowser) {
|
|
92
|
+
openUrl(url);
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
server.on('error', (err) => {
|
|
97
|
+
process.stderr.write('Server error: ' + err.message + '\n');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// Graceful shutdown
|
|
102
|
+
process.on('SIGTERM', () => server.close(() => process.exit(0)));
|
|
103
|
+
process.on('SIGINT', () => server.close(() => process.exit(0)));
|
|
104
|
+
})();
|