Skip to content
Merged
64 changes: 62 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

`reply` is the command-line interface for [Reply.io](https://reply.io). Sign in
once and every Reply.io API request runs as you — from your terminal or your
scripts. Today it handles authentication and identity; resource commands for
sequences, contacts, and the inbox are on the way.
scripts. Today it handles authentication and identity, and `reply api` gives you
authenticated access to the full v3 API; higher-level commands for sequences,
contacts, and the inbox are on the way.

## Installation

Expand Down Expand Up @@ -73,12 +74,71 @@ reply --profile bob@reply.io auth whoami # override for a single command
The active profile is resolved as `--profile` → `REPLY_PROFILE` → the profile
set with `profile use` → the built-in default.

Manage profiles after creating them:

```sh
reply profile show # inspect the current profile (no secrets)
reply profile show alice@reply.io # inspect a specific one
reply profile rename alice@reply.io ally # also moves the stored credential
reply profile unset ally team-id # clear a field (authority|api_base|team-id)
reply profile delete ally # remove it and its stored credential
```

`profile show` lists the backend URLs, pinned team, and which authorization
would be used (in priority order: `--api-key` → `REPLY_API_KEY` → stored
credential) — it never prints tokens or keys. `--authority` and `--api-base`
must be `http(s)` URLs.

## Teams

A profile can pin a team (workspace); it's sent as `X-TEAM-ID`, with precedence
`--team-id` → `REPLY_TEAM_ID` → the profile's team. The `team` command sees and
sets the **current profile's** team:

```sh
reply team list # teams you can act in (* marks the profile's team)
reply team current # the profile's pinned team + the effective team (from whoami)
reply team use 1045 # verify 1045 is one of your teams, then pin it on the current profile
reply team clear # remove the pin
```

If a call needs a team and you're in more than one, the API answers with a
`TEAM_REQUIRED` error listing your teams — run `reply team use <id>` to pin one.

## Raw API access

`reply api` is a raw, authenticated passthrough to any v3 endpoint — the
agent/CI escape hatch. See the
[Reply API reference](https://docs.reply.io/api-reference/introduction) for the
full surface.

Use the path exactly as it appears in the docs (starting with `/v3`); the query
string goes in the path. The request URL is literally `api_base + path`, and the
profile stores the host **without** `/v3`, so the call's URL matches the docs. A
`--body` switches the method to POST (it also accepts `@file` or `-` for stdin).

```sh
reply api /v3/whoami # your identity + team
reply api /v3/sequences # list sequences
reply api /v3/contacts --pretty # list contacts, indented
reply api /v3/sequences/12345 # one sequence by id
reply api /v3/contacts --body @contact.json # create a contact (POST; body schema per the docs)
echo '<json>' | reply api /v3/contacts --body - # body from stdin
reply api /v3/whoami --verbose # full request/response on stderr
```

It prints `{ "code": <status>, "data": <body> }` and exits non-zero on HTTP
`>= 400`. On a team/user-resolution conflict it adds a short fix-it hint on
stderr. Add `--verbose` for a full request/response trace on stderr with
credentials redacted; stdout stays the plain JSON, so pipes keep working.

## Environment variables

| Variable | Description |
|----------|-------------|
| `REPLY_API_KEY` | API key used as the credential for the current invocation |
| `REPLY_PROFILE` | Profile to use (same as `--profile`) |
| `REPLY_TEAM_ID` | Team/workspace id sent as `X-TEAM-ID` (same as `--team-id`) |
| `REPLY_CONFIG_DIR` | Config directory (default `~/.config/reply`; `%APPDATA%\reply` on Windows) |

## Contributing
Expand Down
139 changes: 139 additions & 0 deletions src/__tests__/commands/api.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import {describe, it, expect, beforeEach, afterEach, vi} from 'vitest';
import fs from 'fs';
import os from 'os';
import path from 'path';

const mock_fetch = vi.fn();
vi.stubGlobal('fetch', mock_fetch);

import {handle_api, read_body_arg} from '../../commands/api';
import {UsageError} from '../../utils/errors';
import type {Cli_context} from '../../context';
import type {CredentialStore, Api_key_record} from '../../credentials/types';

const res = (data: unknown, status = 200)=>
new Response(JSON.stringify(data), {status, headers: {'Content-Type': 'application/json'}});

const api_key_record: Api_key_record = {type: 'api_key', key: 'k', user: {id: 1}};
const fake_store = (): CredentialStore=>({
get: async()=>api_key_record, set: async()=>{}, remove: async()=>true, keys: async()=>['dev'],
});
const ctx = (): Cli_context=>({
profile: 'dev', authority: 'https://auth', api_base: 'https://api', key: 'dev',
store: fake_store(), refresh: async(r)=>r,
});

const capture = async(fn: () => Promise<void>): Promise<{out: string; err: string}>=>{
const out: string[] = [];
const err: string[] = [];
const log = console.log;
const error = console.error;
const write = process.stdout.write;
console.log = (...a: unknown[])=>{ out.push(a.join(' ')); };
console.error = (...a: unknown[])=>{ err.push(a.join(' ')); };
process.stdout.write = ((c: unknown): boolean=>{ out.push(String(c)); return true; }) as typeof process.stdout.write;
try { await fn(); } finally { console.log = log; console.error = error; process.stdout.write = write; }
return {out: out.join('\n').trim(), err: err.join('\n').trim()};
};

const method_of = ()=>mock_fetch.mock.calls[0][1].method as string;

let dir: string;
beforeEach(()=>{
vi.clearAllMocks();
process.exitCode = 0;
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'reply-api-'));
});
afterEach(()=>{
process.exitCode = 0;
fs.rmSync(dir, {recursive: true, force: true});
});

describe('handle_api', ()=>{
it('GETs by default and builds the URL as base + path literally', async()=>{
mock_fetch.mockResolvedValue(res({ok: true}, 200));
const {out} = await capture(()=>handle_api('/v3/whoami', {}, ctx(), {}));
expect(method_of()).toBe('GET');
expect(mock_fetch.mock.calls[0][0]).toBe('https://api/v3/whoami');
expect(JSON.parse(out)).toEqual({code: 200, data: {ok: true}});
});

it('POSTs when a --body is given and sends it', async()=>{
mock_fetch.mockResolvedValue(res({id: 1}, 201));
await capture(()=>handle_api('/x', {body: '{"a":1}'}, ctx(), {}));
expect(method_of()).toBe('POST');
expect(mock_fetch.mock.calls[0][1].body).toBe('{"a":1}');
});

it('honors an explicit --method override', async()=>{
mock_fetch.mockResolvedValue(res({}, 200));
await capture(()=>handle_api('/x/9', {method: 'delete'}, ctx(), {}));
expect(method_of()).toBe('DELETE');
});

it('rejects an invalid JSON body', async()=>{
await expect(handle_api('/x', {body: '{bad'}, ctx(), {})).rejects.toThrow(UsageError);
});

it('rejects a disallowed method', async()=>{
await expect(handle_api('/x', {method: 'FROB'}, ctx(), {})).rejects.toThrow(UsageError);
});

it('prints {code,data} and sets exit 1 on a non-2xx', async()=>{
mock_fetch.mockResolvedValue(res({code: 'contact.notFound'}, 404));
const {out} = await capture(()=>handle_api('/x', {}, ctx(), {}));
expect(JSON.parse(out).code).toBe(404);
expect(process.exitCode).toBe(1);
});

it('--verbose prints a redacted req/resp trace to stderr (stdout stays {code,data})', async()=>{
mock_fetch.mockResolvedValue(res({ok: true}, 200));
const secret_store: CredentialStore = {
get: async()=>({type: 'api_key', key: 'SEKRET-TOKEN', user: {id: 1}}),
set: async()=>{}, remove: async()=>true, keys: async()=>['dev'],
};
const c: Cli_context = {
profile: 'dev', authority: 'https://auth', api_base: 'https://api', key: 'dev',
store: secret_store, refresh: async(r)=>r,
};
const {out, err} = await capture(()=>handle_api('/v3/whoami', {}, c, {verbose: true}));
expect(err).toContain('> GET https://api/v3/whoami');
expect(err).toMatch(/> Authorization: Bearer •+/);
expect(err).toContain('< 200');
expect(err).not.toContain('SEKRET-TOKEN');
expect(JSON.parse(out).code).toBe(200);
});

it('prints team-conflict guidance to stderr on TEAM_REQUIRED', async()=>{
mock_fetch.mockResolvedValue(res({code: 'TEAM_REQUIRED', teams: [{teamId: 1045, teamName: 'Acme'}]}, 403));
const {out, err} = await capture(()=>handle_api('/contacts', {}, ctx(), {}));
expect(JSON.parse(out).code).toBe(403);
expect(err).toMatch(/multiple teams/i);
expect(err).toContain('1045');
expect(process.exitCode).toBe(1);
});
});

describe('read_body_arg', ()=>{
it('parses inline JSON', ()=>{
expect(read_body_arg('{"a":1}')).toEqual({a: 1});
});

it('reads @file', ()=>{
const f = path.join(dir, 'body.json');
fs.writeFileSync(f, '{"b":2}');
expect(read_body_arg(`@${f}`)).toEqual({b: 2});
});

it('reads - from stdin (injected)', ()=>{
expect(read_body_arg('-', ()=>'{"c":3}')).toEqual({c: 3});
});

it('returns undefined when absent', ()=>{
expect(read_body_arg(undefined)).toBeUndefined();
});

it('throws UsageError on invalid JSON', ()=>{
expect(()=>read_body_arg('nope')).toThrow(UsageError);
});
});
Loading
Loading