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
14 changes: 13 additions & 1 deletion packages/human/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,10 +49,22 @@ human invite carol --via email --email carol@acme.com
- `--to <humanId>` — address a human who is already linked (`human invite` first), or comma-separated humans (`alice,bob`, max 50) so any listed person can settle.
- `--via <platform>` — deliver on a specific linked channel instead of the default.

## Auth
## Auth & headless use

`setup` stores credentials in `~/.novu/human.json`. Alternatively set `NOVU_SECRET_KEY` (and optionally `NOVU_API_URL`) for an existing Novu environment.

In containers, sandboxes, and CI — anywhere no config file exists — the CLI is fully operational from environment variables alone:

```bash
docker run -e NOVU_SECRET_KEY=... -e HUMAN_TO=alice -e HUMAN_VIA=slack agent \
npx @novu/human approve "Deploy to prod?"
```

- `HUMAN_TO` — default recipient subscriberId(s), comma-separated like `--to` (max 50).
- `HUMAN_VIA` — default channel (`telegram`, `slack`, or `email`), like `--via`.

Precedence is always **CLI flags > environment variables > `~/.novu/human.json`**, and env values are never written back to the config file.

## Teaching your coding agent to use it

`setup` offers to install a skill (the [agentskills.io](https://agentskills.io) `SKILL.md` format) that teaches
Expand Down
2 changes: 1 addition & 1 deletion packages/human/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@novu/human",
"version": "0.1.3",
"version": "0.2.0",
"description": "The human API for agents — ask, approve, choose, tell. Agents reach a human on Telegram/Slack and block until the human answers.",
"main": "dist/src/index.js",
"publishConfig": {
Expand Down
8 changes: 3 additions & 5 deletions packages/human/src/commands/channels.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
import pc from 'picocolors';
import { loadConfig, NOT_SET_UP_MESSAGE, saveConfig } from '../config';
import { loadConfig, NOT_SET_UP_MESSAGE, saveConfig, SUPPORTED_CHANNELS } from '../config';
import { fail } from '../output';

const SUPPORTED = ['telegram', 'slack', 'email'] as const;

export async function channelsCommand(options: { default?: string; json?: boolean }): Promise<never> {
const config = loadConfig();

Expand All @@ -13,8 +11,8 @@ export async function channelsCommand(options: { default?: string; json?: boolea

if (options.default) {
const target = options.default.toLowerCase();
if (!(SUPPORTED as readonly string[]).includes(target)) {
fail(`Unknown channel "${target}". Use one of: ${SUPPORTED.join(', ')}.`);
if (!(SUPPORTED_CHANNELS as readonly string[]).includes(target)) {
fail(`Unknown channel "${target}". Use one of: ${SUPPORTED_CHANNELS.join(', ')}.`);
}

saveConfig({ ...config, defaultChannel: target });
Expand Down
46 changes: 44 additions & 2 deletions packages/human/src/commands/interact.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { describe, expect, it, vi } from 'vitest';
import { afterEach, describe, expect, it, vi } from 'vitest';

vi.mock('../output', async (importOriginal) => {
const original = await importOriginal<typeof import('../output')>();
Expand All @@ -11,9 +11,11 @@ vi.mock('../output', async (importOriginal) => {
};
});

const { parseDuration, parseHumanToOption } = await import('./interact');
const { parseDuration, parseHumanToOption, resolveTo } = await import('./interact');
const { exitCodeFor, EXIT_DENIED, EXIT_GONE, EXIT_OK, EXIT_TIMEOUT } = await import('../output');

type HumanCliConfig = import('../config').HumanCliConfig;

describe('parseHumanToOption', () => {
it('splits comma-separated humans and dedupes', () => {
expect(parseHumanToOption('alice')).toEqual(['alice']);
Expand All @@ -28,6 +30,46 @@ describe('parseHumanToOption', () => {
});
});

describe('resolveTo', () => {
const config: HumanCliConfig = {
apiUrl: 'https://api.novu.co',
auth: { mode: 'apiKey', secretKey: 'api_key_private' },
relayAgentIdentifier: 'human-relay',
subscriberId: 'dave',
};

afterEach(() => {
delete process.env.HUMAN_TO;
});

it('works env-only, with no subscriberId in the config', () => {
process.env.HUMAN_TO = 'alice';
expect(resolveTo({ ...config, subscriberId: undefined })).toEqual(['alice']);
});

it('splits, trims, and dedupes a comma list from the env', () => {
process.env.HUMAN_TO = ' alice , bob , alice ';
expect(resolveTo(config)).toEqual(['alice', 'bob']);
});

it('applies the recipient cap to the env list and names the source', () => {
process.env.HUMAN_TO = Array.from({ length: 51 }, (_, index) => `s${index}`).join(',');
expect(() => resolveTo(config)).toThrow('HUMAN_TO supports at most 50');
});

it('lets a --to flag beat HUMAN_TO, and env beat the config file', () => {
process.env.HUMAN_TO = 'alice';
expect(resolveTo(config, 'carol')).toEqual(['carol']);
expect(resolveTo(config)).toEqual(['alice']);
});

it('falls through to the config subscriberId when the env is unset or blank', () => {
expect(resolveTo(config)).toBe('dave');
process.env.HUMAN_TO = ' ';
expect(resolveTo(config)).toBe('dave');
});
});

describe('parseDuration', () => {
it('parses plain seconds and suffixed durations', () => {
expect(parseDuration('90')).toBe(90);
Expand Down
28 changes: 21 additions & 7 deletions packages/human/src/commands/interact.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ import {
type Interaction,
type InteractionKind,
} from '../api/human';
import { NOT_SET_UP_MESSAGE, resolveConfig, resolveVia } from '../config';
import { type HumanCliConfig, NOT_SET_UP_MESSAGE, resolveConfig, resolveVia } from '../config';
import { EXIT_TIMEOUT, emitResult, fail } from '../output';
import { sleep } from '../poll';
import { startWaitIndicator } from '../spinner';
Expand Down Expand Up @@ -42,7 +42,7 @@ export function clientFromConfig(apiUrl?: string): {
/** Matches Novu `HUMAN_INTERACTION_MAX_RECIPIENTS`. The CLI cannot import `@novu/shared`. */
const MAX_HUMAN_TO = 50;

export function parseHumanToOption(raw: string): string[] {
export function parseHumanToOption(raw: string, label = '`--to`'): string[] {
const ids = [
...new Set(
raw
Expand All @@ -52,29 +52,43 @@ export function parseHumanToOption(raw: string): string[] {
),
];
if (ids.length === 0) {
fail('`--to` must include at least one subscriberId');
fail(`${label} must include at least one subscriberId`);
}

if (ids.length > MAX_HUMAN_TO) {
fail(`\`--to\` supports at most ${MAX_HUMAN_TO} subscriberIds`);
fail(`${label} supports at most ${MAX_HUMAN_TO} subscriberIds`);
}

return ids;
}

/** Recipient precedence: `--to` flag > HUMAN_TO env > config file subscriberId. */
export function resolveTo(config: HumanCliConfig, toFlag?: string): string | string[] | undefined {
if (toFlag) {
return parseHumanToOption(toFlag);
}

const envTo = process.env.HUMAN_TO?.trim();
if (envTo) {
return parseHumanToOption(envTo, 'HUMAN_TO');
}

return config.subscriberId;
}

/** Shared engine behind ask / approve / choose / tell. */
export async function runInteraction(kind: InteractionKind, prompt: string, options: InteractOptions): Promise<never> {
try {
const { client, config } = clientFromConfig(options.apiUrl);

const to = options.to ? parseHumanToOption(options.to) : config.subscriberId;
const to = resolveTo(config, options.to);

if (!to) {
fail(NOT_SET_UP_MESSAGE);
}

// `--via` or the saved defaultChannel preference; omit via and the API
// picks when only one channel is linked.
// `--via`, HUMAN_VIA, or the saved defaultChannel preference; omit
// via and the API picks when only one channel is linked.
const via = resolveVia(config, options.via);

const input: CreateInteractionInput = {
Expand Down
32 changes: 32 additions & 0 deletions packages/human/src/config.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ const base: HumanCliConfig = {
afterEach(() => {
delete process.env.NOVU_HUMAN_CONFIG;
delete process.env.NOVU_SECRET_KEY;
delete process.env.HUMAN_VIA;
});

describe('config migration', () => {
Expand Down Expand Up @@ -70,6 +71,26 @@ describe('resolveVia', () => {
it('omits via when nothing is preferred so the API can pick', () => {
expect(resolveVia({ ...base, subscriberId: 'human_abc' })).toBeUndefined();
});

it('falls back to HUMAN_VIA over the config default, case-insensitively', () => {
process.env.HUMAN_VIA = 'Telegram';
expect(resolveVia(config)).toBe('telegram');
});

it('lets a --via flag beat HUMAN_VIA', () => {
process.env.HUMAN_VIA = 'telegram';
expect(resolveVia(config, 'email')).toBe('email');
});

it('rejects an unsupported HUMAN_VIA value', () => {
process.env.HUMAN_VIA = 'carrier-pigeon';
expect(() => resolveVia(config)).toThrow(/Invalid HUMAN_VIA/);
});

it('ignores an empty HUMAN_VIA', () => {
process.env.HUMAN_VIA = ' ';
expect(resolveVia(config)).toBe('slack');
});
});

describe('resolveConfig', () => {
Expand All @@ -93,4 +114,15 @@ describe('resolveConfig', () => {
defaultChannel: 'telegram',
});
});

it('works with no config file at all when NOVU_SECRET_KEY is set', () => {
process.env.NOVU_HUMAN_CONFIG = join(mkdtempSync(join(tmpdir(), 'human-config-')), 'missing.json');
process.env.NOVU_SECRET_KEY = 'api_key_private';

expect(resolveConfig()).toEqual({
apiUrl: 'https://api.novu.co',
relayAgentIdentifier: 'human-relay',
auth: { mode: 'apiKey', secretKey: 'api_key_private' },
});
});
});
19 changes: 15 additions & 4 deletions packages/human/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,10 @@ export interface HumanCliConfig {
export const DEFAULT_API_URL = 'https://api.novu.co';
export const DEFAULT_RELAY_AGENT_IDENTIFIER = 'human-relay';

export const SUPPORTED_CHANNELS = ['telegram', 'slack', 'email'] as const;

export const NOT_SET_UP_MESSAGE =
'No human connected yet. Ask your human to run: npx @novu/human setup (or set NOVU_SECRET_KEY).';
'No human connected yet. Ask your human to run: npx @novu/human setup (or set NOVU_SECRET_KEY + HUMAN_TO).';

export function configPath(): string {
return process.env.NOVU_HUMAN_CONFIG ?? join(homedir(), '.novu', 'human.json');
Expand Down Expand Up @@ -97,14 +99,23 @@ export function resolveConfig(overrides?: { apiUrl?: string }): HumanCliConfig {
}

/**
* Channel preference for create: `--via` wins, otherwise the configured
* default. When neither is set, returns undefined and the API picks the sole
* linked channel (or errors if several are linked).
* Channel preference for create: `--via` wins, then HUMAN_VIA, then the
* configured default. When none is set, returns undefined and the API picks
* the sole linked channel (or errors if several are linked).
*/
export function resolveVia(config: HumanCliConfig, via?: string): HumanChannelPlatform | undefined {
if (via) {
return via.toLowerCase();
}

const envVia = process.env.HUMAN_VIA?.trim().toLowerCase();
if (envVia) {
if (!(SUPPORTED_CHANNELS as readonly string[]).includes(envVia)) {
throw new Error(`Invalid HUMAN_VIA "${envVia}". Use one of: ${SUPPORTED_CHANNELS.join(', ')}.`);
}

return envVia;
}

return config.defaultChannel;
}
18 changes: 16 additions & 2 deletions packages/human/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,13 +19,27 @@ program
)
.version(version);

program.addHelpText(
'after',
'\nEnvironment variables (headless/containerized use, no config file needed):\n' +
' NOVU_SECRET_KEY Novu API secret key (replaces `human setup` auth)\n' +
' HUMAN_TO default recipient subscriberId(s), comma-separated (as --to)\n' +
' HUMAN_VIA default channel: telegram, slack, or email (as --via)\n' +
' NOVU_API_URL Novu API URL override\n' +
' NOVU_HUMAN_CONFIG config file path override\n' +
'Precedence: CLI flags > environment variables > ~/.novu/human.json\n'
);

function withCommonOptions(command: Command): Command {
return command
.option(
'--to <humanId>',
'address a linked human, or comma-separated humans (max 50; first valid answer wins; link others with `human invite`)'
'address a linked human, or comma-separated humans (max 50; first valid answer wins; link others with `human invite`) (env: HUMAN_TO)'
)
.option(
'--via <platform>',
'deliver on a specific linked channel (telegram, slack, email) instead of the default (env: HUMAN_VIA)'
)
.option('--via <platform>', 'deliver on a specific linked channel (telegram, slack, email) instead of the default')
.option('--from <name>', 'attribution label shown to the human (e.g. "deploy-bot")')
.option('--ttl <duration>', 'time until the request expires (e.g. 90s, 10m, 2h; max 72h; default 24h)')
.option('--timeout <duration>', 'max time to block waiting (default: block until answered/expired)')
Expand Down
7 changes: 6 additions & 1 deletion packages/human/src/skills/content/human-cli/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,14 +48,19 @@ process/logs already surface.
If it isn't configured on this machine yet, every command exits 1 with:

```
No human connected yet. Ask your human to run: npx @novu/human setup
No human connected yet. Ask your human to run: npx @novu/human setup (or set NOVU_SECRET_KEY + HUMAN_TO).
```

Treat that message as the actual next step: surface it to whoever *is*
reachable (chat, PR description, logs) rather than silently giving up or
looping. Never attempt to configure it on the human's behalf — you don't have
their Telegram/Slack/email credentials, and setup is interactive by design.

In sandboxes and containers with no config file, the CLI is fully operational
when `NOVU_SECRET_KEY` and `HUMAN_TO` are set in the environment
(optionally `HUMAN_VIA` for the channel). `--to`/`--via` flags still
override the env values.

To reach a *different* person than the one who ran setup, they need a linked
channel too:

Expand Down
Loading