@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.
@@ -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 `![${label}](${badgeUrl(score, label)})`;
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
+ };
@@ -0,0 +1,14 @@
1
+ import {
2
+ detectRegressions,
3
+ loadHistory,
4
+ saveDepTree,
5
+ saveEntry,
6
+ saveLastReport
7
+ } from "./chunk-5KJOYSVJ.js";
8
+ export {
9
+ detectRegressions,
10
+ loadHistory,
11
+ saveDepTree,
12
+ saveEntry,
13
+ saveLastReport
14
+ };
@@ -0,0 +1 @@
1
+ #!/usr/bin/env node