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
24 changes: 20 additions & 4 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { version } from "../package.json";
import {
registerAuthStatusCommand,
registerCodeCommandGroup,
registerDocsCommandGroup,
registerExampleCommand,
registerFeedbackCommand,
registerInitCommand,
Expand Down Expand Up @@ -103,6 +104,11 @@ if (shouldEagerLoadGatedCommandGroup(argv, "pkg")) {
registerPkgCommandGroup(program, helpRegistrationOptions),
);
}
if (shouldEagerLoadGatedCommandGroup(argv, "docs")) {
await withTelemetrySpan("cli.register.docs-group", () =>
registerDocsCommandGroup(program, helpRegistrationOptions),
);
}

// Auth status as subcommand of `auth`
const authCommand = program
Expand All @@ -127,7 +133,11 @@ function shouldEagerLoadGatedCommandGroup(
): boolean {
const [firstArg] = args;
return (
firstArg === groupName || (firstArg === "help" && args[1] === groupName)
args.length === 0 ||
firstArg === groupName ||
(firstArg === "help" && (!args[1] || args[1] === groupName)) ||
firstArg === "--help" ||
firstArg === "-h"
);
}

Expand Down Expand Up @@ -159,12 +169,16 @@ function needsGatedHelpRegistration(args: string[]): boolean {
return (
isSearchHelpTarget(secondArg) ||
secondArg === "code" ||
secondArg === "pkg"
secondArg === "pkg" ||
secondArg === "docs"
);
}

return (
isSearchHelpTarget(firstArg) || firstArg === "code" || firstArg === "pkg"
isSearchHelpTarget(firstArg) ||
firstArg === "code" ||
firstArg === "pkg" ||
firstArg === "docs"
);
}

Expand Down Expand Up @@ -193,10 +207,12 @@ function shouldUseExpiredStoredAuthFallbackForHelp(args: string[]): boolean {
firstArg === "search-status" ||
firstArg === "code" ||
firstArg === "pkg" ||
firstArg === "docs" ||
(firstArg === "help" &&
(isSearchHelpTarget(secondArg) ||
secondArg === "code" ||
secondArg === "pkg"))
secondArg === "pkg" ||
secondArg === "docs"))
);
}

Expand Down
36 changes: 7 additions & 29 deletions src/commands/code/index.ts
Original file line number Diff line number Diff line change
@@ -1,20 +1,16 @@
import type { Command } from "commander";
import { resolveStartupCodeNavigationRegistrationState } from "../../container.js";
import type { CodeNavigationCapability } from "../../services/index.js";
import {
type CodeNavigationCapability,
getCodeNavigationUrl,
isCodeNavigationCliOverrideEnabled,
} from "../../services/index.js";
type GatedCommandGroupOptions,
resolveGatedCommandGroupRegistrationState,
} from "../gated-command-group.js";
import { registerCodeFilesCommand } from "./files.js";
import { registerCodeGrepCommand } from "./grep.js";
import { registerCodeReadCommand } from "./read.js";
import { registerCodeSearchSymbolsCommand } from "./search-symbols.js";

export interface CodeCommandGroupOptions {
codeNavigationUrl?: string;
overrideEnabled?: boolean;
export interface CodeCommandGroupOptions extends GatedCommandGroupOptions {
capability?: CodeNavigationCapability;
expiredStoredAuth?: boolean;
}

/**
Expand All @@ -28,26 +24,8 @@ export async function registerCodeCommandGroup(
program: Command,
options: CodeCommandGroupOptions = {},
): Promise<void> {
const codeNavigationUrl = options.codeNavigationUrl ?? getCodeNavigationUrl();
if (!codeNavigationUrl) {
return;
}

const overrideEnabled =
options.overrideEnabled ?? isCodeNavigationCliOverrideEnabled();
const registrationState =
options.capability !== undefined || options.expiredStoredAuth !== undefined
? {
capability: options.capability ?? "unknown",
expiredStoredAuth: options.expiredStoredAuth ?? false,
}
: await resolveStartupCodeNavigationRegistrationState();

if (
!overrideEnabled &&
registrationState.capability !== "enabled" &&
!registrationState.expiredStoredAuth
) {
const registration = await resolveGatedCommandGroupRegistrationState(options);
if (!registration.shouldRegister) {
return;
}

Expand Down
38 changes: 38 additions & 0 deletions src/commands/docs/index.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from "bun:test";
import { Command } from "commander";
import { registerDocsCommandGroup } from "./index.js";

describe("registerDocsCommandGroup", () => {
it("does not register docs group without override or capability", async () => {
const program = new Command();
await registerDocsCommandGroup(program, {
codeNavigationUrl: "https://pkgseer.dev",
overrideEnabled: false,
capability: "disabled",
});

expect(program.commands.some((command) => command.name() === "docs")).toBe(
false,
);
});

it("registers docs group when capability is enabled", async () => {
const program = new Command();
await registerDocsCommandGroup(program, {
codeNavigationUrl: "https://pkgseer.dev",
overrideEnabled: false,
capability: "enabled",
});

const docsCommand = program.commands.find(
(command) => command.name() === "docs",
);
expect(docsCommand).toBeDefined();
expect(
docsCommand?.commands.some((command) => command.name() === "list"),
).toBe(true);
expect(
docsCommand?.commands.some((command) => command.name() === "read"),
).toBe(true);
});
});
32 changes: 32 additions & 0 deletions src/commands/docs/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import type { Command } from "commander";
import type { CodeNavigationCapability } from "../../services/index.js";
import {
type GatedCommandGroupOptions,
resolveGatedCommandGroupRegistrationState,
} from "../gated-command-group.js";
import { registerDocsListCommand } from "./list.js";
import { registerDocsReadCommand } from "./read.js";

export interface DocsCommandGroupOptions extends GatedCommandGroupOptions {
capability?: CodeNavigationCapability;
}

export async function registerDocsCommandGroup(
program: Command,
options: DocsCommandGroupOptions = {},
): Promise<void> {
const registration = await resolveGatedCommandGroupRegistrationState(options);
if (!registration.shouldRegister) {
return;
}

const docsCommand = program
.command("docs")
.summary("Browse and read mixed package documentation")
.description(
"Browse and read package documentation across hosted docs and repository-backed docs. Docs are mixed by default; entries are source-badged and repo-backed pages also expose exact file follow-up metadata.",
);

registerDocsListCommand(docsCommand);
registerDocsReadCommand(docsCommand);
}
103 changes: 103 additions & 0 deletions src/commands/docs/list.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { describe, expect, it, mock, spyOn } from "bun:test";
import { PackageIntelligenceTargetNotFoundError } from "../../services/index.js";
import {
createMockPackageIntelligenceService,
defaultPackageDocsList,
} from "../../services/test-helpers.js";
import { AuthRequiredError } from "../../shared/require-auth.js";
import { type DocsListCommandDependencies, docsListAction } from "./list.js";

describe("docsListAction", () => {
function createDeps(
overrides: Partial<DocsListCommandDependencies> = {},
): DocsListCommandDependencies {
return {
packageIntelligenceService: createMockPackageIntelligenceService(),
codeNavigationUrl: "https://pkgseer.dev",
hasValidToken: true,
mcpUrl: "https://mcp.githits.com",
...overrides,
};
}

it("renders source badges and page IDs in terminal output", async () => {
const writes: string[] = [];
const writeSpy = spyOn(process.stdout, "write").mockImplementation(((
chunk: string | Uint8Array,
) => {
writes.push(
typeof chunk === "string" ? chunk : new TextDecoder().decode(chunk),
);
return true;
}) as typeof process.stdout.write);

await docsListAction("npm:express@5.2.1", {}, createDeps());

const output = writes.join("");
expect(output).toContain("123-getting-started");
expect(output).toContain("[crawled]");
expect(output).toContain("[repo]");
writeSpy.mockRestore();
});

it("prints the lean JSON envelope when --json is provided", async () => {
const logSpy = spyOn(console, "log").mockImplementation(() => {});

await docsListAction("npm:express@5.2.1", { json: true }, createDeps());

const payload = JSON.parse(String(logSpy.mock.calls[0]?.[0]));
expect(payload.name).toBe("express");
expect(payload.pages[0].followUp.pageId).toBe("123-getting-started");
expect(payload.pages[1].readFile.path).toBe("README.md");
logSpy.mockRestore();
});

it("routes service classification through --json error envelope", async () => {
const errorSpy = spyOn(console, "error").mockImplementation(() => {});
const exitSpy = spyOn(process, "exit").mockImplementation(() => {
throw new Error("process.exit");
});

const service = createMockPackageIntelligenceService({
listPackageDocs: mock(() =>
Promise.reject(
new PackageIntelligenceTargetNotFoundError("Package not found"),
),
),
});

try {
await docsListAction(
"npm:ghost",
{ json: true },
createDeps({ packageIntelligenceService: service }),
);
} catch {
// expected
}

const payload = JSON.parse(String(errorSpy.mock.calls[0]?.[0]));
expect(payload.code).toBe("NOT_FOUND");
expect(payload.error).toBe("Package not found");
errorSpy.mockRestore();
exitSpy.mockRestore();
});

it("throws AuthRequiredError before calling the service when unauthenticated", async () => {
const listPackageDocs = mock(() => Promise.resolve(defaultPackageDocsList));
const service = createMockPackageIntelligenceService({ listPackageDocs });

await expect(
docsListAction(
"npm:express",
{},
createDeps({
packageIntelligenceService: service,
hasValidToken: false,
}),
),
).rejects.toThrow(AuthRequiredError);

expect(listPackageDocs).not.toHaveBeenCalled();
});
});
Loading
Loading