diff --git a/packages/human/README.md b/packages/human/README.md index 26fc3a5f967b..7b845ec9bc93 100644 --- a/packages/human/README.md +++ b/packages/human/README.md @@ -49,10 +49,22 @@ human invite carol --via email --email carol@acme.com - `--to ` — 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 ` — 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 diff --git a/packages/human/package.json b/packages/human/package.json index 3b57403b8f85..ff95b525fe4b 100644 --- a/packages/human/package.json +++ b/packages/human/package.json @@ -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": { diff --git a/packages/human/src/commands/channels.ts b/packages/human/src/commands/channels.ts index 52f484f7068a..ea3a44ee3633 100644 --- a/packages/human/src/commands/channels.ts +++ b/packages/human/src/commands/channels.ts @@ -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 { const config = loadConfig(); @@ -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 }); diff --git a/packages/human/src/commands/interact.spec.ts b/packages/human/src/commands/interact.spec.ts index 056242f05208..ca33465cef82 100644 --- a/packages/human/src/commands/interact.spec.ts +++ b/packages/human/src/commands/interact.spec.ts @@ -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(); @@ -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']); @@ -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); diff --git a/packages/human/src/commands/interact.ts b/packages/human/src/commands/interact.ts index 6f57129c8885..eab031785e00 100644 --- a/packages/human/src/commands/interact.ts +++ b/packages/human/src/commands/interact.ts @@ -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'; @@ -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 @@ -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 { 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 = { diff --git a/packages/human/src/config.spec.ts b/packages/human/src/config.spec.ts index bc181a8a23eb..6a4d7d9432db 100644 --- a/packages/human/src/config.spec.ts +++ b/packages/human/src/config.spec.ts @@ -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', () => { @@ -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', () => { @@ -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' }, + }); + }); }); diff --git a/packages/human/src/config.ts b/packages/human/src/config.ts index 81cc767bc00b..b92b140e3987 100644 --- a/packages/human/src/config.ts +++ b/packages/human/src/config.ts @@ -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'); @@ -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; } diff --git a/packages/human/src/index.ts b/packages/human/src/index.ts index b56cc5351452..3bee3cb8e540 100644 --- a/packages/human/src/index.ts +++ b/packages/human/src/index.ts @@ -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 ', - '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 ', + 'deliver on a specific linked channel (telegram, slack, email) instead of the default (env: HUMAN_VIA)' ) - .option('--via ', 'deliver on a specific linked channel (telegram, slack, email) instead of the default') .option('--from ', 'attribution label shown to the human (e.g. "deploy-bot")') .option('--ttl ', 'time until the request expires (e.g. 90s, 10m, 2h; max 72h; default 24h)') .option('--timeout ', 'max time to block waiting (default: block until answered/expired)') diff --git a/packages/human/src/skills/content/human-cli/SKILL.md b/packages/human/src/skills/content/human-cli/SKILL.md index 551346a9f447..9ad1a19cd1e2 100644 --- a/packages/human/src/skills/content/human-cli/SKILL.md +++ b/packages/human/src/skills/content/human-cli/SKILL.md @@ -48,7 +48,7 @@ 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* @@ -56,6 +56,11 @@ 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: