Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
50e3b55
feat(api): add generic `workos api` gateway for raw API requests
nicknisi May 4, 2026
ca3c9af
feat(api): add interactive request builder and fix duplicate resolveA…
nicknisi May 4, 2026
619b578
fix: Temporarily copy over the YAML spec
nicknisi May 4, 2026
0054db9
refactor(api): use @workos/openapi-spec package instead of vendored YAML
nicknisi May 5, 2026
5a5ad2b
chore(build): remove vendored YAML copy from postbuild
nicknisi May 5, 2026
39c67aa
refactor(api): address code review findings
nicknisi May 5, 2026
add7794
fix(api): handle invalid JSON body in dry-run JSON mode and add tests
nicknisi May 5, 2026
aaaa66f
fix(api): emit pure JSON in JSON mode and gracefully handle missing -…
devin-ai-integration[bot] May 5, 2026
ff3b3e7
fix(api): emit structured error from runApiInteractive in JSON mode
devin-ai-integration[bot] May 5, 2026
2d87eb7
fix(api): respect empty bodies and require --yes for mutating JSON re…
devin-ai-integration[bot] May 5, 2026
0b23d79
fix(api): close remaining bugs flagged by Devin Review and CodeRabbit
devin-ai-integration[bot] May 5, 2026
f2af84f
fix(api): refuse JSON-mode interactive flow even in a TTY
devin-ai-integration[bot] May 5, 2026
37ef3b8
fix(api): expose --insecure-storage in help-json for the api command
devin-ai-integration[bot] May 5, 2026
4912d24
fix(api): tighten edge-case handling in body, path, and fetch flows
devin-ai-integration[bot] May 5, 2026
b843058
fix(api): URL-encode path param values in interactive mode
devin-ai-integration[bot] May 5, 2026
a57f38d
fix(api): resolve $ref params and dedupe path/operation overlap
devin-ai-integration[bot] May 5, 2026
11e5ffb
fix(api): switch catalog spec read to async to match CLAUDE.md
devin-ai-integration[bot] May 5, 2026
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: 3 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -49,20 +49,21 @@
"@anthropic-ai/sdk": "^0.78.0",
"@clack/core": "^1.0.1",
"@clack/prompts": "1.0.1",
"@hono/node-server": "^1",
"@napi-rs/keyring": "^1.2.0",
"@workos-inc/node": "^8.7.0",
"@workos/openapi-spec": "^0.1.0",
"@workos/skills": "0.5.0",
"chalk": "^5.6.2",
"diff": "^8.0.3",
"fast-glob": "^3.3.3",
"hono": "^4",
"ink": "^6.8.0",
"opn": "^5.4.0",
"react": "^19.2.4",
"semver": "^7.7.4",
"uuid": "^13.0.0",
"xstate": "^5.28.0",
"hono": "^4",
"@hono/node-server": "^1",
"yaml": "^2.8.2",
"yargs": "^18.0.0",
"zod": "^4.3.6"
Expand Down
8 changes: 8 additions & 0 deletions pnpm-lock.yaml

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

82 changes: 82 additions & 0 deletions src/bin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -430,6 +430,88 @@ yargs(rawArgs)
);
return yargs.demandCommand(1, 'Please specify an env subcommand').strict();
})
.command(
'api [endpoint] [filter]',
'Make authenticated requests to the WorkOS API',
(yargs) =>
yargs
.options(insecureStorageOption)
.positional('endpoint', {
type: 'string',
describe: "API endpoint path (e.g. /users), or 'ls' to list endpoints",
})
.positional('filter', {
type: 'string',
describe: 'Filter keyword (used with ls)',
})
.option('method', {
alias: 'X',
type: 'string',
describe: 'HTTP method (default: GET, or POST if body provided)',
})
.option('data', {
alias: 'd',
type: 'string',
describe: 'JSON request body',
})
.option('file', {
type: 'string',
describe: 'Read request body from a file (or - for stdin)',
})
.option('include', {
alias: 'i',
type: 'boolean',
default: false,
describe: 'Show response headers',
})
.option('api-key', {
type: 'string',
describe: 'Override the API key',
})
.option('dry-run', {
type: 'boolean',
default: false,
describe: 'Show the request without executing it',
})
.option('yes', {
alias: 'y',
type: 'boolean',
default: false,
describe: 'Skip confirmation for mutating requests',
})
.example('workos api ls', 'List all available endpoints')
.example('workos api ls users', 'List endpoints matching "users"')
.example('workos api /user_management/users', 'GET /user_management/users')
.example('workos api /organizations -d \'{"name":"Acme"}\'', 'POST with a JSON body')
.example('workos api /organizations/org_123 -X DELETE', 'DELETE an organization'),
Comment thread
devin-ai-integration[bot] marked this conversation as resolved.
async (argv) => {
await applyInsecureStorage(argv.insecureStorage as boolean | undefined);
const endpoint = argv.endpoint as string | undefined;
const filter = argv.filter as string | undefined;

const { runApiLs, runApiRequest, runApiInteractive } = await import('./commands/api/index.js');

if (!endpoint) {
await runApiInteractive();
return;
}

if (endpoint === 'ls') {
await runApiLs(filter);
return;
}

await runApiRequest(endpoint, {
method: argv.method,
data: argv.data,
file: argv.file,
include: argv.include,
apiKey: argv.apiKey,
dryRun: argv.dryRun,
yes: argv.yes,
});
},
)
Comment thread
coderabbitai[bot] marked this conversation as resolved.
.command(['organization', 'org'], 'Manage WorkOS organizations (create, update, get, list, delete)', (yargs) => {
yargs.options({
...insecureStorageOption,
Expand Down
247 changes: 247 additions & 0 deletions src/commands/api/catalog.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,247 @@
import { describe, it, expect } from 'vitest';
import { parseSpec, endpointsByTag, type EndpointInfo } from './catalog.js';

const SAMPLE_SPEC = `
openapi: 3.0.0
info:
title: Test
version: 1.0.0
paths:
/organizations:
get:
operationId: listOrganizations
summary: List organizations
tags: [Organizations]
parameters:
- name: limit
in: query
required: false
description: Max items
post:
operationId: createOrganization
summary: Create organization
tags: [Organizations]
requestBody:
required: true
content:
application/json:
schema:
type: object
/organizations/{id}:
parameters:
- name: id
in: path
required: true
description: Organization id
get:
operationId: getOrganization
summary: Get organization
tags: [Organizations]
delete:
operationId: deleteOrganization
summary: Delete organization
tags: [Organizations]
/users:
get:
operationId: listUsers
summary: List users
tags: [Users]
`;

describe('parseSpec', () => {
it('returns endpoints for each method on a path', () => {
const catalog = parseSpec(SAMPLE_SPEC);
const ops = catalog.endpoints.filter((e) => e.path === '/organizations').map((e) => e.method);
expect(ops.sort()).toEqual(['GET', 'POST']);
});

it('captures summary, tag, and operationId', () => {
const catalog = parseSpec(SAMPLE_SPEC);
const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET');
expect(get).toMatchObject({
summary: 'List organizations',
tag: 'Organizations',
operationId: 'listOrganizations',
});
});

it('extracts path parameters from shared parameters block', () => {
const catalog = parseSpec(SAMPLE_SPEC);
const get = catalog.endpoints.find((e) => e.path === '/organizations/{id}' && e.method === 'GET');
expect(get?.pathParams).toEqual([{ name: 'id', description: 'Organization id', required: true }]);
expect(get?.queryParams).toEqual([]);
});

it('extracts query parameters from operation', () => {
const catalog = parseSpec(SAMPLE_SPEC);
const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET');
expect(get?.queryParams).toEqual([{ name: 'limit', description: 'Max items', required: false }]);
});

it('flags hasRequestBody when requestBody is present', () => {
const catalog = parseSpec(SAMPLE_SPEC);
const post = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'POST');
const get = catalog.endpoints.find((e) => e.path === '/organizations' && e.method === 'GET');
expect(post?.hasRequestBody).toBe(true);
expect(get?.hasRequestBody).toBe(false);
});

it('produces a sorted unique tags list', () => {
const catalog = parseSpec(SAMPLE_SPEC);
expect(catalog.tags).toEqual(['Organizations', 'Users']);
});

it('returns an empty catalog when paths is missing', () => {
const catalog = parseSpec('openapi: 3.0.0\ninfo:\n title: t\n version: 1.0.0\n');
expect(catalog.endpoints).toEqual([]);
expect(catalog.tags).toEqual([]);
});

it('resolves $ref parameters against components.parameters', () => {
const yaml = `
openapi: 3.0.0
info:
title: Test
version: 1.0.0
components:
parameters:
SharedId:
name: id
in: path
required: true
description: Shared id parameter
SharedLimit:
name: limit
in: query
required: false
description: Page size
paths:
/widgets/{id}:
parameters:
- $ref: '#/components/parameters/SharedId'
get:
operationId: getWidget
summary: Get widget
parameters:
- $ref: '#/components/parameters/SharedLimit'
`;
const catalog = parseSpec(yaml);
const ep = catalog.endpoints.find((e) => e.path === '/widgets/{id}' && e.method === 'GET');
expect(ep?.pathParams).toEqual([{ name: 'id', description: 'Shared id parameter', required: true }]);
expect(ep?.queryParams).toEqual([{ name: 'limit', description: 'Page size', required: false }]);
});

it('skips $ref parameters that cannot be resolved instead of leaking placeholders', () => {
const yaml = `
openapi: 3.0.0
info:
title: Test
version: 1.0.0
paths:
/widgets/{id}:
parameters:
- $ref: '#/components/parameters/Missing'
- name: id
in: path
required: true
description: Inline id
get:
operationId: getWidget
summary: Get widget
`;
const catalog = parseSpec(yaml);
const ep = catalog.endpoints[0];
// The unresolvable $ref should be silently dropped; the inline param survives.
expect(ep?.pathParams).toEqual([{ name: 'id', description: 'Inline id', required: true }]);
});

it('deduplicates params by (name, in) — operation-level overrides path-level', () => {
const yaml = `
openapi: 3.0.0
info:
title: Test
version: 1.0.0
paths:
/widgets/{id}:
parameters:
- name: id
in: path
required: true
description: From path-level
get:
operationId: getWidget
summary: Get widget
parameters:
- name: id
in: path
required: true
description: From operation-level (wins)
`;
const catalog = parseSpec(yaml);
const ep = catalog.endpoints[0];
expect(ep?.pathParams).toEqual([{ name: 'id', description: 'From operation-level (wins)', required: true }]);
});

it('falls back to "other" tag when none is provided', () => {
const yaml = `
openapi: 3.0.0
info:
title: Test
version: 1.0.0
paths:
/noop:
get:
operationId: noop
summary: No tag
`;
const catalog = parseSpec(yaml);
expect(catalog.endpoints[0]?.tag).toBe('other');
expect(catalog.tags).toEqual(['other']);
});
});

describe('endpointsByTag', () => {
const endpoints: EndpointInfo[] = [
{
method: 'GET',
path: '/users',
summary: '',
tag: 'Users',
operationId: 'listUsers',
pathParams: [],
queryParams: [],
hasRequestBody: false,
},
{
method: 'POST',
path: '/organizations',
summary: '',
tag: 'Organizations',
operationId: 'createOrg',
pathParams: [],
queryParams: [],
hasRequestBody: true,
},
{
method: 'DELETE',
path: '/users/{id}',
summary: '',
tag: 'Users',
operationId: 'deleteUser',
pathParams: [],
queryParams: [],
hasRequestBody: false,
},
];

it('groups endpoints by tag preserving insertion order', () => {
const grouped = endpointsByTag(endpoints);
expect([...grouped.keys()]).toEqual(['Users', 'Organizations']);
expect(grouped.get('Users')?.map((e) => e.operationId)).toEqual(['listUsers', 'deleteUser']);
expect(grouped.get('Organizations')?.map((e) => e.operationId)).toEqual(['createOrg']);
});

it('returns an empty map when no endpoints are provided', () => {
expect(endpointsByTag([]).size).toBe(0);
});
});
Loading
Loading