@nebulord/sickbay 0.1.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/README.md +231 -0
- package/dist/DiffApp-YF2PYQZK.js +187 -0
- package/dist/DoctorApp-U465IMK7.js +447 -0
- package/dist/FixApp-RJPCWNXJ.js +344 -0
- package/dist/StatsApp-BI6COY7S.js +375 -0
- package/dist/TrendApp-XL77HKDR.js +118 -0
- package/dist/TuiApp-VJNV4FD3.js +982 -0
- package/dist/ai-7DGOLNJX.js +64 -0
- package/dist/badge-KQ73KEIN.js +41 -0
- package/dist/chunk-5KJOYSVJ.js +95 -0
- package/dist/chunk-BIK4EL4H.js +19 -0
- package/dist/chunk-BUD5BE6U.js +61 -0
- package/dist/chunk-D24FSOW4.js +22 -0
- package/dist/chunk-POUHUMJN.js +21 -0
- package/dist/chunk-SSUXSMGH.js +25 -0
- package/dist/history-DYFJ65XH.js +14 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +535 -0
- package/dist/init-J2NPRPDO.js +54 -0
- package/dist/resolve-package-PHPJWOLY.js +8 -0
- package/dist/web-EE2VYPEX.js +198 -0
- package/package.json +58 -0
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// src/services/ai.ts
|
|
2
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
3
|
+
function createAIService(apiKey) {
|
|
4
|
+
const client = new Anthropic({ apiKey });
|
|
5
|
+
async function generateSummary(report) {
|
|
6
|
+
const prompt = `You are a code health analyst. Analyze this project health report and provide a structured summary with the following sections:
|
|
7
|
+
|
|
8
|
+
**Health Assessment**
|
|
9
|
+
A one-sentence overall health statement using the score (${report.overallScore}/100).
|
|
10
|
+
|
|
11
|
+
**Critical Issues**
|
|
12
|
+
List 2-3 most important issues to address. If none, say "No critical issues detected."
|
|
13
|
+
|
|
14
|
+
**What's Going Well**
|
|
15
|
+
Highlight 1-2 positive aspects or passing checks.
|
|
16
|
+
|
|
17
|
+
**Next Steps**
|
|
18
|
+
One actionable recommendation to improve the codebase.
|
|
19
|
+
|
|
20
|
+
Use markdown formatting (**bold** for headings). Be direct and specific.
|
|
21
|
+
|
|
22
|
+
Report:
|
|
23
|
+
${JSON.stringify(report, null, 2)}`;
|
|
24
|
+
const response = await client.messages.create({
|
|
25
|
+
model: "claude-sonnet-4-20250514",
|
|
26
|
+
max_tokens: 600,
|
|
27
|
+
messages: [{ role: "user", content: prompt }]
|
|
28
|
+
});
|
|
29
|
+
const textBlock = response.content.find((block) => block.type === "text");
|
|
30
|
+
return textBlock && "text" in textBlock ? textBlock.text : "Unable to generate summary.";
|
|
31
|
+
}
|
|
32
|
+
async function chat(message, report, history) {
|
|
33
|
+
const systemPrompt = `You are an expert code health assistant analyzing a project's Sickbay report.
|
|
34
|
+
|
|
35
|
+
The report contains:
|
|
36
|
+
- Overall score: ${report.overallScore}/100
|
|
37
|
+
- ${report.summary.critical} critical issues, ${report.summary.warnings} warnings, ${report.summary.info} info items
|
|
38
|
+
- Checks across dependencies, security, code quality, performance, and git health
|
|
39
|
+
|
|
40
|
+
Full report data:
|
|
41
|
+
${JSON.stringify(report, null, 2)}
|
|
42
|
+
|
|
43
|
+
Answer questions clearly and concisely. Reference specific checks, scores, and issues when relevant. Be helpful and actionable.`;
|
|
44
|
+
const messages = [
|
|
45
|
+
...history.map((h) => ({
|
|
46
|
+
role: h.role,
|
|
47
|
+
content: h.content
|
|
48
|
+
})),
|
|
49
|
+
{ role: "user", content: message }
|
|
50
|
+
];
|
|
51
|
+
const response = await client.messages.create({
|
|
52
|
+
model: "claude-sonnet-4-20250514",
|
|
53
|
+
max_tokens: 1e3,
|
|
54
|
+
system: systemPrompt,
|
|
55
|
+
messages
|
|
56
|
+
});
|
|
57
|
+
const textBlock = response.content.find((block) => block.type === "text");
|
|
58
|
+
return textBlock && "text" in textBlock ? textBlock.text : "Unable to generate response.";
|
|
59
|
+
}
|
|
60
|
+
return { generateSummary, chat };
|
|
61
|
+
}
|
|
62
|
+
export {
|
|
63
|
+
createAIService
|
|
64
|
+
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
// src/commands/badge.ts
|
|
2
|
+
import { existsSync, readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
function getScoreColor(score) {
|
|
5
|
+
if (score >= 90) return "brightgreen";
|
|
6
|
+
if (score >= 80) return "green";
|
|
7
|
+
if (score >= 60) return "yellow";
|
|
8
|
+
return "red";
|
|
9
|
+
}
|
|
10
|
+
function encodeLabel(label) {
|
|
11
|
+
return label.replace(/-/g, "--").replace(/_/g, "__").replace(/ /g, "%20");
|
|
12
|
+
}
|
|
13
|
+
function badgeUrl(score, label = "sickbay") {
|
|
14
|
+
const color = getScoreColor(score);
|
|
15
|
+
const encoded = encodeLabel(label);
|
|
16
|
+
return `https://img.shields.io/badge/${encoded}-${score}%2F100-${color}`;
|
|
17
|
+
}
|
|
18
|
+
function badgeMarkdown(score, label = "sickbay") {
|
|
19
|
+
return `})`;
|
|
20
|
+
}
|
|
21
|
+
function badgeHtml(score, label = "sickbay") {
|
|
22
|
+
return `<img src="${badgeUrl(score, label)}" alt="${label}" />`;
|
|
23
|
+
}
|
|
24
|
+
function loadScoreFromLastReport(projectPath) {
|
|
25
|
+
const filePath = join(projectPath, ".sickbay", "last-report.json");
|
|
26
|
+
if (!existsSync(filePath)) return null;
|
|
27
|
+
try {
|
|
28
|
+
const data = JSON.parse(readFileSync(filePath, "utf-8"));
|
|
29
|
+
if (typeof data.overallScore !== "number") return null;
|
|
30
|
+
return data.overallScore;
|
|
31
|
+
} catch {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
export {
|
|
36
|
+
badgeHtml,
|
|
37
|
+
badgeMarkdown,
|
|
38
|
+
badgeUrl,
|
|
39
|
+
getScoreColor,
|
|
40
|
+
loadScoreFromLastReport
|
|
41
|
+
};
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
// src/lib/history.ts
|
|
2
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
function historyFilePath(projectPath) {
|
|
5
|
+
return join(projectPath, ".sickbay", "history.json");
|
|
6
|
+
}
|
|
7
|
+
function loadHistory(projectPath) {
|
|
8
|
+
const filePath = historyFilePath(projectPath);
|
|
9
|
+
if (!existsSync(filePath)) return null;
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(readFileSync(filePath, "utf-8"));
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function saveEntry(report) {
|
|
17
|
+
mkdirSync(join(report.projectPath, ".sickbay"), { recursive: true });
|
|
18
|
+
const filePath = historyFilePath(report.projectPath);
|
|
19
|
+
const existing = loadHistory(report.projectPath) ?? {
|
|
20
|
+
projectPath: report.projectPath,
|
|
21
|
+
projectName: report.projectInfo.name,
|
|
22
|
+
entries: []
|
|
23
|
+
};
|
|
24
|
+
const categoryScores = {};
|
|
25
|
+
const categoryChecks = {};
|
|
26
|
+
for (const check of report.checks) {
|
|
27
|
+
if (check.status === "skipped") continue;
|
|
28
|
+
if (!categoryChecks[check.category]) categoryChecks[check.category] = [];
|
|
29
|
+
categoryChecks[check.category].push(check.score);
|
|
30
|
+
}
|
|
31
|
+
for (const [cat, scores] of Object.entries(categoryChecks)) {
|
|
32
|
+
categoryScores[cat] = Math.round(
|
|
33
|
+
scores.reduce((a, b) => a + b, 0) / scores.length
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
existing.entries.push({
|
|
37
|
+
timestamp: report.timestamp,
|
|
38
|
+
overallScore: report.overallScore,
|
|
39
|
+
categoryScores,
|
|
40
|
+
summary: { ...report.summary },
|
|
41
|
+
checksRun: report.checks.filter((c) => c.status !== "skipped").length
|
|
42
|
+
});
|
|
43
|
+
if (existing.entries.length > 100) {
|
|
44
|
+
existing.entries = existing.entries.slice(-100);
|
|
45
|
+
}
|
|
46
|
+
writeFileSync(filePath, JSON.stringify(existing, null, 2));
|
|
47
|
+
}
|
|
48
|
+
function saveLastReport(report) {
|
|
49
|
+
mkdirSync(join(report.projectPath, ".sickbay"), { recursive: true });
|
|
50
|
+
writeFileSync(
|
|
51
|
+
join(report.projectPath, ".sickbay", "last-report.json"),
|
|
52
|
+
JSON.stringify(report, null, 2)
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
function saveDepTree(projectPath, tree) {
|
|
56
|
+
mkdirSync(join(projectPath, ".sickbay"), { recursive: true });
|
|
57
|
+
writeFileSync(
|
|
58
|
+
join(projectPath, ".sickbay", "dep-tree.json"),
|
|
59
|
+
JSON.stringify(tree, null, 2)
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
function detectRegressions(entries) {
|
|
63
|
+
if (entries.length < 2) return [];
|
|
64
|
+
const latest = entries[entries.length - 1];
|
|
65
|
+
const previous = entries[entries.length - 2];
|
|
66
|
+
const regressions = [];
|
|
67
|
+
if (latest.overallScore < previous.overallScore - 5) {
|
|
68
|
+
regressions.push({
|
|
69
|
+
category: "overall",
|
|
70
|
+
drop: previous.overallScore - latest.overallScore,
|
|
71
|
+
from: previous.overallScore,
|
|
72
|
+
to: latest.overallScore
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
for (const [cat, score] of Object.entries(latest.categoryScores)) {
|
|
76
|
+
const prevScore = previous.categoryScores[cat];
|
|
77
|
+
if (prevScore !== void 0 && score < prevScore - 5) {
|
|
78
|
+
regressions.push({
|
|
79
|
+
category: cat,
|
|
80
|
+
drop: prevScore - score,
|
|
81
|
+
from: prevScore,
|
|
82
|
+
to: score
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return regressions;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export {
|
|
90
|
+
loadHistory,
|
|
91
|
+
saveEntry,
|
|
92
|
+
saveLastReport,
|
|
93
|
+
saveDepTree,
|
|
94
|
+
detectRegressions
|
|
95
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// src/components/Header.tsx
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
var ASCII_ART = `
|
|
5
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
6
|
+
\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
7
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588\u2588\u2588
|
|
8
|
+
\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
9
|
+
\u2588\u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588\u2588\u2588\u2588\u2588 \u2588\u2588 \u2588\u2588 \u2588\u2588
|
|
10
|
+
|
|
11
|
+
A vitals health check for your app
|
|
12
|
+
`.trim();
|
|
13
|
+
function Header({ projectName }) {
|
|
14
|
+
return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column", marginBottom: 1 }, /* @__PURE__ */ React.createElement(Text, { color: "green" }, ASCII_ART), /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " v", "0.1.0")), projectName && /* @__PURE__ */ React.createElement(Box, { marginTop: 1 }, /* @__PURE__ */ React.createElement(Text, { dimColor: true }, " Analyzing "), /* @__PURE__ */ React.createElement(Text, { bold: true, color: "white" }, projectName)));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export {
|
|
18
|
+
Header
|
|
19
|
+
};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// src/lib/resolve-package.ts
|
|
2
|
+
import { readFileSync } from "fs";
|
|
3
|
+
import { join } from "path";
|
|
4
|
+
import { detectMonorepo } from "@nebulord/sickbay-core";
|
|
5
|
+
async function resolveProject(projectPath, packageName) {
|
|
6
|
+
const monorepoInfo = await detectMonorepo(projectPath);
|
|
7
|
+
if (!monorepoInfo.isMonorepo) {
|
|
8
|
+
if (packageName) {
|
|
9
|
+
process.stderr.write(
|
|
10
|
+
`--package flag used but "${projectPath}" is not a monorepo
|
|
11
|
+
`
|
|
12
|
+
);
|
|
13
|
+
process.exit(1);
|
|
14
|
+
}
|
|
15
|
+
return { isMonorepo: false, targetPath: projectPath };
|
|
16
|
+
}
|
|
17
|
+
const packageNames = /* @__PURE__ */ new Map();
|
|
18
|
+
for (const p of monorepoInfo.packagePaths) {
|
|
19
|
+
try {
|
|
20
|
+
const pkg = JSON.parse(readFileSync(join(p, "package.json"), "utf-8"));
|
|
21
|
+
packageNames.set(p, pkg.name ?? p);
|
|
22
|
+
} catch {
|
|
23
|
+
packageNames.set(p, p);
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
if (packageName) {
|
|
27
|
+
const targetPath = monorepoInfo.packagePaths.find((p) => {
|
|
28
|
+
const name = packageNames.get(p) ?? "";
|
|
29
|
+
return name === packageName || name.endsWith(`/${packageName}`);
|
|
30
|
+
});
|
|
31
|
+
if (!targetPath) {
|
|
32
|
+
process.stderr.write(
|
|
33
|
+
`Package "${packageName}" not found in monorepo
|
|
34
|
+
`
|
|
35
|
+
);
|
|
36
|
+
process.exit(1);
|
|
37
|
+
}
|
|
38
|
+
return {
|
|
39
|
+
isMonorepo: true,
|
|
40
|
+
monorepoInfo,
|
|
41
|
+
targetPath,
|
|
42
|
+
packagePaths: monorepoInfo.packagePaths,
|
|
43
|
+
packageNames
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
return {
|
|
47
|
+
isMonorepo: true,
|
|
48
|
+
monorepoInfo,
|
|
49
|
+
packagePaths: monorepoInfo.packagePaths,
|
|
50
|
+
packageNames
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
function shortName(fullName) {
|
|
54
|
+
const slashIdx = fullName.lastIndexOf("/");
|
|
55
|
+
return slashIdx >= 0 ? fullName.substring(slashIdx + 1) : fullName;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export {
|
|
59
|
+
resolveProject,
|
|
60
|
+
shortName
|
|
61
|
+
};
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// src/components/ProgressList.tsx
|
|
2
|
+
import React, { useState, useEffect } from "react";
|
|
3
|
+
import { Box, Text } from "ink";
|
|
4
|
+
var SPINNER_FRAMES = ["\u28FE", "\u28FD", "\u28FB", "\u28BF", "\u287F", "\u28DF", "\u28EF", "\u28F7"];
|
|
5
|
+
function Spinner() {
|
|
6
|
+
const [frame, setFrame] = useState(0);
|
|
7
|
+
useEffect(() => {
|
|
8
|
+
const id = setInterval(
|
|
9
|
+
() => setFrame((f) => (f + 1) % SPINNER_FRAMES.length),
|
|
10
|
+
80
|
|
11
|
+
);
|
|
12
|
+
return () => clearInterval(id);
|
|
13
|
+
}, []);
|
|
14
|
+
return /* @__PURE__ */ React.createElement(Text, { color: "green" }, SPINNER_FRAMES[frame]);
|
|
15
|
+
}
|
|
16
|
+
function ProgressList({ items }) {
|
|
17
|
+
return /* @__PURE__ */ React.createElement(Box, { flexDirection: "column" }, items.map((item) => /* @__PURE__ */ React.createElement(Box, { key: item.name }, item.status === "running" ? /* @__PURE__ */ React.createElement(Spinner, null) : item.status === "done" ? /* @__PURE__ */ React.createElement(Text, { color: "green" }, "\u2713") : /* @__PURE__ */ React.createElement(Text, { dimColor: true }, "\u25CB"), /* @__PURE__ */ React.createElement(Text, null, " ", item.name))));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export {
|
|
21
|
+
ProgressList
|
|
22
|
+
};
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// src/lib/messages.ts
|
|
2
|
+
var LOADING_MESSAGES = [
|
|
3
|
+
"Scanning for pre-existing conditions\u2026",
|
|
4
|
+
"Counting packages\u2026 still counting\u2026",
|
|
5
|
+
"Still here. Still scanning...",
|
|
6
|
+
"The unused dependencies know what they did...",
|
|
7
|
+
"Every unused export is a tiny cry for help...",
|
|
8
|
+
"Checking if 'TODO: fix later' was ever fixed later...",
|
|
9
|
+
"Your secrets are safe with us. Unlike your .env file...",
|
|
10
|
+
"Good things take time. This is one of the good things. Probably...",
|
|
11
|
+
"npm audit found issues. npm audit --fix found different issues.",
|
|
12
|
+
"This is fine...",
|
|
13
|
+
"Evaluating life choices. Yours. Via package.json.",
|
|
14
|
+
"Almost done. (This is not a legally binding statement.)",
|
|
15
|
+
"node_modules: depth unknown. Will not attempt.",
|
|
16
|
+
"Performing checks. Results may vary..."
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
export {
|
|
20
|
+
LOADING_MESSAGES
|
|
21
|
+
};
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// src/commands/trend.ts
|
|
2
|
+
var SPARKLINE_CHARS = ["\u2581", "\u2582", "\u2583", "\u2584", "\u2585", "\u2586", "\u2587", "\u2588"];
|
|
3
|
+
function sparkline(values) {
|
|
4
|
+
if (values.length === 0) return "";
|
|
5
|
+
const min = Math.min(...values);
|
|
6
|
+
const max = Math.max(...values);
|
|
7
|
+
const range = max - min || 1;
|
|
8
|
+
return values.map(
|
|
9
|
+
(v) => SPARKLINE_CHARS[Math.round((v - min) / range * (SPARKLINE_CHARS.length - 1))]
|
|
10
|
+
).join("");
|
|
11
|
+
}
|
|
12
|
+
function trendArrow(values) {
|
|
13
|
+
if (values.length < 2) return { direction: "stable", label: "\u2014" };
|
|
14
|
+
const latest = values[values.length - 1];
|
|
15
|
+
const first = values[0];
|
|
16
|
+
const diff = latest - first;
|
|
17
|
+
if (diff > 2) return { direction: "up", label: `\u2191${diff}` };
|
|
18
|
+
if (diff < -2) return { direction: "down", label: `\u2193${Math.abs(diff)}` };
|
|
19
|
+
return { direction: "stable", label: "\xB10" };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export {
|
|
23
|
+
sparkline,
|
|
24
|
+
trendArrow
|
|
25
|
+
};
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
#!/usr/bin/env node
|