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
79 changes: 77 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -52,33 +52,63 @@ OAuth authorize/token endpoints derive from this base, so logging in targets the
```
kit login Authenticate via OAuth (PKCE)
kit logout Clear stored OAuth tokens
kit account View account info
kit config show Show all config and auth status
```

### account

```
account View account info
account colors
account set-colors <hex...> Replace brand colors (up to 10)
account creator-profile
account email-stats
account growth-stats [options]
```

### subscribers

```
list [options]
get [options] <id>
filter [options] Filter by engagement, sign-up date, state, tags
create [options] <email>
update [options] <id>
unsubscribe <id>
tags [options] <id>
stats [options] <id>
```

`list` takes `--slim` to drop the expensive optional fields.

`filter` reads its conditions from `--json <json>` or `--file <path>`, as either a
bare conditions array or a full body with an `all` key:

```
kit subscribers filter --json '[{"type":"subscriber_state","states":["active"]}]'
kit subscribers filter --file conditions.json --include tags,stats --stats-start 2026-05-01
```

`create` and `update` print a warning on stderr when the API ignores a custom
field key. Keys are the field's `key`, not its label, so `last_name` rather than
`Last Name`.

### tags

```
list [options]
create <name>
update <id> <name> Rename a tag
subscribers [options] <tagId>
add <tagId> <subscriberId>
add-by-email <tagId> <email>
remove <tagId> <subscriberId>
remove-by-email <tagId> <email>
```

`subscribers` filters on `--state`, `--created-after`, `--created-before`,
`--tagged-after`, and `--tagged-before`.

### forms

```
Expand All @@ -92,11 +122,23 @@ add-by-email <formId> <email>

```
list [options]
get [options] <id>
create [options] --name <name>
update [options] <id>
delete <id>
subscribers [options] <sequenceId>
add <sequenceId> <subscriberId>
add-by-email <sequenceId> <email>
emails list [options] <sequenceId>
emails get [options] <sequenceId> <id>
emails create [options] <sequenceId> --subject <s> --delay-value <n> --delay-unit <days|hours>
emails update [options] <sequenceId> <id>
emails delete <sequenceId> <id>
```

`list` and `get` take `--include stats`. `emails list` also takes
`--include-content`.

### broadcasts

```
Expand All @@ -105,9 +147,13 @@ get [options] <id>
create [options]
update [options] <id>
delete <id>
stats [options] <id>
stats [options] [id] One broadcast, or every broadcast with no ID
clicks [options] <id> Link click stats
```

`list` and `stats` filter on `--status <draft|scheduled|sending|completed|aborted>`,
`--sent-after`, and `--sent-before`.

### custom-fields

```
Expand All @@ -122,6 +168,7 @@ delete <id>
```
list [options]
get [options] <id>
create --file <path> Record a purchase from JSON
```

### webhooks
Expand All @@ -132,6 +179,25 @@ create [options] <targetUrl> <eventName>
delete <id>
```

### posts

```
list [options] --include-content for post bodies
get [options] <id>
```

### snippets

```
list [options] --snippet-type <inline|block>, --archived
get [options] <id>
create [options] <name> --type <inline|block>
update [options] <id> --name, --content, --html, --archive, --restore
```

An inline snippet holds Liquid text, passed with `--content`. A block snippet
holds HTML, passed with `--html`.

### segments · email-templates

```
Expand All @@ -145,6 +211,7 @@ All bulk commands take `--file <path>` (JSON array) and optional `--callback-url
```
bulk subscribers create --file <path> [{email_address, first_name?, state?}, ...]
bulk tags create --file <path> [{name}, ...]
bulk tags delete --file <path> [{id}, ...]
bulk tags add --file <path> [{tag_id, subscriber_id}, ...]
bulk tags remove --file <path> [{tag_id, subscriber_id}, ...]
bulk forms add --file <path> [{form_id, subscriber_id, referrer?}, ...]
Expand All @@ -163,6 +230,14 @@ bulk custom-fields update-values --file <path> [{subscriber_id, subscriber_cust

Run `kit <command> --help` for full flag details on any command.

## API coverage

[`spec/coverage.js`](spec/coverage.js) maps every operation in the stored API spec
to the command that reaches it. A test holds the map to the spec and to the
command tree, so a spec change that adds or drops an endpoint fails the suite
until someone triages it, and the map can never name a command that no longer
exists. Today it covers all 73 operations.

## Claude Code Skill

```
Expand Down
45 changes: 2 additions & 43 deletions bin/kit.js
Original file line number Diff line number Diff line change
@@ -1,46 +1,5 @@
#!/usr/bin/env node

import { Command } from 'commander';
import { accountCommand, configCommand, setupSkillCommand } from '../src/commands/account.js';
import { loginCommand, logoutCommand } from '../src/commands/auth.js';
import { bulkCommand } from '../src/commands/bulk.js';
import { subscribersCommand } from '../src/commands/subscribers.js';
import { tagsCommand } from '../src/commands/tags.js';
import { formsCommand } from '../src/commands/forms.js';
import { sequencesCommand } from '../src/commands/sequences.js';
import { broadcastsCommand } from '../src/commands/broadcasts.js';
import { customFieldsCommand } from '../src/commands/custom-fields.js';
import { purchasesCommand } from '../src/commands/purchases.js';
import { webhooksCommand } from '../src/commands/webhooks.js';
import { segmentsCommand } from '../src/commands/segments.js';
import { emailTemplatesCommand } from '../src/commands/email-templates.js';
import { postsCommand } from '../src/commands/posts.js';
import { snippetsCommand } from '../src/commands/snippets.js';
import { buildProgram } from '../src/program.js';

const program = new Command();

program
.name('kit')
.description('CLI for the Kit (ConvertKit) email marketing API (V4)')
.version('1.0.0');

program.addCommand(loginCommand());
program.addCommand(logoutCommand());
program.addCommand(accountCommand());
program.addCommand(configCommand());
program.addCommand(setupSkillCommand());
program.addCommand(subscribersCommand());
program.addCommand(tagsCommand());
program.addCommand(formsCommand());
program.addCommand(sequencesCommand());
program.addCommand(broadcastsCommand());
program.addCommand(customFieldsCommand());
program.addCommand(purchasesCommand());
program.addCommand(webhooksCommand());
program.addCommand(segmentsCommand());
program.addCommand(emailTemplatesCommand());
program.addCommand(postsCommand());
program.addCommand(snippetsCommand());
program.addCommand(bulkCommand());

program.parse();
buildProgram().parse();
125 changes: 125 additions & 0 deletions scripts/spec-coverage.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
/**
* Holds spec/coverage.js to the spec and to the command tree.
*
* This is the test that makes an api-spec-change issue actionable. When the spec
* gains or loses an endpoint, the first two tests here fail and name it. When a
* command is renamed or removed, the third fails and names it.
*/
import { test, describe } from 'node:test';
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { COVERAGE, NOT_EXPOSED, specOperations } from '../spec/coverage.js';
import { buildProgram } from '../src/program.js';

const __dirname = dirname(fileURLToPath(import.meta.url));
const spec = JSON.parse(readFileSync(join(__dirname, '..', 'spec', 'v4.json'), 'utf8'));

const OPERATIONS = specOperations(spec);

/** Every command path in the tree, as space-separated strings. */
function commandPaths(cmd, prefix = []) {
const paths = [];
for (const child of cmd.commands) {
if (child.name() === 'help') continue;
const path = [...prefix, child.name()];
paths.push(path.join(' '));
paths.push(...commandPaths(child, path));
}
return paths;
}

const COMMANDS = new Set(commandPaths(buildProgram()));

describe('spec coverage', () => {
test('the spec has operations to check', () => {
assert.ok(OPERATIONS.length > 40, `only found ${OPERATIONS.length} operations`);
});

test('every spec operation is accounted for', () => {
const missing = OPERATIONS.filter((op) => !(op in COVERAGE) && !(op in NOT_EXPOSED));
assert.deepEqual(
missing,
[],
`The spec has operations that spec/coverage.js does not mention. Add a CLI ` +
`command for each one, or add it to NOT_EXPOSED with a reason:\n ` +
missing.join('\n ')
);
});

test('no coverage entry names an operation the spec dropped', () => {
const known = new Set(OPERATIONS);
const stale = [...Object.keys(COVERAGE), ...Object.keys(NOT_EXPOSED)].filter((op) => !known.has(op));
assert.deepEqual(
stale,
[],
`spec/coverage.js mentions operations the spec no longer has:\n ` + stale.join('\n ')
);
});

test('every command named in the map exists in the command tree', () => {
const broken = Object.entries(COVERAGE)
.filter(([, command]) => !COMMANDS.has(command))
.map(([op, command]) => `${op} -> ${command}`);
assert.deepEqual(
broken,
[],
`spec/coverage.js names commands that do not exist:\n ` + broken.join('\n ')
);
});

test('every NOT_EXPOSED entry gives a reason', () => {
const unexplained = Object.entries(NOT_EXPOSED)
.filter(([, reason]) => typeof reason !== 'string' || reason.trim().length === 0)
.map(([op]) => op);
assert.deepEqual(unexplained, []);
});

test('an operation is not both covered and skipped', () => {
const both = Object.keys(COVERAGE).filter((op) => op in NOT_EXPOSED);
assert.deepEqual(both, []);
});
});

describe('command tree', () => {
test('every top-level command has a description', () => {
const undescribed = buildProgram()
.commands.filter((c) => c.name() !== 'help' && !c.description())
.map((c) => c.name());
assert.deepEqual(undescribed, []);
});

test('every leaf command has a description', () => {
const walk = (cmd, prefix = []) => {
const bad = [];
for (const child of cmd.commands) {
if (child.name() === 'help') continue;
const path = [...prefix, child.name()];
if (!child.description()) bad.push(path.join(' '));
bad.push(...walk(child, path));
}
return bad;
};
assert.deepEqual(walk(buildProgram()), []);
});

test('no two sibling commands share a name', () => {
const walk = (cmd, prefix = []) => {
const dupes = [];
const seen = new Set();
for (const child of cmd.commands) {
const name = child.name();
if (seen.has(name)) dupes.push([...prefix, name].join(' '));
seen.add(name);
dupes.push(...walk(child, [...prefix, name]));
}
return dupes;
};
assert.deepEqual(walk(buildProgram()), []);
});

test('the program reports its version', () => {
assert.match(buildProgram().version(), /^\d+\.\d+\.\d+$/);
});
});
Loading
Loading