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
5 changes: 5 additions & 0 deletions .changeset/native-arg-parsing.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@team-plain/cli": patch
---

Replace commander with Node's built-in argument parsing.
3 changes: 1 addition & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
9 changes: 0 additions & 9 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

142 changes: 111 additions & 31 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -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<string, Command> = {
"index-url": {
type: "URL",
noun: "URL",
argument: "<url>",
description: "This will index a specific url you provide.",
},
"index-sitemap": {
type: "SITEMAP",
noun: "sitemap",
argument: "<sitemap url>",
description: "This will index all the urls in a given sitemap you provide.",
},
};

const HELP = `Usage: plain <command> [options]

Plain CLI

Commands:
index-url <url> Index a single page by URL
index-sitemap <sitemap url> 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 <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;
Expand Down Expand Up @@ -37,7 +86,7 @@ function fail(url: string, message: string): never {
async function createKnowledgeSource(
url: string,
type: KnowledgeSourceType,
labelTypeIds: string[] = [],
labelTypeIds: string[],
): Promise<void> {
try {
const { error } = await getClient().mutation.createKnowledgeSource({
Expand All @@ -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("<url>")
.option("-l, --labelTypeIds <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("<sitemap url>")
.option("-l, --labelTypeIds <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}'`);
Comment on lines +160 to +163

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Help command stops working

Running plain help or plain help index-url now exits with “unknown command 'help'” instead of showing help. The previous commander setup supplied these commands automatically. The new routing only accepts flags and names in COMMANDS, so existing help calls reach usageError. Restore support for help and help <command>.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/index.ts
Line: 160-163

Comment:
**Help command stops working**

Running `plain help` or `plain help index-url` now exits with “unknown command 'help'” instead of showing help. The previous `commander` setup supplied these commands automatically. The new routing only accepts flags and names in `COMMANDS`, so existing help calls reach `usageError`. Restore support for `help` and `help <command>`.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Fix in Claude Code Fix in Cursor

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is fine IMO.

}
62 changes: 62 additions & 0 deletions test/cli.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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));

Expand Down Expand Up @@ -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 <url> 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([
Expand Down
Loading