Skip to content
Merged
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
23 changes: 21 additions & 2 deletions packages/loopover-mcp/bin/loopover-mcp.js
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ const CLI_COMMAND_SPEC = {
profile: ["list", "create", "switch", "remove"],
cache: ["status", "clear", "list"],
agent: ["plan", "status", "explain", "packet"],
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision"],
maintain: ["status", "queue", "approve", "reject", "pause", "resume", "set-level", "precision", "onboarding-pack"],
};
const COMPLETION_SHELLS = ["bash", "zsh", "fish", "powershell"];
const AGENT_PROFILE_IDS = ["miner-planner", "miner-auto-dev", "maintainer-triage", "repo-owner-intake"];
Expand Down Expand Up @@ -2829,6 +2829,7 @@ function printMaintainHelp() {
` actions: ${MAINTAIN_ACTION_CLASSES.join(", ")}`,
` levels: ${MAINTAIN_AUTONOMY_LEVELS.join(", ")}`,
" precision [--window-days N] Show gate false-positive telemetry (blocked-then-merged per gate type).",
" onboarding-pack [--refresh] Preview the repo's contributor onboarding pack.",
"",
"Pass --json for machine-readable output.",
].join("\n") + "\n",
Expand Down Expand Up @@ -2935,7 +2936,25 @@ async function maintainCli(args) {
emit(payload, lines.join("\n"));
return;
}
throw new Error(`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision.`);
if (subcommand === "onboarding-pack") {
// #6738: session-authenticated mirror of GET /onboarding-pack/preview (and the remote
// loopover_get_repo_onboarding_pack tool). Bare `--refresh` becomes options.refresh === true via
// parseOptions; omit the query otherwise so the default matches the precision-style GET pattern
// (server treats only the exact string "true" as a refresh).
const query = options.refresh === true ? "?refresh=true" : "";
const payload = await apiGet(`${repoBase}/onboarding-pack/preview${query}`);
emit(
payload,
[
`LoopOver onboarding pack preview for ${repoFullName} (preview-only, not published).`,
sanitizePlainTextTerminalOutput(JSON.stringify(payload.preview ?? payload, null, 2)),
].join("\n"),
);
return;
}
throw new Error(
`Unknown maintain subcommand: ${subcommand}. Use status | queue | approve <id> | reject <id> | pause | resume | set-level <action> <level> | precision | onboarding-pack.`,
);
}

async function runCli(args) {
Expand Down
4 changes: 3 additions & 1 deletion test/unit/mcp-cli-basics.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -220,7 +220,9 @@ describe("loopover-mcp CLI — basics", () => {
expect(ps).toContain("Register-ArgumentCompleter -Native -CommandName loopover-mcp");
expect(ps).toContain("[System.Management.Automation.CompletionResult]::new");
expect(ps).toContain("$commands = @('login', 'logout'");
expect(ps).toContain("'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision')");
expect(ps).toContain(
"'maintain' = @('status', 'queue', 'approve', 'reject', 'pause', 'resume', 'set-level', 'precision', 'onboarding-pack')",
);
});

it("emits completion as machine-readable json", () => {
Expand Down
23 changes: 20 additions & 3 deletions test/unit/mcp-cli-maintain.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import { AUTONOMY_LEVELS } from "../../src/settings/autonomy";
import { closeFixtureServer, runAsync, startFixtureServer } from "./support/mcp-cli-harness";
import { closeFixtureServer, repoOnboardingPackFixture, runAsync, startFixtureServer } from "./support/mcp-cli-harness";

// #6153: MAINTAIN_AUTONOMY_LEVELS is a hand-synced copy of the live enum (the CLI reaches @loopover/engine only
// through its published export map, which doesn't surface AUTONOMY_LEVELS), so nothing but a test can catch the
Expand All @@ -26,9 +26,9 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
tempDir = null;
});

async function env() {
async function env(onApiRequest?: (request: import("node:http").IncomingMessage) => void) {
tempDir = mkdtempSync(join(tmpdir(), "loopover-cli-"));
const url = await startFixtureServer();
const url = await startFixtureServer(onApiRequest ? { onApiRequest } : {});
return { LOOPOVER_API_URL: url, LOOPOVER_TOKEN: "session-token", LOOPOVER_CONFIG_DIR: tempDir, LOOPOVER_API_TIMEOUT_MS: "1000" };
}

Expand Down Expand Up @@ -95,6 +95,22 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
expect(scoped).toMatch(/Gate precision for owner\/repo \(last 30d\)/);
});

it("onboarding-pack mirrors the session-gated API payload and forwards refresh", async () => {
const requests: string[] = [];
const e = await env((request) => requests.push(request.url ?? ""));

const json = JSON.parse(
await runAsync(["maintain", "onboarding-pack", "--repo", "owner/repo", "--refresh", "--json"], e),
);
expect(json).toEqual(repoOnboardingPackFixture);
expect(requests.at(-1)).toBe("/v1/repos/owner/repo/onboarding-pack/preview?refresh=true");

const plain = await runAsync(["maintain", "onboarding-pack", "--repo", "owner/repo"], e);
expect(plain).toContain("LoopOver onboarding pack preview for owner/repo (preview-only, not published).");
expect(plain).toContain(repoOnboardingPackFixture.preview.previewMarkdown);
expect(requests.at(-1)).toBe("/v1/repos/owner/repo/onboarding-pack/preview");
});

it("validates inputs: --repo required, id required for approve, known subcommand + action/level", async () => {
const e = await env();
await expect(runAsync(["maintain", "status"], e)).rejects.toThrow(/Pass --repo/);
Expand Down Expand Up @@ -137,5 +153,6 @@ describe("loopover-mcp CLI — maintain (#784)", () => {
expect(out).toMatch(/approve <id>/);
expect(out).toMatch(/queue/);
expect(out).toMatch(/pause/);
expect(out).toMatch(/onboarding-pack/);
});
});
30 changes: 30 additions & 0 deletions test/unit/support/mcp-cli-harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,28 @@ import { join } from "node:path";
import { expect } from "vitest";

export const bin = join(process.cwd(), "packages/loopover-mcp/bin/loopover-mcp.js");
export const repoOnboardingPackFixture = {
repoFullName: "owner/repo",
accepted: true,
policySource: "policy_compiler",
preview: {
repoFullName: "owner/repo",
generatedAt: "2026-07-17T00:00:00.000Z",
source: "policy_compiler",
previewOnly: true,
publicSafe: true,
contributionLanes: [],
labelPolicy: { preferredLabels: ["help wanted"], requiredLabels: [], discouragedLabels: [], note: null },
validationExpectations: ["npm test"],
readinessWarnings: [],
maintainerExpectations: [],
publicOutputBoundaries: ["Preview only."],
previewMarkdown: "# Contributor onboarding",
droppedPublicItems: [],
privateOwnerContext: { itemCount: 0, includedInPublicPreview: false },
publication: { status: "preview_only", allowed: false, actions: [], reason: "Preview-only output." },
},
} as const;
let server: Server | null = null;

/** #6261: put `injection` in every free-text field of a slop assessment that reaches plain-text output. */
Expand Down Expand Up @@ -464,6 +486,14 @@ export async function startFixtureServer(
);
return;
}
const onboardingPackUrl = new URL(request.url ?? "/", "http://localhost");
if (onboardingPackUrl.pathname === "/v1/repos/owner/repo/onboarding-pack/preview" && request.method === "GET") {
const refresh = onboardingPackUrl.searchParams.get("refresh");
if (refresh === null || refresh === "true") {
response.end(JSON.stringify(repoOnboardingPackFixture));
return;
}
}
if (request.url === "/v1/upstream/drift" && request.method === "GET") {
response.end(
JSON.stringify({
Expand Down