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
6 changes: 5 additions & 1 deletion src/cli/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ import { integrationsCommand } from "../commands/integrations";
import { knowledgeCommand } from "../commands/knowledge";
import { membersCommand } from "../commands/members";
import { orgCommand } from "../commands/org";
import { readCommand } from "../commands/read";
import { reviewCommand } from "../commands/review";
import { skillCommand } from "../commands/skill";
import { sourcesCommand } from "../commands/sources";
import { suggestCommand } from "../commands/suggest";
import { tagsCommand } from "../commands/tags";
import { threadsCommand } from "../commands/threads";
import { writeCommand } from "../commands/write";
import {
getConfigPath,
isAuthenticated,
Expand Down Expand Up @@ -247,12 +249,14 @@ export function createProgram(): Command {
program.addCommand(knowledgeCommand());
program.addCommand(membersCommand());
program.addCommand(orgCommand());
program.addCommand(readCommand());
program.addCommand(reviewCommand());
program.addCommand(skillCommand());
program.addCommand(sourcesCommand());
program.addCommand(suggestCommand());
program.addCommand(tagsCommand());
program.addCommand(threadsCommand());
program.addCommand(skillCommand());
program.addCommand(writeCommand());

// setup
program
Expand Down
22 changes: 22 additions & 0 deletions src/client/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,28 @@ describe("Client", () => {
});
});

describe("timeoutMs option", () => {
it("uses the default 10s timeout when no option is passed", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
const client = new Client(makeConfig());
await client.get("/test");
const timeoutCall = setTimeoutSpy.mock.calls.find((c) => c[1] === 10_000);
expect(timeoutCall).toBeDefined();
setTimeoutSpy.mockRestore();
});

it("uses the override when opts.timeoutMs is passed to doRequest", async () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
mockFetch.mockResolvedValueOnce(jsonResponse({ ok: true }));
const client = new Client(makeConfig());
await client.doRequest("POST", "/slow", { x: 1 }, { timeoutMs: 60_000 });
const timeoutCall = setTimeoutSpy.mock.calls.find((c) => c[1] === 60_000);
expect(timeoutCall).toBeDefined();
setTimeoutSpy.mockRestore();
});
});

describe("doRequestRaw", () => {
it("returns 401 without retrying or refreshing", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({}, 401));
Expand Down
36 changes: 29 additions & 7 deletions src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,13 @@ export interface APIKeyResponse {
key_prefix: string;
}

const DEFAULT_REQUEST_TIMEOUT_MS = 10_000;

export interface RequestOptions {
/** Override the per-request HTTP timeout (ms). Defaults to 10_000. */
timeoutMs?: number;
}

export class Client {
private baseURL: string;
private config: Config;
Expand All @@ -47,8 +54,17 @@ export class Client {

/**
* Performs an authenticated HTTP request with auto-refresh on 401/403.
*
* `opts.timeoutMs` overrides the default 10s timeout for endpoints known to
* exceed it (e.g. the GitHub PAT storage endpoint, which performs KMS
* encrypt + DB upsert + sync enqueue).
*/
async doRequest(method: string, path: string, body?: unknown): Promise<Response> {
async doRequest(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
if (!isAuthenticated(this.config)) {
throw new Error("not authenticated - please run setup first");
}
Expand All @@ -58,7 +74,7 @@ export class Client {
await this.refreshToken();
}

let resp = await this.doRequestOnce(method, path, body);
let resp = await this.doRequestOnce(method, path, body, opts);

// If backend says unauthorized, try refresh + retry once
if (resp.status === 401 || resp.status === 403) {
Expand All @@ -67,7 +83,7 @@ export class Client {
} catch {
throw new SessionExpiredError();
}
resp = await this.doRequestOnce(method, path, body);
resp = await this.doRequestOnce(method, path, body, opts);
}

return resp;
Expand All @@ -76,11 +92,16 @@ export class Client {
/**
* Performs a single authenticated request without any retry/refresh logic.
*/
async doRequestRaw(method: string, path: string): Promise<Response> {
return this.doRequestOnce(method, path);
async doRequestRaw(method: string, path: string, opts?: RequestOptions): Promise<Response> {
return this.doRequestOnce(method, path, undefined, opts);
}

private async doRequestOnce(method: string, path: string, body?: unknown): Promise<Response> {
private async doRequestOnce(
method: string,
path: string,
body?: unknown,
opts?: RequestOptions,
): Promise<Response> {
const url = this.baseURL + path;
const headers: Record<string, string> = {
"Content-Type": "application/json",
Expand All @@ -93,7 +114,8 @@ export class Client {
}

const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 10_000);
const timeoutMs = opts?.timeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
const timeout = setTimeout(() => controller.abort(), timeoutMs);
options.signal = controller.signal;

try {
Expand Down
9 changes: 7 additions & 2 deletions src/commands/ask.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,9 @@ export function askCommand(): Command {
const backendURL = getBackendURL();

if (!backendURL) {
console.error(pc.red("Backend URL not configured."));
console.error(
pc.red("Backend URL not configured. Reinstall the CLI or set DOSU_BACKEND_URL_OVERRIDE."),
);
process.exit(1);
}

Expand Down Expand Up @@ -70,6 +72,9 @@ export function askCommand(): Command {
detail = typeof raw === "string" ? raw : JSON.stringify(raw, null, 2);
} catch {}
console.error(pc.red(`Error: ${detail}`));
console.error(
pc.dim("Run `dosu logs --tail 30` for details, or `dosu status` to check auth."),
);
process.exit(1);
}

Expand Down Expand Up @@ -101,7 +106,7 @@ export function askCommand(): Command {
}
} catch (err: unknown) {
if (err instanceof Error && err.name === "AbortError") {
console.error(pc.red("Request timed out."));
console.error(pc.red("Request timed out after 120s. Re-run, or simplify the question."));
process.exit(1);
}
throw err;
Expand Down
14 changes: 9 additions & 5 deletions src/commands/knowledge.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,8 @@ describe("knowledge search", () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery
.mockResolvedValueOnce([
{ id: "ds1", name: "GH" },
{ id: "ds2", name: "Slack" },
{ data_source_id: "ds1", name: "GH" },
{ data_source_id: "ds2", name: "Slack" },
])
.mockResolvedValueOnce({ documents: [{ title: "Doc A", similarity: 0.95 }] });

Expand All @@ -90,7 +90,7 @@ describe("knowledge search", () => {
it("outputs valid JSON with --json flag", async () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery
.mockResolvedValueOnce([{ id: "ds1" }])
.mockResolvedValueOnce([{ data_source_id: "ds1" }])
.mockResolvedValueOnce({ documents: [{ title: "Result", similarity: 0.8 }] });

await run("search", "--json", "query");
Expand All @@ -111,7 +111,9 @@ describe("knowledge search", () => {

it("prints message when search returns empty", async () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery.mockResolvedValueOnce([{ id: "ds1" }]).mockResolvedValueOnce({ documents: [] });
mockQuery
.mockResolvedValueOnce([{ data_source_id: "ds1" }])
.mockResolvedValueOnce({ documents: [] });

await run("search", "query");

Expand All @@ -124,7 +126,9 @@ describe("knowledge search", () => {
title: `Doc ${i}`,
similarity: 0.9 - i * 0.1,
}));
mockQuery.mockResolvedValueOnce([{ id: "ds1" }]).mockResolvedValueOnce({ documents: results });
mockQuery
.mockResolvedValueOnce([{ data_source_id: "ds1" }])
.mockResolvedValueOnce({ documents: results });

await run("search", "--limit", "3", "query");

Expand Down
4 changes: 3 additions & 1 deletion src/commands/knowledge.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,9 @@ export function knowledgeCommand(): Command {
excluded_provider_slugs: [],
});

const dataSourceIds = dataSources.map((ds: { id: string }) => ds.id);
const dataSourceIds = (dataSources as { data_source_id?: string }[])
.map((ds) => ds.data_source_id)
.filter((id): id is string => typeof id === "string");
if (dataSourceIds.length === 0) {
console.log(pc.dim("No data sources connected. Add data sources in the Dosu dashboard."));
return;
Expand Down
83 changes: 83 additions & 0 deletions src/commands/read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
/**
* `dosu read` — CLI shortcut for the MCP init_knowledge tool.
*
* Semantic search over the reviewed knowledge base. Returns results ranked
* by relevance without the LLM source-selection pass (faster for CLI use).
*/

import { Command } from "commander";
import pc from "picocolors";
import { Client } from "../client/client";
import { loadConfig } from "../config/config";
import { logger } from "../debug/logger";
import { printResult, truncate } from "./output";

function requireConfig() {
const cfg = loadConfig();
if (!cfg.api_key) {
console.error(pc.red("Not configured. Run 'dosu setup' first."));
process.exit(1);
}
if (!cfg.deployment_id) {
console.error(pc.red("Missing deployment config. Run 'dosu setup' to reconfigure."));
process.exit(1);
}
return cfg;
}

export function readCommand(): Command {
return new Command("read")
.description("Retrieve relevant context from the knowledge base")
.argument("[query]", "Optional search query")
.option("--limit <n>", "Maximum results (default: 10)", "10")
.option("--json", "Output as JSON")
.action(async (query: string | undefined, opts: { limit?: string; json?: boolean }) => {
const cfg = requireConfig();
const apiClient = new Client(cfg);
const question = query ?? "What should I know before making changes?";

logger.debug("read", `Searching: ${question}`);

const resp = await apiClient.doRequest("POST", "/v1/knowledge/search", {
// biome-ignore lint/style/noNonNullAssertion: checked in requireConfig
deployment_id: cfg.deployment_id!,
query: question,
top_k: Number.parseInt(opts.limit ?? "10", 10),
});

if (!resp.ok) {
let detail = `Request failed with status ${resp.status}`;
try {
const body = await resp.json();
const raw = body.detail ?? detail;
detail = typeof raw === "string" ? raw : JSON.stringify(raw);
} catch {}
console.error(pc.red(`Error: ${detail}`));
console.error(
pc.dim("Run `dosu logs --tail 30` for details, or `dosu status` to check auth."),
);
process.exit(1);
}

const body = await resp.json();

if (opts.json) {
printResult(body, opts);
return;
}

const results: { title: string; content: string; url?: string }[] = body.results ?? [];
if (results.length === 0) {
console.log(pc.dim("No results found."));
return;
}

for (const r of results) {
console.log(pc.bold(truncate(r.title, 80)));
if (r.content) {
console.log(pc.dim(truncate(r.content, 300)));
}
console.log();
}
});
}
9 changes: 7 additions & 2 deletions src/commands/skill.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,16 @@ const SKILL_NAME = "dosu";
* what was installed. Network failure is non-fatal — the skill is still
* installed, the SHA is just not cached (the update checker will fill it
* in on the next stale check).
*
* Pass `silent: true` to suppress raw npx output (used during setup to avoid
* showing security-risk warnings in the FTUE).
*/
export async function installSkill(): Promise<{ success: boolean; sha?: string }> {
export async function installSkill(
opts: { silent?: boolean } = {},
): Promise<{ success: boolean; sha?: string }> {
try {
execSync(`npx skills add ${SKILL_REPO} -g -s ${SKILL_NAME} -y`, {
stdio: "inherit",
stdio: opts.silent ? "pipe" : "inherit",
});
} catch (err) {
logger.error("skill", `Failed to install skill: ${err}`);
Expand Down
6 changes: 3 additions & 3 deletions src/commands/suggest.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ describe("suggest list", () => {
describe("suggest generate", () => {
it("fetches data sources then calls suggestedDoc.generate", async () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery.mockResolvedValueOnce([{ id: "ds1" }, { id: "ds2" }]); // dataSource.list
mockQuery.mockResolvedValueOnce([{ data_source_id: "ds1" }, { data_source_id: "ds2" }]); // dataSource.list
mockMutate.mockResolvedValueOnce({});

await run("generate");
Expand All @@ -111,7 +111,7 @@ describe("suggest generate", () => {

it("outputs JSON with --json", async () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery.mockResolvedValueOnce([{ id: "ds1" }]);
mockQuery.mockResolvedValueOnce([{ data_source_id: "ds1" }]);
mockMutate.mockResolvedValueOnce({ status: "generating" });

await run("generate", "--json");
Expand All @@ -121,7 +121,7 @@ describe("suggest generate", () => {

it("prints human-readable confirmation", async () => {
mockLoadConfig.mockReturnValue(validConfig);
mockQuery.mockResolvedValueOnce([{ id: "ds1" }]);
mockQuery.mockResolvedValueOnce([{ data_source_id: "ds1" }]);
mockMutate.mockResolvedValueOnce({});

await run("generate");
Expand Down
4 changes: 3 additions & 1 deletion src/commands/suggest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,9 @@ export function suggestCommand(): Command {
org_id: cfg.org_id,
excluded_provider_slugs: [],
});
const dataSourceIds = dataSources.map((ds: { id: string }) => ds.id);
const dataSourceIds = (dataSources as { data_source_id?: string }[])
.map((ds) => ds.data_source_id)
.filter((id): id is string => typeof id === "string");

const result = await client.suggestedDoc.generate.mutate({
knowledgeStoreId: ksId,
Expand Down
Loading
Loading