soloship 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 +252 -0
- package/bin/soloship.js +2 -0
- package/dist/artifacts.d.ts +83 -0
- package/dist/artifacts.js +241 -0
- package/dist/ci.d.ts +2 -0
- package/dist/ci.js +184 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.js +63 -0
- package/dist/detect.d.ts +30 -0
- package/dist/detect.js +127 -0
- package/dist/doctor.d.ts +10 -0
- package/dist/doctor.js +205 -0
- package/dist/hooks.d.ts +2 -0
- package/dist/hooks.js +477 -0
- package/dist/init.d.ts +5 -0
- package/dist/init.js +94 -0
- package/dist/manifest.d.ts +63 -0
- package/dist/manifest.js +90 -0
- package/dist/pkg.d.ts +1 -0
- package/dist/pkg.js +9 -0
- package/dist/rollback.d.ts +12 -0
- package/dist/rollback.js +129 -0
- package/dist/rules.d.ts +1 -0
- package/dist/rules.js +119 -0
- package/dist/scaffold.d.ts +7 -0
- package/dist/scaffold.js +138 -0
- package/dist/templates.d.ts +5 -0
- package/dist/templates.js +175 -0
- package/dist/upgrade.d.ts +12 -0
- package/dist/upgrade.js +62 -0
- package/package.json +38 -0
package/dist/ci.js
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
export async function installCi(root, project) {
|
|
4
|
+
const results = [];
|
|
5
|
+
// Only install CI for projects with git and tests
|
|
6
|
+
if (!project.hasGit) {
|
|
7
|
+
results.push("CI: skipped (no git repository)");
|
|
8
|
+
return results;
|
|
9
|
+
}
|
|
10
|
+
const workflowsDir = join(root, ".github", "workflows");
|
|
11
|
+
// Don't overwrite existing CI
|
|
12
|
+
if (existsSync(workflowsDir)) {
|
|
13
|
+
results.push("CI: .github/workflows/ already exists, skipping");
|
|
14
|
+
return results;
|
|
15
|
+
}
|
|
16
|
+
mkdirSync(workflowsDir, { recursive: true });
|
|
17
|
+
// Main CI workflow
|
|
18
|
+
const ciWorkflow = generateCiWorkflow(project);
|
|
19
|
+
writeFileSync(join(workflowsDir, "ci.yml"), ciWorkflow);
|
|
20
|
+
results.push("CI: .github/workflows/ci.yml (lint, test, build on push)");
|
|
21
|
+
// Architecture fitness functions (only for TypeScript projects)
|
|
22
|
+
if (project.stack.language === "typescript" ||
|
|
23
|
+
project.stack.language === "javascript") {
|
|
24
|
+
const archTest = generateArchitectureTest(project);
|
|
25
|
+
const testDir = join(root, "__arch__");
|
|
26
|
+
if (!existsSync(testDir)) {
|
|
27
|
+
mkdirSync(testDir, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
writeFileSync(join(testDir, "fitness.test.ts"), archTest);
|
|
30
|
+
results.push("CI: __arch__/fitness.test.ts (architecture fitness functions)");
|
|
31
|
+
}
|
|
32
|
+
return results;
|
|
33
|
+
}
|
|
34
|
+
function generateCiWorkflow(project) {
|
|
35
|
+
const pm = project.stack.packageManager;
|
|
36
|
+
const installCmd = pm === "bun"
|
|
37
|
+
? "bun install"
|
|
38
|
+
: pm === "pnpm"
|
|
39
|
+
? "pnpm install"
|
|
40
|
+
: pm === "yarn"
|
|
41
|
+
? "yarn install"
|
|
42
|
+
: "npm ci";
|
|
43
|
+
const nodeVersion = "22";
|
|
44
|
+
return `name: CI
|
|
45
|
+
|
|
46
|
+
on:
|
|
47
|
+
push:
|
|
48
|
+
branches: [main]
|
|
49
|
+
pull_request:
|
|
50
|
+
branches: [main]
|
|
51
|
+
|
|
52
|
+
jobs:
|
|
53
|
+
ci:
|
|
54
|
+
runs-on: ubuntu-latest
|
|
55
|
+
steps:
|
|
56
|
+
- uses: actions/checkout@v4
|
|
57
|
+
|
|
58
|
+
- uses: actions/setup-node@v4
|
|
59
|
+
with:
|
|
60
|
+
node-version: '${nodeVersion}'
|
|
61
|
+
cache: '${pm === "bun" ? "npm" : pm}'
|
|
62
|
+
|
|
63
|
+
- name: Install dependencies
|
|
64
|
+
run: ${installCmd}
|
|
65
|
+
|
|
66
|
+
- name: Lint
|
|
67
|
+
run: ${pm === "bun" ? "bun run lint" : "npm run lint"}
|
|
68
|
+
continue-on-error: false
|
|
69
|
+
|
|
70
|
+
- name: Test
|
|
71
|
+
run: ${pm === "bun" ? "bun test" : "npm test"}
|
|
72
|
+
|
|
73
|
+
- name: Build
|
|
74
|
+
run: ${pm === "bun" ? "bun run build" : "npm run build"}
|
|
75
|
+
|
|
76
|
+
- name: Security scan (Semgrep)
|
|
77
|
+
run: |
|
|
78
|
+
if [ -f ".semgrep.yml" ]; then
|
|
79
|
+
pip install semgrep
|
|
80
|
+
semgrep --config .semgrep.yml --error --severity ERROR src/
|
|
81
|
+
semgrep --config .semgrep.yml --severity WARNING src/ || true
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
- name: Architecture fitness
|
|
85
|
+
run: npx vitest run __arch__/ 2>/dev/null || npx jest __arch__/ 2>/dev/null || true
|
|
86
|
+
`;
|
|
87
|
+
}
|
|
88
|
+
function generateArchitectureTest(project) {
|
|
89
|
+
// Generate basic fitness functions based on common patterns
|
|
90
|
+
return `/**
|
|
91
|
+
* Architecture Fitness Functions
|
|
92
|
+
*
|
|
93
|
+
* These tests enforce architectural boundaries. They run in CI and fail
|
|
94
|
+
* the build if the architecture drifts from its blueprint.
|
|
95
|
+
*
|
|
96
|
+
* Customize these rules based on your project's module boundaries.
|
|
97
|
+
* Run /audit to discover what rules should be added.
|
|
98
|
+
*/
|
|
99
|
+
|
|
100
|
+
import { readdirSync, readFileSync, existsSync } from "node:fs";
|
|
101
|
+
import { join, resolve } from "node:path";
|
|
102
|
+
import { describe, test, expect } from "vitest";
|
|
103
|
+
|
|
104
|
+
const ROOT = resolve(__dirname, "..");
|
|
105
|
+
|
|
106
|
+
function getSourceFiles(dir: string, ext = ".ts"): string[] {
|
|
107
|
+
const results: string[] = [];
|
|
108
|
+
if (!existsSync(dir)) return results;
|
|
109
|
+
|
|
110
|
+
function walk(d: string) {
|
|
111
|
+
for (const entry of readdirSync(d, { withFileTypes: true })) {
|
|
112
|
+
const full = join(d, entry.name);
|
|
113
|
+
if (entry.isDirectory() && !entry.name.startsWith(".") && entry.name !== "node_modules") {
|
|
114
|
+
walk(full);
|
|
115
|
+
} else if (entry.isFile() && (entry.name.endsWith(ext) || entry.name.endsWith(ext + "x"))) {
|
|
116
|
+
results.push(full);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
walk(dir);
|
|
121
|
+
return results;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function getImports(filePath: string): string[] {
|
|
125
|
+
const content = readFileSync(filePath, "utf-8");
|
|
126
|
+
const imports: string[] = [];
|
|
127
|
+
const importRegex = /(?:import|from)\\s+['"](.*?)['"]/g;
|
|
128
|
+
let match;
|
|
129
|
+
while ((match = importRegex.exec(content)) !== null) {
|
|
130
|
+
imports.push(match[1]);
|
|
131
|
+
}
|
|
132
|
+
return imports;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
describe("Architecture Fitness Functions", () => {
|
|
136
|
+
test("no circular directory dependencies at top level", () => {
|
|
137
|
+
// This is a placeholder — customize based on your project structure.
|
|
138
|
+
// Example: src/pages/ should not import from src/components/
|
|
139
|
+
// if components/ imports from pages/ (circular).
|
|
140
|
+
expect(true).toBe(true);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
test("CLAUDE.md exists", () => {
|
|
144
|
+
expect(existsSync(join(ROOT, "CLAUDE.md"))).toBe(true);
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test("CHANGELOG.md exists", () => {
|
|
148
|
+
expect(existsSync(join(ROOT, "CHANGELOG.md"))).toBe(true);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("no source file exceeds 500 lines", () => {
|
|
152
|
+
const srcDir = join(ROOT, "src");
|
|
153
|
+
if (!existsSync(srcDir)) return;
|
|
154
|
+
|
|
155
|
+
const violations: string[] = [];
|
|
156
|
+
for (const file of getSourceFiles(srcDir)) {
|
|
157
|
+
const lines = readFileSync(file, "utf-8").split("\\n").length;
|
|
158
|
+
if (lines > 500) {
|
|
159
|
+
violations.push(\`\${file.replace(ROOT + "/", "")} (\${lines} lines)\`);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
expect(violations).toEqual([]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("no hardcoded API keys in source", () => {
|
|
167
|
+
const srcDir = join(ROOT, "src");
|
|
168
|
+
if (!existsSync(srcDir)) return;
|
|
169
|
+
|
|
170
|
+
const keyPattern = /(ANTHROPIC|OPENAI|STRIPE|FIREBASE)_[A-Z_]*KEY\\s*=\\s*["'][a-zA-Z0-9]{20,}["']/;
|
|
171
|
+
const violations: string[] = [];
|
|
172
|
+
|
|
173
|
+
for (const file of getSourceFiles(srcDir)) {
|
|
174
|
+
const content = readFileSync(file, "utf-8");
|
|
175
|
+
if (keyPattern.test(content)) {
|
|
176
|
+
violations.push(file.replace(ROOT + "/", ""));
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
expect(violations).toEqual([]);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
183
|
+
`;
|
|
184
|
+
}
|
package/dist/cli.d.ts
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import { Command } from "commander";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import { runInit } from "./init.js";
|
|
4
|
+
import { runRollback } from "./rollback.js";
|
|
5
|
+
import { runDoctor } from "./doctor.js";
|
|
6
|
+
const program = new Command();
|
|
7
|
+
program
|
|
8
|
+
.name("soloship")
|
|
9
|
+
.description("Ship solo, safely — guardrails for AI-assisted development")
|
|
10
|
+
.version("0.1.0");
|
|
11
|
+
program
|
|
12
|
+
.command("init")
|
|
13
|
+
.description("Initialize Soloship in the current project")
|
|
14
|
+
.option("--skip-prompts", "Use defaults without asking questions")
|
|
15
|
+
.action(async (options) => {
|
|
16
|
+
console.log("");
|
|
17
|
+
console.log(chalk.bold("Soloship") + " — Ship Solo, Safely");
|
|
18
|
+
console.log(chalk.dim("Setting up mechanical enforcement, documentation infrastructure, and workflow rules."));
|
|
19
|
+
console.log("");
|
|
20
|
+
try {
|
|
21
|
+
await runInit(options);
|
|
22
|
+
console.log("");
|
|
23
|
+
console.log(chalk.green.bold("Soloship initialized."));
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log("Next steps:");
|
|
26
|
+
console.log(chalk.dim(" Existing project: ") + "Run /audit in Claude Code");
|
|
27
|
+
console.log(chalk.dim(" New project: ") + "Run /bootstrap in Claude Code");
|
|
28
|
+
console.log("");
|
|
29
|
+
}
|
|
30
|
+
catch (err) {
|
|
31
|
+
console.error(chalk.red("Setup failed:"), err);
|
|
32
|
+
process.exit(1);
|
|
33
|
+
}
|
|
34
|
+
});
|
|
35
|
+
program
|
|
36
|
+
.command("rollback")
|
|
37
|
+
.description("Roll back to the last Soloship safety snapshot")
|
|
38
|
+
.action(async () => {
|
|
39
|
+
try {
|
|
40
|
+
await runRollback();
|
|
41
|
+
}
|
|
42
|
+
catch (err) {
|
|
43
|
+
console.error(chalk.red("Rollback failed:"), err);
|
|
44
|
+
process.exit(1);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
program
|
|
48
|
+
.command("doctor")
|
|
49
|
+
.description("Audit your Claude Code environment for Soloship companion dependencies")
|
|
50
|
+
.action(async () => {
|
|
51
|
+
console.log("");
|
|
52
|
+
console.log(chalk.bold("Soloship Doctor"));
|
|
53
|
+
console.log(chalk.dim("Checking plugins, MCP servers, global skills, and hooks against the Soloship manifest."));
|
|
54
|
+
console.log("");
|
|
55
|
+
try {
|
|
56
|
+
await runDoctor();
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
console.error(chalk.red("Doctor failed:"), err);
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
program.parse();
|
package/dist/detect.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
export interface ProjectInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
description: string;
|
|
4
|
+
stack: StackInfo;
|
|
5
|
+
hasGit: boolean;
|
|
6
|
+
hasClaude: boolean;
|
|
7
|
+
existingDocs: ExistingDocs;
|
|
8
|
+
}
|
|
9
|
+
export interface StackInfo {
|
|
10
|
+
language: "typescript" | "javascript" | "python" | "unknown";
|
|
11
|
+
framework: string | null;
|
|
12
|
+
packageManager: "npm" | "yarn" | "pnpm" | "bun" | "pip" | "unknown";
|
|
13
|
+
hasTests: boolean;
|
|
14
|
+
hasCi: boolean;
|
|
15
|
+
hasLinter: boolean;
|
|
16
|
+
hasFormatter: boolean;
|
|
17
|
+
}
|
|
18
|
+
export interface ExistingDocs {
|
|
19
|
+
hasClaudeMd: boolean;
|
|
20
|
+
hasAgentsMd: boolean;
|
|
21
|
+
hasChangelog: boolean;
|
|
22
|
+
hasReadme: boolean;
|
|
23
|
+
hasDocsDir: boolean;
|
|
24
|
+
hasPlansDir: boolean;
|
|
25
|
+
hasSolutionsDir: boolean;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Detect project type, stack, and existing infrastructure from the filesystem.
|
|
29
|
+
*/
|
|
30
|
+
export declare function detectProject(root: string): Partial<ProjectInfo>;
|
package/dist/detect.js
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Detect project type, stack, and existing infrastructure from the filesystem.
|
|
5
|
+
*/
|
|
6
|
+
export function detectProject(root) {
|
|
7
|
+
const stack = detectStack(root);
|
|
8
|
+
const existingDocs = detectExistingDocs(root);
|
|
9
|
+
const name = detectName(root);
|
|
10
|
+
return {
|
|
11
|
+
name,
|
|
12
|
+
stack,
|
|
13
|
+
hasGit: existsSync(join(root, ".git")),
|
|
14
|
+
hasClaude: existsSync(join(root, ".claude")),
|
|
15
|
+
existingDocs,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
function detectStack(root) {
|
|
19
|
+
const info = {
|
|
20
|
+
language: "unknown",
|
|
21
|
+
framework: null,
|
|
22
|
+
packageManager: "unknown",
|
|
23
|
+
hasTests: false,
|
|
24
|
+
hasCi: false,
|
|
25
|
+
hasLinter: false,
|
|
26
|
+
hasFormatter: false,
|
|
27
|
+
};
|
|
28
|
+
// Check for package.json (Node.js/JS/TS projects)
|
|
29
|
+
const pkgPath = join(root, "package.json");
|
|
30
|
+
if (existsSync(pkgPath)) {
|
|
31
|
+
try {
|
|
32
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
33
|
+
const allDeps = {
|
|
34
|
+
...pkg.dependencies,
|
|
35
|
+
...pkg.devDependencies,
|
|
36
|
+
};
|
|
37
|
+
// Language
|
|
38
|
+
if (allDeps["typescript"] || existsSync(join(root, "tsconfig.json"))) {
|
|
39
|
+
info.language = "typescript";
|
|
40
|
+
}
|
|
41
|
+
else {
|
|
42
|
+
info.language = "javascript";
|
|
43
|
+
}
|
|
44
|
+
// Framework detection
|
|
45
|
+
if (allDeps["react"])
|
|
46
|
+
info.framework = "react";
|
|
47
|
+
else if (allDeps["next"])
|
|
48
|
+
info.framework = "nextjs";
|
|
49
|
+
else if (allDeps["vue"])
|
|
50
|
+
info.framework = "vue";
|
|
51
|
+
else if (allDeps["svelte"])
|
|
52
|
+
info.framework = "svelte";
|
|
53
|
+
else if (allDeps["express"])
|
|
54
|
+
info.framework = "express";
|
|
55
|
+
else if (allDeps["fastify"])
|
|
56
|
+
info.framework = "fastify";
|
|
57
|
+
// Package manager
|
|
58
|
+
if (existsSync(join(root, "bun.lockb")) || existsSync(join(root, "bun.lock")))
|
|
59
|
+
info.packageManager = "bun";
|
|
60
|
+
else if (existsSync(join(root, "pnpm-lock.yaml")))
|
|
61
|
+
info.packageManager = "pnpm";
|
|
62
|
+
else if (existsSync(join(root, "yarn.lock")))
|
|
63
|
+
info.packageManager = "yarn";
|
|
64
|
+
else
|
|
65
|
+
info.packageManager = "npm";
|
|
66
|
+
// Tests
|
|
67
|
+
info.hasTests = !!(allDeps["jest"] ||
|
|
68
|
+
allDeps["vitest"] ||
|
|
69
|
+
allDeps["mocha"] ||
|
|
70
|
+
allDeps["@testing-library/react"] ||
|
|
71
|
+
pkg.scripts?.test);
|
|
72
|
+
// Linter
|
|
73
|
+
info.hasLinter = !!(allDeps["eslint"] ||
|
|
74
|
+
allDeps["biome"] ||
|
|
75
|
+
existsSync(join(root, ".eslintrc.json")) ||
|
|
76
|
+
existsSync(join(root, ".eslintrc.js")) ||
|
|
77
|
+
existsSync(join(root, "eslint.config.js")) ||
|
|
78
|
+
existsSync(join(root, "eslint.config.mjs")));
|
|
79
|
+
// Formatter
|
|
80
|
+
info.hasFormatter = !!(allDeps["prettier"] ||
|
|
81
|
+
allDeps["biome"] ||
|
|
82
|
+
existsSync(join(root, ".prettierrc")) ||
|
|
83
|
+
existsSync(join(root, ".prettierrc.json")));
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
// Invalid package.json, continue with defaults
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Check for Python
|
|
90
|
+
if (existsSync(join(root, "requirements.txt")) ||
|
|
91
|
+
existsSync(join(root, "pyproject.toml")) ||
|
|
92
|
+
existsSync(join(root, "setup.py"))) {
|
|
93
|
+
info.language = "python";
|
|
94
|
+
info.packageManager = "pip";
|
|
95
|
+
}
|
|
96
|
+
// CI detection
|
|
97
|
+
info.hasCi =
|
|
98
|
+
existsSync(join(root, ".github", "workflows")) ||
|
|
99
|
+
existsSync(join(root, ".gitlab-ci.yml")) ||
|
|
100
|
+
existsSync(join(root, ".circleci"));
|
|
101
|
+
return info;
|
|
102
|
+
}
|
|
103
|
+
function detectExistingDocs(root) {
|
|
104
|
+
return {
|
|
105
|
+
hasClaudeMd: existsSync(join(root, "CLAUDE.md")),
|
|
106
|
+
hasAgentsMd: existsSync(join(root, "AGENTS.md")),
|
|
107
|
+
hasChangelog: existsSync(join(root, "CHANGELOG.md")),
|
|
108
|
+
hasReadme: existsSync(join(root, "README.md")) ||
|
|
109
|
+
existsSync(join(root, "readme.md")),
|
|
110
|
+
hasDocsDir: existsSync(join(root, "docs")),
|
|
111
|
+
hasPlansDir: existsSync(join(root, "docs", "plans")),
|
|
112
|
+
hasSolutionsDir: existsSync(join(root, "docs", "solutions")),
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function detectName(root) {
|
|
116
|
+
const pkgPath = join(root, "package.json");
|
|
117
|
+
if (existsSync(pkgPath)) {
|
|
118
|
+
try {
|
|
119
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
|
|
120
|
+
return pkg.name;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return undefined;
|
|
127
|
+
}
|
package/dist/doctor.d.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `soloship doctor` — audit the user's Claude Code environment against
|
|
3
|
+
* Soloship's dependency manifest.
|
|
4
|
+
*
|
|
5
|
+
* Reads the local filesystem only — no network calls. Reports what's present,
|
|
6
|
+
* what's missing, and how to install the missing pieces. Exits 0 if all
|
|
7
|
+
* REQUIRED dependencies are present (recommended ones are informational),
|
|
8
|
+
* exits 1 if any required dep is missing.
|
|
9
|
+
*/
|
|
10
|
+
export declare function runDoctor(): Promise<void>;
|
package/dist/doctor.js
ADDED
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `soloship doctor` — audit the user's Claude Code environment against
|
|
3
|
+
* Soloship's dependency manifest.
|
|
4
|
+
*
|
|
5
|
+
* Reads the local filesystem only — no network calls. Reports what's present,
|
|
6
|
+
* what's missing, and how to install the missing pieces. Exits 0 if all
|
|
7
|
+
* REQUIRED dependencies are present (recommended ones are informational),
|
|
8
|
+
* exits 1 if any required dep is missing.
|
|
9
|
+
*/
|
|
10
|
+
import chalk from "chalk";
|
|
11
|
+
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
12
|
+
import { join } from "node:path";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { SOLOSHIP_MANIFEST, } from "./manifest.js";
|
|
15
|
+
export async function runDoctor() {
|
|
16
|
+
const home = homedir();
|
|
17
|
+
const settingsPath = join(home, ".claude", "settings.json");
|
|
18
|
+
const claudeJsonPath = join(home, ".claude.json");
|
|
19
|
+
const skillsDir = join(home, ".claude", "skills");
|
|
20
|
+
const settings = readJsonSafe(settingsPath);
|
|
21
|
+
const claudeJson = readJsonSafe(claudeJsonPath);
|
|
22
|
+
console.log(chalk.dim("Checking your Claude Code environment..."));
|
|
23
|
+
console.log("");
|
|
24
|
+
const sections = [
|
|
25
|
+
{
|
|
26
|
+
title: "Plugins",
|
|
27
|
+
results: SOLOSHIP_MANIFEST.plugins.map((dep) => checkPlugin(dep, settings)),
|
|
28
|
+
},
|
|
29
|
+
{
|
|
30
|
+
title: "MCP servers",
|
|
31
|
+
results: SOLOSHIP_MANIFEST.mcpServers.map((dep) => checkMcpServer(dep, claudeJson)),
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
title: "Global skills",
|
|
35
|
+
results: SOLOSHIP_MANIFEST.skills.map((dep) => checkSkill(dep, skillsDir)),
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
title: "Hooks",
|
|
39
|
+
results: SOLOSHIP_MANIFEST.hooks.map((dep) => checkHook(dep, settings)),
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
for (const section of sections) {
|
|
43
|
+
printSection(section);
|
|
44
|
+
}
|
|
45
|
+
const exitCode = printSummary(sections);
|
|
46
|
+
process.exit(exitCode);
|
|
47
|
+
}
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Individual check functions
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
function checkPlugin(dep, settings) {
|
|
52
|
+
const enabled = settings?.enabledPlugins || {};
|
|
53
|
+
// Plugin keys in settings.json look like "superpowers@superpowers-marketplace"
|
|
54
|
+
const expectedKey = `${dep.id}@${dep.source}`;
|
|
55
|
+
// Also accept a bare id match for marketplaces whose names we may not know
|
|
56
|
+
// precisely — any key starting with "dep.id@" counts as present.
|
|
57
|
+
const present = Object.keys(enabled).some((key) => key === expectedKey || key.startsWith(`${dep.id}@`));
|
|
58
|
+
return {
|
|
59
|
+
name: dep.id,
|
|
60
|
+
present,
|
|
61
|
+
severity: dep.severity,
|
|
62
|
+
purpose: dep.purpose,
|
|
63
|
+
install: dep.install,
|
|
64
|
+
usedBy: dep.usedBy,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
function checkMcpServer(dep, claudeJson) {
|
|
68
|
+
const servers = claudeJson?.mcpServers || {};
|
|
69
|
+
const present = Object.prototype.hasOwnProperty.call(servers, dep.name);
|
|
70
|
+
return {
|
|
71
|
+
name: dep.name,
|
|
72
|
+
present,
|
|
73
|
+
severity: dep.severity,
|
|
74
|
+
purpose: dep.purpose,
|
|
75
|
+
install: dep.install,
|
|
76
|
+
notes: dep.notes,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function checkSkill(dep, skillsDir) {
|
|
80
|
+
let present = false;
|
|
81
|
+
if (existsSync(skillsDir)) {
|
|
82
|
+
try {
|
|
83
|
+
const entries = readdirSync(skillsDir);
|
|
84
|
+
present = entries.includes(dep.name);
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Permission error or similar — treat as missing
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return {
|
|
91
|
+
name: dep.name,
|
|
92
|
+
present,
|
|
93
|
+
severity: dep.severity,
|
|
94
|
+
purpose: dep.purpose,
|
|
95
|
+
install: dep.install,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
function checkHook(dep, settings) {
|
|
99
|
+
const hooks = settings?.hooks || {};
|
|
100
|
+
const eventEntries = hooks[dep.event] || [];
|
|
101
|
+
const present = eventEntries.some((entry) => {
|
|
102
|
+
const matcher = entry.matcher;
|
|
103
|
+
if (dep.matcher !== null && matcher !== dep.matcher) {
|
|
104
|
+
return false;
|
|
105
|
+
}
|
|
106
|
+
const nestedHooks = entry.hooks || [];
|
|
107
|
+
return nestedHooks.some((h) => {
|
|
108
|
+
const command = h.command;
|
|
109
|
+
return typeof command === "string" && command.includes(dep.commandContains);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
const matcherLabel = dep.matcher ? `(${dep.matcher})` : "";
|
|
113
|
+
return {
|
|
114
|
+
name: `${dep.event}${matcherLabel}`,
|
|
115
|
+
present,
|
|
116
|
+
severity: dep.severity,
|
|
117
|
+
purpose: dep.purpose,
|
|
118
|
+
install: dep.install,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
// ---------------------------------------------------------------------------
|
|
122
|
+
// Formatting
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
function printSection(section) {
|
|
125
|
+
const present = section.results.filter((r) => r.present).length;
|
|
126
|
+
const total = section.results.length;
|
|
127
|
+
const required = section.results.filter((r) => r.severity === "required").length;
|
|
128
|
+
const requiredPresent = section.results.filter((r) => r.severity === "required" && r.present).length;
|
|
129
|
+
const header = chalk.bold(section.title.toUpperCase()) +
|
|
130
|
+
chalk.dim(` (${present}/${total} present` +
|
|
131
|
+
(required > 0 ? `, ${requiredPresent}/${required} required` : "") +
|
|
132
|
+
")");
|
|
133
|
+
console.log(header);
|
|
134
|
+
for (const result of section.results) {
|
|
135
|
+
printResult(result);
|
|
136
|
+
}
|
|
137
|
+
console.log("");
|
|
138
|
+
}
|
|
139
|
+
function printResult(result) {
|
|
140
|
+
const marker = result.present
|
|
141
|
+
? chalk.green(" ✓")
|
|
142
|
+
: result.severity === "required"
|
|
143
|
+
? chalk.red(" ✗")
|
|
144
|
+
: chalk.yellow(" ✗");
|
|
145
|
+
const nameColumn = result.name.padEnd(28);
|
|
146
|
+
const severityTag = result.severity === "required" ? chalk.red("[required]") : chalk.dim("[recommended]");
|
|
147
|
+
console.log(`${marker} ${nameColumn} ${severityTag}`);
|
|
148
|
+
console.log(` ${chalk.dim(result.purpose)}`);
|
|
149
|
+
if (!result.present) {
|
|
150
|
+
console.log(` ${chalk.cyan("install:")} ${result.install}`);
|
|
151
|
+
if (result.notes) {
|
|
152
|
+
console.log(` ${chalk.cyan("notes:")} ${result.notes}`);
|
|
153
|
+
}
|
|
154
|
+
if (result.usedBy && result.usedBy.length > 0) {
|
|
155
|
+
console.log(` ${chalk.cyan("used by:")} ${result.usedBy.join(", ")}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
function printSummary(sections) {
|
|
160
|
+
const all = sections.flatMap((s) => s.results);
|
|
161
|
+
const requiredMissing = all.filter((r) => r.severity === "required" && !r.present);
|
|
162
|
+
const recommendedMissing = all.filter((r) => r.severity === "recommended" && !r.present);
|
|
163
|
+
console.log(chalk.bold("SUMMARY"));
|
|
164
|
+
if (requiredMissing.length === 0 && recommendedMissing.length === 0) {
|
|
165
|
+
console.log(chalk.green(" All dependencies present — environment is Soloship-ready."));
|
|
166
|
+
console.log("");
|
|
167
|
+
return 0;
|
|
168
|
+
}
|
|
169
|
+
if (requiredMissing.length > 0) {
|
|
170
|
+
console.log(chalk.red(` ${requiredMissing.length} required dependency missing:`));
|
|
171
|
+
for (const r of requiredMissing) {
|
|
172
|
+
console.log(` - ${r.name}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (recommendedMissing.length > 0) {
|
|
176
|
+
console.log(chalk.yellow(` ${recommendedMissing.length} recommended dependency missing:`));
|
|
177
|
+
for (const r of recommendedMissing) {
|
|
178
|
+
console.log(` - ${r.name}`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
console.log("");
|
|
182
|
+
if (requiredMissing.length > 0) {
|
|
183
|
+
console.log(chalk.red("Some Soloship skills will not work until the required dependencies are installed."));
|
|
184
|
+
console.log("");
|
|
185
|
+
return 1;
|
|
186
|
+
}
|
|
187
|
+
console.log(chalk.dim("Recommended dependencies improve non-coder workflow but are not strictly required."));
|
|
188
|
+
console.log("");
|
|
189
|
+
return 0;
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
192
|
+
// Helpers
|
|
193
|
+
// ---------------------------------------------------------------------------
|
|
194
|
+
function readJsonSafe(path) {
|
|
195
|
+
if (!existsSync(path)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
try {
|
|
199
|
+
const raw = readFileSync(path, "utf-8");
|
|
200
|
+
return JSON.parse(raw);
|
|
201
|
+
}
|
|
202
|
+
catch {
|
|
203
|
+
return null;
|
|
204
|
+
}
|
|
205
|
+
}
|
package/dist/hooks.d.ts
ADDED