From 250a4d056a4e4518ed56684d1e20a022f6aea3b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9ril=20Pilon?= Date: Thu, 8 Oct 2026 10:09:28 +0100 Subject: [PATCH 1/2] Replace commander with node:util parseArgs Drops the commander dependency. Help, version and the variadic -l/--labelTypeIds option keep working as before. Co-Authored-By: Claude --- .changeset/native-arg-parsing.md | 5 ++ package.json | 3 +- pnpm-lock.yaml | 9 -- src/index.ts | 142 ++++++++++++++++++++++++------- test/cli.test.ts | 62 ++++++++++++++ 5 files changed, 179 insertions(+), 42 deletions(-) create mode 100644 .changeset/native-arg-parsing.md diff --git a/.changeset/native-arg-parsing.md b/.changeset/native-arg-parsing.md new file mode 100644 index 0000000..16e769f --- /dev/null +++ b/.changeset/native-arg-parsing.md @@ -0,0 +1,5 @@ +--- +"@team-plain/cli": minor +--- + +Replace commander with Node's built-in argument parsing. diff --git a/package.json b/package.json index 132292a..92b0355 100644 --- a/package.json +++ b/package.json @@ -40,8 +40,7 @@ "typescript": "^7.0.2" }, "dependencies": { - "@team-plain/graphql": "^3.2.0", - "commander": "^15.0.0" + "@team-plain/graphql": "^3.2.0" }, "packageManager": "pnpm@12.8.2+sha512.a5941679663d952c5f0ecc38ba98af98b4dc01b95780354f6894f2f873973cef2f7e2989d7db3ee5393938ae21f62fe06bcdf685d475ddad829a62095d2b8b11" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2b0de4a..cdc6a9a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -169,9 +169,6 @@ importers: '@team-plain/graphql': specifier: ^3.2.0 version: 3.2.0 - commander: - specifier: ^15.0.0 - version: 15.0.0 devDependencies: '@biomejs/biome': specifier: ^2.5.14 @@ -751,10 +748,6 @@ packages: resolution: {integrity: sha512-tixWYgm5ZoOD+3g6UTea91eow5z6AAHaho3g0V9CNSNb45gM8SmflpAc+GRd1InC4AqN/07Unrgp56Y94N9hJQ==} engines: {node: '>=20.19.0'} - commander@15.0.0: - resolution: {integrity: sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==} - engines: {node: '>=22.12.0'} - defu@6.1.7: resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} @@ -1363,8 +1356,6 @@ snapshots: cac@7.0.0: {} - commander@15.0.0: {} - defu@6.1.7: {} dts-resolver@3.0.0: {} diff --git a/src/index.ts b/src/index.ts index a0e8094..61a88c5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,14 +1,63 @@ #!/usr/bin/env node +import { parseArgs } from "node:util"; import { type KnowledgeSourceType, PlainClient } from "@team-plain/graphql"; -import { program } from "commander"; import packageJson from "../package.json" with { type: "json" }; const DOCS_URL = "https://www.plain.com/docs/product/agents/knowledge-sources"; const PERMISSIONS_HELP = "To use this you need to set an environment variable called PLAIN_API_KEY with the following permissions: \n- knowledgeSource:create"; -type IndexOptions = { labelTypeIds?: string[] }; +type Command = { + type: KnowledgeSourceType; + noun: string; + argument: string; + description: string; +}; + +const COMMANDS: Record = { + "index-url": { + type: "URL", + noun: "URL", + argument: "", + description: "This will index a specific url you provide.", + }, + "index-sitemap": { + type: "SITEMAP", + noun: "sitemap", + argument: "", + description: "This will index all the urls in a given sitemap you provide.", + }, +}; + +const HELP = `Usage: plain [options] + +Plain CLI + +Commands: + index-url Index a single page by URL + index-sitemap Index all the urls in a sitemap + +Options: + -V, --version Output the version number + -h, --help Display help`; + +function commandHelp(name: string, { argument, description }: Command): string { + return `Usage: plain ${name} ${argument} [options] + +${description} + +${PERMISSIONS_HELP} + +Options: + -l, --labelTypeIds Array of label type IDs + -h, --help Display help`; +} + +function usageError(message: string): never { + console.error(`error: ${message}\nRun 'plain --help' for usage.`); + process.exit(1); +} function getClient(): PlainClient { const apiKey = process.env.PLAIN_API_KEY; @@ -37,7 +86,7 @@ function fail(url: string, message: string): never { async function createKnowledgeSource( url: string, type: KnowledgeSourceType, - labelTypeIds: string[] = [], + labelTypeIds: string[], ): Promise { try { const { error } = await getClient().mutation.createKnowledgeSource({ @@ -51,34 +100,65 @@ async function createKnowledgeSource( } } -program.name("plain").version(packageJson.version).description("Plain CLI"); - -program - .command("index-url") - .description( - `This will index a specific url you provide.\n\n${PERMISSIONS_HELP}`, - ) - .argument("") - .option("-l, --labelTypeIds ", "Array of label type IDs") - .action(async (url: string, options: IndexOptions) => { - await createKnowledgeSource(url, "URL", options.labelTypeIds); - console.log( - `✅ Successfully indexed URL ${url} - The URL will be indexed and knowledge sources will be available in Plain. See ${DOCS_URL} for more information.`, - ); - }); +function parse(args: string[]) { + try { + return parseArgs({ + args, + options: { + labelTypeIds: { type: "string", short: "l", multiple: true }, + help: { type: "boolean", short: "h" }, + }, + allowPositionals: true, + tokens: true, + }).tokens; + } catch (err) { + return usageError(err instanceof Error ? err.message : String(err)); + } +} + +async function index(name: string, command: Command, args: string[]) { + // `-l a b` keeps collecting label type IDs until the next flag, as it did + // when this CLI used commander. + const labelTypeIds: string[] = []; + const positionals: string[] = []; + let collecting = false; + for (const token of parse(args)) { + if (token.kind === "option" && token.name === "help") { + console.log(commandHelp(name, command)); + return; + } + if (token.kind === "option") { + labelTypeIds.push(token.value ?? ""); + collecting = true; + } else if (token.kind === "positional") { + (collecting ? labelTypeIds : positionals).push(token.value); + } else { + collecting = false; + } + } -program - .command("index-sitemap") - .description( - `This will index all the urls in a given sitemap you provide.\n\n${PERMISSIONS_HELP}`, - ) - .argument("") - .option("-l, --labelTypeIds ", "Array of label type IDs") - .action(async (url: string, options: IndexOptions) => { - await createKnowledgeSource(url, "SITEMAP", options.labelTypeIds); - console.log( - `✅ Successfully indexed sitemap ${url} - The sitemap will be indexed and knowledge sources will be available in Plain. See ${DOCS_URL} for more information.`, + const { type, noun, argument } = command; + if (positionals.length !== 1) { + usageError( + `'${name}' expects one ${argument} argument, got ${positionals.length}`, ); - }); + } -program.parse(process.argv); + const [url] = positionals; + await createKnowledgeSource(url, type, labelTypeIds); + console.log( + `✅ Successfully indexed ${noun} ${url} - The ${noun} will be indexed and knowledge sources will be available in Plain. See ${DOCS_URL} for more information.`, + ); +} + +const [name, ...args] = process.argv.slice(2); + +if (!name || name === "-h" || name === "--help") { + console.log(HELP); +} else if (name === "-V" || name === "--version") { + console.log(packageJson.version); +} else if (Object.hasOwn(COMMANDS, name)) { + await index(name, COMMANDS[name], args); +} else { + usageError(`unknown command '${name}'`); +} diff --git a/test/cli.test.ts b/test/cli.test.ts index ae69f3c..ebe0230 100644 --- a/test/cli.test.ts +++ b/test/cli.test.ts @@ -4,6 +4,7 @@ import { createServer, type Server } from "node:http"; import type { AddressInfo } from "node:net"; import { after, before, beforeEach, describe, test } from "node:test"; import { fileURLToPath } from "node:url"; +import packageJson from "../package.json" with { type: "json" }; const CLI = fileURLToPath(new URL("../dist/index.mjs", import.meta.url)); @@ -157,6 +158,67 @@ describe("index-url", () => { }); }); +describe("argument parsing", () => { + test("accepts repeated label type flags", async () => { + const { code } = await run([ + "index-url", + "-l", + "lt_1", + "--labelTypeIds=lt_2", + "--", + "https://example.com/page", + ]); + + assert.equal(code, 0); + assert.deepEqual(requests[0].body.variables, { + input: { + url: "https://example.com/page", + type: "URL", + labelTypeIds: ["lt_1", "lt_2"], + }, + }); + }); + + test("rejects a wrong number of arguments", async () => { + const { code, stderr } = await run(["index-url"]); + + assert.equal(code, 1); + assert.match(stderr, /'index-url' expects one argument, got 0/); + assert.equal(requests.length, 0); + }); + + test("rejects unknown options", async () => { + const { code, stderr } = await run(["index-url", "--nope", "a"]); + + assert.equal(code, 1); + assert.match(stderr, /Unknown option '--nope'/); + }); + + test("rejects unknown commands", async () => { + const { code, stderr } = await run(["nope"]); + + assert.equal(code, 1); + assert.match(stderr, /unknown command 'nope'/); + }); +}); + +describe("help", () => { + test("lists every command", async () => { + const { code, stdout } = await run(["--help"]); + + assert.equal(code, 0); + assert.match(stdout, /index-url/); + assert.match(stdout, /index-sitemap/); + }); + + test("prints the version", async () => { + const { code, stdout } = await run(["--version"]); + + assert.equal(code, 0); + assert.equal(stdout.trim(), packageJson.version); + }); +}); + describe("index-sitemap", () => { test("creates a sitemap knowledge source with no label types", async () => { const { code, stdout } = await run([ From 542a73e3d4f51c723277c5f065d28c7188fa5ea1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?M=C3=A9ril=20Pilon?= Date: Thu, 8 Oct 2026 10:10:45 +0100 Subject: [PATCH 2/2] Make changeset a patch Co-Authored-By: Claude --- .changeset/native-arg-parsing.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/native-arg-parsing.md b/.changeset/native-arg-parsing.md index 16e769f..326171e 100644 --- a/.changeset/native-arg-parsing.md +++ b/.changeset/native-arg-parsing.md @@ -1,5 +1,5 @@ --- -"@team-plain/cli": minor +"@team-plain/cli": patch --- Replace commander with Node's built-in argument parsing.