Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 129 additions & 4 deletions packages/gittensory-mcp/bin/gittensory-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -1671,6 +1671,8 @@ async function doctor(options) {
remediation: sanitizeDiagnosticText(remediation, [options.cwd]),
}),
);
let authLogin = options.login ?? activeProfile.session?.login;
let repoFullName = typeof options.repo === "string" ? options.repo : undefined;

let health = null;
try {
Expand Down Expand Up @@ -1718,6 +1720,7 @@ async function doctor(options) {
} else {
try {
const session = await apiGet("/v1/auth/session");
authLogin = session.login ?? authLogin;
add("auth", "pass", `Profile ${activeProfileName} authenticated as ${session.login}; session expires ${session.expiresAt}.`);
} catch (error) {
add("auth", "warn", `A token is configured for profile ${activeProfileName} but no user session was verified: ${error instanceof Error ? error.message : "session_check_failed"}.`, "If this is a static beta token, this can be expected. Otherwise run `gittensory-mcp login`.");
Expand Down Expand Up @@ -1745,6 +1748,7 @@ async function doctor(options) {
repoFullName: options.repo,
login: options.login ?? activeProfile.session?.login ?? "local",
});
repoFullName = metadata.repoFullName ?? repoFullName;
add("git_metadata", "pass", `${metadata.repoFullName} on ${metadata.branchName}; ${metadata.changedFiles.length} changed file(s).`);
} catch (error) {
add("git_metadata", "warn", error instanceof Error ? error.message : "git_metadata_failed", "Run from a git repo or pass --repo owner/repo.");
Expand Down Expand Up @@ -1778,26 +1782,147 @@ async function doctor(options) {
add("gittensor_root", "warn", "Python gittensor scorer is configured but GITTENSOR_ROOT is unset.", "Set GITTENSOR_ROOT to a local entrius/gittensor checkout.");
}

const statusValue = doctorStatus(checks);
const checklist = buildDoctorChecklist(checks, {
status: statusValue,
profileName: activeProfileName,
login: authLogin,
repoFullName,
});
const nextCommand = checklist.find((group) => group.id === "next_command")?.nextCommand;
const payload = {
status: checks.some((check) => check.status === "fail") ? "needs_attention" : checks.some((check) => check.status === "warn") ? "warnings" : "ok",
status: statusValue,
apiUrl,
profile: profilePublicState(activeProfileName),
config: { configured: existsSync(configPath), activeProfile: activeProfileName, profileCount: profileList(config).length },
decisionPackCache,
sourceUploadSupported: false,
checklist,
nextCommand,
checks,
};
if (options.json) process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
else {
process.stdout.write(`Gittensory doctor: ${payload.status}\n`);
process.stdout.write(`Profile: ${activeProfileName}\n`);
for (const check of checks) {
process.stdout.write(`- ${check.status}: ${check.name} - ${check.detail}\n`);
if (check.remediation) process.stdout.write(` ${check.remediation}\n`);
for (const group of checklist) {
process.stdout.write(`\n${group.title}: ${group.status}\n`);
if (group.id === "next_command") {
process.stdout.write(`- ${group.detail}\n`);
if (group.nextCommand?.command) process.stdout.write(` ${group.nextCommand.command}\n`);
continue;
}
for (const check of group.checks ?? []) {
process.stdout.write(`- ${check.status}: ${check.name} - ${check.detail}\n`);
if (check.remediation) process.stdout.write(` ${check.remediation}\n`);
}
}
}
}

function doctorStatus(checks) {
if (checks.some((check) => check.status === "fail")) return "needs_attention";
if (checks.some((check) => check.status === "warn")) return "warnings";
return "ok";
}

function buildDoctorChecklist(checks, context) {
const byName = new Map(checks.map((check) => [check.name, check]));
const groups = doctorChecklistGroups().map((group) => {
const groupChecks = group.checks.map((name) => byName.get(name)).filter(Boolean);
return stripUndefined({
id: group.id,
title: group.title,
status: checklistStatus(groupChecks),
checks: groupChecks,
});
});
const nextCommand = doctorNextCommand(byName, context);
return [
...groups,
stripUndefined({
id: "next_command",
title: "Next command",
status: context.status === "needs_attention" ? "fail" : context.status === "warnings" ? "warn" : "pass",
detail: nextCommand.reason,
nextCommand,
}),
];
}

function doctorChecklistGroups() {
return [
{ id: "auth", title: "Auth", checks: ["auth"] },
{ id: "api_compatibility", title: "API compatibility", checks: ["api_health", "version", "api_compatibility"] },
{ id: "local_repo_readiness", title: "Local repo readiness", checks: ["git_metadata", "client_path"] },
{ id: "scorer_availability", title: "Scorer availability", checks: ["local_scorer", "gittensor_root"] },
{ id: "output_safety", title: "Output safety", checks: ["source_upload", "decision_pack_cache"] },
];
}

function checklistStatus(checks) {
if (checks.some((check) => check.status === "fail")) return "fail";
if (checks.some((check) => check.status === "warn")) return "warn";
return "pass";
}

function doctorNextCommand(byName, context) {
const sourceUpload = byName.get("source_upload");
if (sourceUpload?.status === "fail") {
return {
command: "unset GITTENSORY_UPLOAD_SOURCE",
reason: "Disable source upload first; the local MCP wrapper only sends metadata.",
};
}
const apiCompatibility = byName.get("api_compatibility");
if (apiCompatibility?.status === "fail") {
return {
command: apiCompatibility.remediation ?? upgradeCommand,
reason: "Upgrade the MCP package before relying on API-backed commands.",
};
}
const auth = byName.get("auth");
if (auth?.status === "fail") {
return {
command: `gittensory-mcp login --profile ${context.profileName}`,
reason: "Authenticate the active profile so doctor, plan, preflight, and packet commands can call the API.",
};
}
const apiHealth = byName.get("api_health");
if (apiHealth?.status === "fail") {
return {
command: "gittensory-mcp status --json",
reason: "Check API reachability before running planner or preflight commands.",
};
}
const version = byName.get("version");
if (version?.status === "warn" && version.remediation?.includes("npm install")) {
return {
command: upgradeCommand,
reason: "Update the MCP package so local behavior matches the current API.",
};
}
const gitMetadata = byName.get("git_metadata");
if (gitMetadata?.status === "warn") {
return {
command: "gittensory-mcp doctor --repo owner/repo --json",
reason: "Run doctor from a git checkout or pass the repository explicitly.",
};
}
const localScorer = byName.get("local_scorer");
if (localScorer?.status === "warn" && localScorer.remediation) {
const scorerSetupCommand = localScorer.remediation.startsWith("Example: ") ? localScorer.remediation.replace(/^Example:\s*/, "") : "gittensory-mcp doctor --json";
return {
command: scorerSetupCommand,
reason: "Configure the optional local scorer for richer private branch analysis.",
};
}
return {
command: `gittensory-mcp preflight --login ${context.login ?? "<github-login>"} --repo ${context.repoFullName ?? "owner/repo"} --json`,
reason: "Run branch preflight next; source upload remains disabled.",
};
}

function initClient(options) {
const client = String(options.print ?? options.client ?? "").toLowerCase();
if (!client) throw new Error("Pass --print codex, --print claude, --print cursor, or --print mcp.");
Expand Down
110 changes: 109 additions & 1 deletion test/unit/mcp-cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,12 +50,32 @@ describe("gittensory-mcp CLI", () => {
GITTENSOR_SCORE_PREVIEW_CMD: `node ${join(process.cwd(), "test/fixtures/local-scorer/scorer-malformed.mjs")}`,
GITTENSORY_SKIP_NPM_VERSION_CHECK: "true",
}),
) as { status: string; config: { configured: boolean }; checks: Array<{ name: string; status: string; detail: string; remediation?: string }> };
) as {
status: string;
config: { configured: boolean };
checklist: Array<{ id: string; title: string; status: string; checks?: Array<{ name: string; status: string; detail: string; remediation?: string }> }>;
nextCommand: { command: string; reason: string };
checks: Array<{ name: string; status: string; detail: string; remediation?: string }>;
};

const serialized = JSON.stringify(payload);
expect(payload.status).toMatch(/ok|warnings/);
expect(serialized).not.toMatch(/secret-gittensor|secret-config/);
expect(payload.config.configured).toBe(true);
expect(payload.checklist).toEqual(
expect.arrayContaining([
expect.objectContaining({ id: "auth", title: "Auth", status: "pass" }),
expect.objectContaining({ id: "api_compatibility", title: "API compatibility", status: "pass" }),
expect.objectContaining({ id: "local_repo_readiness", title: "Local repo readiness", status: "pass" }),
expect.objectContaining({ id: "scorer_availability", title: "Scorer availability", status: "warn" }),
expect.objectContaining({ id: "output_safety", title: "Output safety", status: "pass" }),
expect.objectContaining({ id: "next_command", title: "Next command", status: "warn" }),
]),
);
expect(payload.nextCommand).toMatchObject({
command: "gittensory-mcp doctor --json",
reason: expect.stringContaining("local scorer"),
});
expect(payload.checks).toEqual(
expect.arrayContaining([
expect.objectContaining({ name: "api_health", status: "pass" }),
Expand All @@ -73,6 +93,35 @@ describe("gittensory-mcp CLI", () => {
expect(localScorer?.detail).not.toMatch(join(process.cwd(), "test/fixtures"));
});

it("uses doctor as a first-run auth checklist when no local session is configured", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer();
const payload = JSON.parse(
await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], {
GITTENSORY_API_URL: url,
GITTENSORY_API_TOKEN: "",
GITTENSORY_TOKEN: "",
GITTENSORY_MCP_TOKEN: "",
GITTENSORY_CONFIG_DIR: tempDir,
GITTENSORY_SKIP_NPM_VERSION_CHECK: "true",
}),
) as {
status: string;
checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string }> }>;
nextCommand: { command: string; reason: string };
};

const auth = payload.checklist.find((group) => group.id === "auth");
expect(payload.status).toBe("needs_attention");
expect(auth).toMatchObject({ status: "fail" });
expect(auth?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "auth", status: "fail" })]));
expect(payload.nextCommand).toMatchObject({
command: "gittensory-mcp login --profile default",
reason: expect.stringContaining("Authenticate"),
});
expect(JSON.stringify(payload)).not.toContain(tempDir);
});

it("reports a stale global install with an exact upgrade command and npx fallback", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer({ latestVersion: "9.9.9" });
Expand Down Expand Up @@ -266,6 +315,8 @@ describe("gittensory-mcp CLI", () => {
});

const doctor = JSON.parse(await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], env)) as {
checklist: Array<{ id: string; status: string }>;
nextCommand: { command: string; reason: string };
checks: Array<{ name: string; status: string; remediation?: string }>;
};
expect(doctor.checks).toEqual(
Expand All @@ -277,6 +328,63 @@ describe("gittensory-mcp CLI", () => {
}),
]),
);
expect(doctor.checklist).toEqual(expect.arrayContaining([expect.objectContaining({ id: "api_compatibility", status: "fail" })]));
expect(doctor.nextCommand).toMatchObject({
command: "npm install -g @jsonbored/gittensory-mcp@latest",
reason: expect.stringContaining("Upgrade"),
});
});

it("keeps source upload unsupported and fail-closed in the doctor checklist", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer();
const payload = JSON.parse(
await runAsync(["doctor", "--cwd", tempDir, "--repo", "JSONbored/gittensory", "--json"], {
GITTENSORY_API_URL: url,
GITTENSORY_TOKEN: "session-token",
GITTENSORY_CONFIG_DIR: tempDir,
GITTENSORY_SKIP_NPM_VERSION_CHECK: "true",
GITTENSORY_UPLOAD_SOURCE: "true",
}),
) as {
sourceUploadSupported: boolean;
checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string; remediation?: string }> }>;
nextCommand: { command: string; reason: string };
};

const outputSafety = payload.checklist.find((group) => group.id === "output_safety");
expect(payload.sourceUploadSupported).toBe(false);
expect(outputSafety).toMatchObject({ status: "fail" });
expect(outputSafety?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "source_upload", status: "fail" })]));
expect(payload.nextCommand).toMatchObject({
command: "unset GITTENSORY_UPLOAD_SOURCE",
reason: expect.stringContaining("metadata"),
});
});

it("points missing local repo readiness at an explicit repo-aware doctor command", async () => {
tempDir = mkdtempSync(join(tmpdir(), "gittensory-cli-"));
const url = await startFixtureServer();
const payload = JSON.parse(
await runAsync(["doctor", "--cwd", tempDir, "--json"], {
GITTENSORY_API_URL: url,
GITTENSORY_TOKEN: "session-token",
GITTENSORY_CONFIG_DIR: tempDir,
GITTENSORY_SKIP_NPM_VERSION_CHECK: "true",
}),
) as {
checklist: Array<{ id: string; status: string; checks?: Array<{ name: string; status: string; detail: string }> }>;
nextCommand: { command: string; reason: string };
};

const repoReadiness = payload.checklist.find((group) => group.id === "local_repo_readiness");
expect(repoReadiness).toMatchObject({ status: "warn" });
expect(repoReadiness?.checks).toEqual(expect.arrayContaining([expect.objectContaining({ name: "git_metadata", status: "warn" })]));
expect(payload.nextCommand).toMatchObject({
command: "gittensory-mcp doctor --repo owner/repo --json",
reason: expect.stringContaining("git checkout"),
});
expect(JSON.stringify(payload)).not.toContain(tempDir);
});

it("does not print configured tokens or local absolute paths in status or doctor output", async () => {
Expand Down