diff --git a/packages/loopover-mcp/bin/loopover-mcp.js b/packages/loopover-mcp/bin/loopover-mcp.js index ec0ce3cee1..369c3fb865 100755 --- a/packages/loopover-mcp/bin/loopover-mcp.js +++ b/packages/loopover-mcp/bin/loopover-mcp.js @@ -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"]; @@ -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", @@ -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 | reject | pause | resume | set-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 | reject | pause | resume | set-level | precision | onboarding-pack.`, + ); } async function runCli(args) { diff --git a/test/unit/mcp-cli-basics.test.ts b/test/unit/mcp-cli-basics.test.ts index 4ca445fe53..3ab511bb1c 100644 --- a/test/unit/mcp-cli-basics.test.ts +++ b/test/unit/mcp-cli-basics.test.ts @@ -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", () => { diff --git a/test/unit/mcp-cli-maintain.test.ts b/test/unit/mcp-cli-maintain.test.ts index f80641974b..227960d717 100644 --- a/test/unit/mcp-cli-maintain.test.ts +++ b/test/unit/mcp-cli-maintain.test.ts @@ -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 @@ -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" }; } @@ -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/); @@ -137,5 +153,6 @@ describe("loopover-mcp CLI — maintain (#784)", () => { expect(out).toMatch(/approve /); expect(out).toMatch(/queue/); expect(out).toMatch(/pause/); + expect(out).toMatch(/onboarding-pack/); }); }); diff --git a/test/unit/support/mcp-cli-harness.ts b/test/unit/support/mcp-cli-harness.ts index fa2eec3666..9c6b601c9f 100644 --- a/test/unit/support/mcp-cli-harness.ts +++ b/test/unit/support/mcp-cli-harness.ts @@ -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. */ @@ -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({