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
4 changes: 2 additions & 2 deletions src/cli.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,19 @@
import chalk from "chalk";
import { Command, Option } from "commander";
import packageJson from "../package.json" with { type: "json" };
import { registerApiCommands } from "./commands/api.js";
import { registerAuthCommands } from "./commands/auth.js";
import { registerCuratedCommands } from "./commands/curated.js";
import { registerSkillCommands } from "./commands/skills.js";
import { bannerEnabled, renderBanner } from "./lib/banner.js";
import { manifest } from "./lib/operations.js";
import { OUTPUT_FORMATS } from "./lib/types.js";
import { CLI_VERSION } from "./lib/version.js";

export function buildCli(): Command {
const program = new Command()
.name("asknews")
.description("The official AskNews CLI for humans, automation, and agents")
.version(packageJson.version)
.version(CLI_VERSION)
.showHelpAfterError()
.showSuggestionAfterError()
// Put a blank line between each argument/option/command entry so dense, multi-line parameter
Expand Down
3 changes: 3 additions & 0 deletions src/lib/http.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { CliConfig } from "./config.js";
import { ApiError, NetworkError, UsageError } from "./errors.js";
import { redact } from "./redact.js";
import type { ApiResponse, OperationDefinition, RequestInput, TokenRefresher } from "./types.js";
import { USER_AGENT } from "./version.js";

// Default timeouts for operations whose normal completion time exceeds the global 60 s
// default. Applied only when the user did not set --timeout or ASKNEWS_TIMEOUT_MS.
Expand Down Expand Up @@ -90,6 +91,7 @@ export async function executeRawRequest<T>(
const headers = new Headers({
accept: "application/json",
authorization: `Bearer ${credential}`,
"user-agent": USER_AGENT,
});
return executeRequest<T>(
config,
Expand Down Expand Up @@ -191,6 +193,7 @@ function prepareOperationRequest(
const headers = new Headers({
accept: input.body && isStreamBody(input.body) ? "text/event-stream" : "application/json",
authorization: `Bearer ${credential}`,
"user-agent": USER_AGENT,
...input.headers,
});
let body: string | undefined;
Expand Down
7 changes: 7 additions & 0 deletions src/lib/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import packageJson from "../../package.json" with { type: "json" };

export const CLI_VERSION: string = packageJson.version;

// Identifies CLI traffic at the API gateway (usage is attributed per channel), next to the SDKs'
// own `asknews-sdk-<lang>` agents. Never include anything user-specific here.
export const USER_AGENT = `asknews-cli/${CLI_VERSION}`;
68 changes: 68 additions & 0 deletions test/user-agent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { afterEach, expect, test, vi } from "vitest";
import type { CliConfig } from "../src/lib/config.js";
import { executeOperation, executeOperationStream, executeRawRequest } from "../src/lib/http.js";
import type { OperationDefinition } from "../src/lib/types.js";
import { CLI_VERSION, USER_AGENT } from "../src/lib/version.js";

const config: CliConfig = {
apiUrl: "http://api.invalid/v1",
authUrl: "http://auth.invalid",
oauthClientId: "test",
output: "json",
configDir: "/tmp/asknews-cli-user-agent-test",
timeoutMs: 1_000,
noColor: true,
};

const operation: OperationDefinition = {
operationId: "search_news",
tag: "news",
command: "news search",
method: "GET",
path: "/v1/news/search",
summary: "",
parameters: [],
requestBody: null,
safety: "read-only",
};

function stubFetch(): ReturnType<typeof vi.fn> {
const mockFetch = vi.fn(
async () =>
new Response("{}", { status: 200, headers: { "content-type": "application/json" } }),
);
vi.stubGlobal("fetch", mockFetch);
return mockFetch;
}

function sentUserAgent(mockFetch: ReturnType<typeof vi.fn>): string | null {
const init = mockFetch.mock.calls[0]?.[1] as RequestInit;
return new Headers(init.headers).get("user-agent");
}

afterEach(() => {
vi.unstubAllGlobals();
});

test("the user agent names the CLI and its package version", () => {
expect(USER_AGENT).toBe(`asknews-cli/${CLI_VERSION}`);
expect(CLI_VERSION).toMatch(/^\d+\.\d+\.\d+/);
});

test("raw requests send the CLI user agent", async () => {
const mockFetch = stubFetch();
await executeRawRequest(config, "key", "GET", "/test");
expect(sentUserAgent(mockFetch)).toBe(USER_AGENT);
});

test("operation requests send the CLI user agent", async () => {
const mockFetch = stubFetch();
await executeOperation(config, "key", operation, {});
expect(sentUserAgent(mockFetch)).toBe(USER_AGENT);
});

test("streamed operation requests send the CLI user agent", async () => {
const mockFetch = stubFetch();
await executeOperationStream(config, "key", operation, { body: { stream: true } }, () => {});
expect(sentUserAgent(mockFetch)).toBe(USER_AGENT);
});
Loading