Before writing code: read
cli-design.md. It defines the command surface, output envelope, error codes, and what's in v0.1 vs deferred. Implementation follows that contract; changes to the contract land via PRs that argue for the change.
git clone <repo-url> monday-cli
cd monday-cli
npm install
cp .env.example .env
# edit .env — at minimum set MONDAY_API_TOKENnpm run dev -- --help # tsx (no build, fastest iteration)
npm run build && npm start -- --help # exercise the compiled binarynpm link will symlink the monday bin into your PATH after a build, so
you can use it from any directory.
The project enforces three gates. Run all three before committing:
npm run typecheck
npm run lint
npm testOr all at once:
npm run typecheck && npm run lint && npm testThe shape is established by M0–M3; new commands plug into it without
ceremony. Each step is small — read src/commands/account/whoami.ts
(simplest), src/commands/board/list.ts (page-based collection), and
src/commands/board/describe.ts (cache-aware single resource) for
working references.
Export a CommandModule<Input, Output> instance — never a free
function. The module bundles input + output schemas, examples, and an
attach(program, ctx) hook that wires commander.
import { z } from 'zod';
import { ensureSubcommand, type CommandModule } from '../types.js';
import { emitSuccess } from '../emit.js';
import { resolveClient } from '../../api/resolve-client.js';
import { parseArgv } from '../parse-argv.js';
const inputSchema = z.object({ /* positionals + flags */ }).strict();
const outputSchema = z.object({ /* what we promise to emit */ }).strict();
export const myCommand: CommandModule<
z.infer<typeof inputSchema>,
z.infer<typeof outputSchema>
> = {
name: 'group.verb',
summary: 'one-liner shown by --help',
examples: ['monday group verb <arg>', 'monday group verb <arg> --json'],
idempotent: true, // documented per command
inputSchema,
outputSchema,
attach: (program, ctx) => {
const noun = ensureSubcommand(program, 'group', 'Group commands');
noun
.command('verb <arg>')
.description(myCommand.summary)
.addHelpText('after', /* "Examples:\n ..." */)
.action(async (arg: unknown, opts: unknown) => {
const parsed = parseArgv(myCommand.inputSchema, {
arg,
...(opts as Readonly<Record<string, unknown>>),
});
const { client, toEmit } = resolveClient(ctx, program.opts());
const result = await client.raw<...>(QUERY, vars, { operationName: 'X' });
emitSuccess({
ctx,
data: outputSchema.parse(/* projection of result.data */),
schema: myCommand.outputSchema,
programOpts: program.opts(),
...toEmit(result),
});
});
},
};Key invariants:
- Use
parseArgv, neverinputSchema.parseat the action boundary. A rawZodErrorwould land asinternal_error/ exit 2;parseArgvwraps it asusage_error/ exit 1 with structureddetails.issues. Mandatory pervalidation.md. - Use
resolveClient(ctx, program.opts())for any network command. It returns{ client, globalFlags, apiVersion, toEmit }, commitsmeta.api_version/meta.sourcefor the error path, and closes overapiVersionso the success path can't drift. - Splat
...toEmit(result)into theemitSuccessoptions sosource/apiVersion/complexity/cacheAgeSecondsflow in as required keys. Cache-aware commands construct the meta values directly (seeboard describefor the pattern). - Output schemas use
.strict()— they describe what we promise to emit, so internal drift fails fast. Upstream-data parsing insideapi/should default to strip-mode (drop unknown SDK-added fields).
If no method on MondayClient covers the GraphQL operation, either
add a typed wrapper to src/api/client.ts or use client.raw<T>()
directly. The latter is fine for one-off shapes; the former when
multiple commands need it. Either way, the GraphQL string + the
projection schema live in src/api/, never in commands/*.
For shared cross-command logic (caching, name resolution, paginated
walks, filter parsing, item projection), add a focused module to
src/api/ rather than duplicating it across commands. M3 added
board-metadata.ts, columns.ts, resolvers.ts, walk-pages.ts;
M4 added pagination.ts (cursor walker), filters.ts (--where
parser), sort.ts (per-page ID-asc), item-projection.ts
(canonical §6.2 / §6.3 item shape) for the same reason.
For commands sharing a full action shape (not just a logic helper),
the lightweight pattern is src/commands/run-by-id-lookup.ts (M4 R7)
— compresses parseArgv → resolveClient → client.raw → not_found → emit into one call. Used by all five v0.1 get-by-id commands. Pick
this over per-command boilerplate when the shape is identical and
the only variation is the GraphQL query + collection key + error
detail key.
Page-based list commands (Monday's boards / workspaces / users
/ updates queries — anything limit: + page:) go through the
shared walker. It enforces the --limit-pages cap and emits the
pagination_cap_reached warning when capped:
const result = await walkPages<unknown, RawBoards>({
fetchPage: (page) => client.raw<RawBoards>(QUERY, { ..., page }, opts),
extractItems: (r) => r.data.boards ?? [],
pageSize: limit,
all: parsed.all === true,
startPage: parsed.page ?? 1,
maxPages: parsed.limitPages ?? DEFAULT_MAX_PAGES,
});
const warnings: Warning[] = [];
if (parsed.all === true && result.hasMore) {
warnings.push(buildCapWarning(result.pagesFetched));
}Cursor-based commands (item list, item search, item find —
anything via items_page → next_items_page) go through the
cursor walker in src/api/pagination.ts:
const result = await paginate<unknown, InitialResponse | NextResponse>({
fetchInitial: (effectiveLimit) => client.raw<InitialResponse>(
QUERY,
{ ..., limit: effectiveLimit },
{ operationName: 'X' },
),
fetchNext: (cursor, effectiveLimit) => client.raw<NextResponse>(
NEXT_QUERY,
{ cursor, limit: effectiveLimit },
{ operationName: 'XNext' },
),
extractPage: (r) => /* shape into { cursor, items } */,
getId: idFromRawItem,
now: ctx.clock, // injected for deterministic
// stale-cursor age tests
all: parsed.all === true,
...(parsed.limit === undefined ? {} : { limit: parsed.limit }),
pageSize: parsed.pageSize ?? DEFAULT_PAGE_SIZE,
// For NDJSON streaming:
// onItem: (raw) => stdout.write(JSON.stringify(redact(project(raw))) + '\n'),
});Hard rules:
fetchInitial/fetchNextMUST honoureffectiveLimit— the walker passesmin(pageSize, remainingBudget)so Monday's cursor advances over exactly the rows the walker emits. Passing a larger constantlimit:corrupts cursor state on--limit < pageSizeresume (Codex M4 pass-2 §1).now: ctx.clockis mandatory — the §5.6 stale-cursor age computation uses this. Wall-clock breaks deterministic tests and can give wrong answers if the system clock skews mid-walk.- Don't catch
stale_cursorand retry. §5.6 forbids it (silent re-issue can duplicate or skip rows). The walker enriches the error withdetails.cursor_age_seconds / items_returned_so_far / last_item_idand re-throws; the runner's catch-all surfaces the §6.5 envelope.
Import the module in src/commands/index.ts and append it to the
registry. cli/program.ts walks the registry to attach commander
commands; commands/schema/index.ts walks the same list to emit
JSON Schema 2020-12. Order is registration order is irrelevant
(monday schema sorts lexicographically).
The pyramid:
tests/unit/<area>/<thing>.test.ts— pure logic + helper modules. Example:walkPagescap behaviour,findOneambiguity, theexampleSetForColumnper-type mapping. Drive every reachable branch (testing.md "every branch covered" rule); mark genuinely unreachable defensive guards with/* c8 ignore */.tests/integration/commands/<noun>.test.ts— driverun({ transport: FixtureTransport })end-to-end. Each command needs at least: happy path, one error code per noun (M2's pattern —unauthorizedvia HTTP 401 also pins the--api-version 2026-04→ error envelope meta agreement, including on pre-resolveClientusage_errorpaths via the program's preAction hook — Codex M4 pass-2 §3), parse-boundaryusage_errorfor any non-trivial input shape. Shared scaffolding lives intests/integration/helpers.ts(M4 R6 —baseOptions / drive / EnvelopeShape / parseEnvelope / assertEnvelopeContract); new test files import from there.tests/e2e/<area>.test.ts— spawn the compiled binary against the in-process fixture HTTP server (tests/e2e/fixture-server.ts) for one command per noun. M3's E2E suite lives intests/e2e/m3.test.ts; copy the shape.
Hand-shaped objects are forbidden. Tests must drive real argv
through commander (program.parse(argv, { from: 'user' }) and pass
program.opts() through), not feed hand-shaped records into
parseGlobalFlags / input schemas. M0's review found this exact
shape silently passing — see .claude/rules/testing.md for the
canonical anti-pattern.
docs/cli-design.md— the binding contract. New command in §4.3 command tree; new error code in §6.5; new warning code in §6.1. Design changes go through Codex review before merge (seecli-design.mdhistoryee3f288,5218ca0for the pattern).docs/v0.1-plan.md— milestone block + post-mortem after Codex review. Spec gaps the implementation surfaced (e.g. M3's--limit-pagesflag) are logged as backfill work in the milestone's exit block.CLAUDE.md— bump "Current state" if a milestone shipped; bump "Contract headlines" if a binding decision moved.docs/api-reference.md— add to the cheat sheet only if the command introduces a Monday concept not already covered.
By default no test touches the network. To run live E2E tests:
export MONDAY_API_TOKEN=<test-workspace-token>
export RUN_LIVE_TESTS=1
npm run test:e2eLive tests must use a dedicated test workspace — never a production one —
and must clean up everything they create (use try { ... } finally { cleanup() }).
Not yet defined. Will use changesets or similar once we have something worth shipping.