Skip to content
Closed
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,12 @@ jobs:
- name: Generate API client
run: pnpm generate

- name: Check generated commands
run: |
pnpm generate:commands
git status --porcelain -- src/commands
test -z "$(git status --porcelain -- src/commands)"

- name: Check formatting
run: pnpm format:check

Expand Down
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
# Command structure

Commands in `src/commands/` are generated from `api-spec.json` by `scripts/codegen/`; put exceptions in `scripts/codegen/config.ts` instead of editing generated files.

We map from the REST api path to a command structure for CLI with the following rules:
- A command is `<group> <verb>-<noun>`. The group is the path's first segment (`/vendors/...` → `vendors`).
- The verb is a trailing action in the path (`/vendors/{vendorId}/set-status` → `set-status`), or comes from the method when there is no trailing action: GET → `list` or `get`, POST → `create` or `upload` (files), PATCH → `update`, PUT → `set`, DELETE → `delete`. A POST or DELETE that attaches or detaches an existing resource is `add` or `remove` (`groups add-person`), and a GET that returns a file is `download` (`trust-centers download-resource-media`). A multi-word action puts the noun after its first word: `trust-centers send-viewer-invite-reminder`.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"typecheck": "tsc --noEmit",
"test": "node --import tsx --test 'src/**/*.test.ts'",
"build:binaries": "tsx scripts/build-binaries.ts",
"generate:commands": "tsx scripts/codegen/generate.ts",
"format": "oxfmt",
"format:check": "oxfmt --check"
},
Expand Down
128 changes: 128 additions & 0 deletions scripts/codegen/commands.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
// Which commands exist and what they're called: each operation is placed in
// a group and named `<verb>-<noun>` (`vendors list-findings`). See AGENTS.md
// "Command structure".
import * as config from "./config.js";
import type { Group, Operation, Placement, Segment } from "./types.js";

// ===========================================================================
// Words
// ===========================================================================

export const lowerFirst = (s: string) =>
s.replace(/^./, (c) => c.toLowerCase());
export const upperFirst = (s: string) =>
s.replace(/^./, (c) => c.toUpperCase());
// `statusMatchesAny` → `status-matches-any`, and back with `camel`.
export const kebab = (s: string) =>
s.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase();
export const camel = (s: string) =>
s.replace(/-([a-z0-9])/g, (_, c: string) => c.toUpperCase());
export const pascal = (s: string) => upperFirst(camel(s));
// `faqCategories` and `faq-categories` → "faq categories".
export const humanize = (s: string) => kebab(s).replace(/-/g, " ");

// "a vendor", "an integration", "a user".
export const a = (noun: string) =>
`${/^[aeiou]/i.test(noun) && !/^(us|uni|eu)/i.test(noun) ? "an" : "a"} ${noun}`;

// Singularizes the last word of a kebab-case segment: `security-reviews`.
export function singular(segment: string): string {

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I chose this over introducing small deps like the pluralize npm package, mostly because I figured that we're reviewing any generated outputs anyway and probably don't want a bunch of tiny deps.

const word = segment.split("-").at(-1)!;
const one =
config.irregularNouns[word] ??
(word.endsWith("ies")
? `${word.slice(0, -3)}y`
: word.endsWith("sses")
? word.slice(0, -2)
: word.endsWith("s") && !/(ss|us)$/.test(word)
? word.slice(0, -1)
: word);
return segment.slice(0, -word.length) + one;
}
export const isPlural = (segment: string) => singular(segment) !== segment;

// Display names from `config.nouns`, keyed by "<group> <segment>" or by
// segment, else from the segment itself.
export const nounFor = (segment: string, group?: string) =>
(group && config.nouns[`${group} ${segment}`]) || config.nouns[segment];
export const displaySingular = (segment: string, group?: string) =>
nounFor(segment, group)?.singular ?? humanize(singular(segment));
export const displayPlural = (segment: string, group?: string) =>
nounFor(segment, group)?.plural ?? humanize(segment);

// ===========================================================================
// Groups and commands
// ===========================================================================

export function newGroup(name: string): Group {
const noun = config.nouns[name];
const plural = noun?.plural ?? displayPlural(name);
return {
name,
description: `Manage ${plural}`,
singular: noun?.singular ?? displaySingular(name),
plural,
commands: [],
};
}

export function place(op: Operation): Placement {
const [groupSegment, ...rest] = op.segments as [Segment, ...Segment[]];
const last = rest.at(-1);
const isAction =
last &&
!last.param &&
config.actionVerbs.includes(last.name.split("-")[0]!);
return {
groupSegment,
resources: isAction ? rest.slice(0, -1) : rest,
action: isAction ? last.name : undefined,
};
}

// `<verb>-<noun>`, unless `config.operations` names the command.
export function commandName(op: Operation, placement: Placement): string {
const override = config.operations[op.id]?.name;
if (override) return override;
const atItem = op.segments.at(-1)!.param !== undefined;
let verb: string;
let rest: string[] = [];
// Operations on a whole collection keep the last noun plural.
let plural = !atItem;
const link = config.links.includes(op.id);
if (placement.action) {
[verb, ...rest] = placement.action.split("-") as [string, ...string[]];
plural = false;
} else if (op.method === "get") {
verb = op.returnsFile ? "download" : atItem ? "get" : "list";
plural = verb === "list";
} else if (op.method === "post") {
verb = link ? "add" : op.body === "multipart" ? "upload" : "create";
// `create` and `add` make one item, unless the API adds many at once.
plural = op.id.startsWith("Bulk");
} else if (op.method === "delete" && link) {
verb = "remove";
} else {
verb = { patch: "update", put: "set", delete: "delete" }[op.method];
}
const noun = placement.resources.map((s, i) =>
plural && i === placement.resources.length - 1
? (config.nouns[s.name]?.commandPlural ?? s.name)
: (config.nouns[s.name]?.commandSingular ?? singular(s.name)),
);
return [verb, ...noun, ...rest].join("-");
}

export function checkUniqueNames(groups: Group[]): void {
for (const group of groups) {
const seen = new Map<string, string>();
for (const { name, op } of group.commands) {
if (seen.has(name)) {
throw new Error(
`duplicate command "${group.name} ${name}" for ${seen.get(name)} and ${op.id}; add a name override in scripts/codegen/config.ts`,
);
}
seen.set(name, op.id);
}
}
}
Loading
Loading