@oppira/cli 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,147 @@
1
+ # Oppira CLI (`oppira`)
2
+
3
+ Terminal control for Oppira — competitor intelligence, playbook, studio, and ops —
4
+ in one command. Dependency-free Node ESM (Node ≥ 20): runs with just `node`.
5
+
6
+ ## Two execution modes
7
+
8
+ | Mode | How | Serves | Auth |
9
+ |------|-----|--------|------|
10
+ | **Remote** (default) | HTTP client against the deployed API | customers, agents, CI | JWT login, or durable `ca_` API key |
11
+ | **Local / ops** | Imports the backend engines + Mongo directly | the team | backend `.env` (MONGO_URI + secrets) |
12
+
13
+ Local mode reaches the **cron-only generators** (insights / alerts / battlecard) and
14
+ the **intelligent scraper** without needing new HTTP endpoints — it mirrors the
15
+ existing one-off scripts in `../scripts` (dotenv + `mongoose.connect` + engine call).
16
+ It only works from a repo checkout with the backend secrets present.
17
+
18
+ ## Install
19
+
20
+ **For end users (published package):**
21
+
22
+ ```bash
23
+ npm install -g @oppira/cli
24
+ oppira --help
25
+ ```
26
+
27
+ **From the repo (team / ops mode):**
28
+
29
+ ```bash
30
+ cd cli && npm link # then `oppira` is on your PATH
31
+ # or run directly:
32
+ node cli/bin/oppira.js --help
33
+ ```
34
+
35
+ Only the repo checkout can run the local/ops commands (they need the backend
36
+ `.env` + `node_modules`). The published package runs the **remote** commands.
37
+
38
+ ## First run
39
+
40
+ ```bash
41
+ oppira config set-url https://api.oppira.com # or leave default http://localhost:3008
42
+ oppira auth login --email you@co.com # stores a JWT session (~/.oppira)
43
+ oppira comp list
44
+ ```
45
+
46
+ ## Authentication: session vs. API key
47
+
48
+ Two ways to authenticate — pick one per profile:
49
+
50
+ 1. **Login session (works everywhere, default):**
51
+ ```bash
52
+ oppira auth login --email you@co.com
53
+ ```
54
+ Stores a JWT + refresh token; auto-refreshes. Best for interactive use.
55
+
56
+ 2. **API key (durable — best for CI / agents / automation):**
57
+ Generate the key in the **Oppira web app**, then paste it into the CLI:
58
+ ```bash
59
+ oppira auth key set ca_xxxxxxxx # or: oppira auth key generate
60
+ ```
61
+ > A `ca_` key unlocks the read-only `/mcp` analytics out of the box. To use it
62
+ > for the **full REST command set**, the server must have
63
+ > `OPPIRA_CLI_APIKEY_REST=true`. Until that's enabled, use `oppira auth login`
64
+ > for REST commands.
65
+
66
+ Config lives in `~/.oppira/config.json`; credentials (tokens, key) in
67
+ `~/.oppira/credentials.json` (chmod 600). Multiple profiles are supported
68
+ (`oppira config use <name>`, `--profile <name>`, or `OPPIRA_PROFILE`).
69
+
70
+ ## Commands
71
+
72
+ ```
73
+ auth login | logout | whoami | key generate|revoke
74
+ config show | list | set-url | set-output | use
75
+ comp list | show | add | track | toggle-tracking | insights | alerts | rm
76
+ discover profiles | job | suggestions | dismiss
77
+ scrape run (intelligent, local) | raw <platform> --url
78
+ insights list | summary | generate* (*local/ops force-run)
79
+ alerts list | run*
80
+ battlecard show | history | regen*
81
+ playbook show | bootstrap | refresh-section | refresh-cluster
82
+ studio posts | publish | calendars | calendar-generate | comments | draft-reply
83
+ artifact get | export
84
+ ask "<question>" (streams the unified agent reply)
85
+ admin stats | ai-usage | errors | users* | set-plan*
86
+ ```
87
+
88
+ Global flags: `--json` (machine output for scripts/agents), `--local`,
89
+ `--profile <name>`, `--help`, `--version`. Every command exits non-zero on failure.
90
+
91
+ ### Examples
92
+
93
+ ```bash
94
+ oppira ask "compare Acme and Globex activity this month"
95
+ oppira ask "summarize my playbook" --json | jq -r .text
96
+ oppira scrape run Acme --platforms fb,ig # ops, local mode
97
+ oppira insights generate --email client@co.com # force cron-only generator
98
+ oppira battlecard regen Globex
99
+ oppira admin errors --limit 20
100
+ oppira admin ai-usage --by-plan
101
+ ```
102
+
103
+ ## Server-side: enable API-key REST access (opt-in)
104
+
105
+ Granting a `ca_` key full REST authority (so users/CI can drive the CLI with just
106
+ an API key instead of a 15-min JWT) is a deliberate security decision and is **off
107
+ by default**. To turn on the "generate a key in the web app → use the CLI" flow,
108
+ set on the server:
109
+
110
+ ```
111
+ OPPIRA_CLI_APIKEY_REST=true
112
+ ```
113
+
114
+ Until then, REST commands authenticate with the JWT session from `oppira auth login`.
115
+
116
+ ## Publishing (maintainers)
117
+
118
+ The package is publish-ready (`publishConfig.access = public`, `files` whitelist).
119
+
120
+ ```bash
121
+ cd cli
122
+ npm version patch # or minor/major — bump the version
123
+ npm publish # publishes @oppira/cli to npm
124
+ ```
125
+
126
+ Prerequisites & notes:
127
+ - You must own/belong to the **`@oppira`** npm org for the scoped name. If you don't,
128
+ rename `name` in package.json to an unscoped `oppira-cli` (the `oppira` command name
129
+ is unchanged) and drop `publishConfig`.
130
+ - `npm login` first; the publish is public.
131
+ - Only `bin/`, `src/`, `README.md` ship (the `files` field) — no repo internals.
132
+ - End users then: `npm install -g @oppira/cli`.
133
+
134
+ ## Status (phased build)
135
+
136
+ - **Working now:** auth/config, competitors (+ analytics: timeline/compare/top-posts/tone in
137
+ local mode), discover, playbook, insights/alerts/battlecard reads, `ask` (SSE), artifact
138
+ get/export, admin (reads + user mgmt + `script` runner folding `../scripts`), studio
139
+ (posts/calendars/comments/templates/formats/settings/channels/assets/policy + actions),
140
+ local/ops force-generators + intelligent scrape.
141
+ - **Remote force-generate:** `insights generate`, `alerts run`, `battlecard regen` now hit
142
+ owner-scoped REST endpoints by default (added to the backend); pass `--local` to run the
143
+ engine directly instead.
144
+ - **Follow-up (still open):** MCP-backed analytics over HTTP (so timeline/compare/top-N work
145
+ remotely, not just local), deeper Studio (image-layer editing, ads), and per-endpoint
146
+ rate-limiting on the force-generate routes.
147
+ ```
package/bin/oppira.js ADDED
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env node
2
+ // Oppira CLI entrypoint. Thin shim — all logic lives in src/ so the package
3
+ // stays testable as a library too (see package.json "exports").
4
+ import { main } from '../src/index.js';
5
+
6
+ main(process.argv.slice(2)).catch((err) => {
7
+ // Last-resort handler: src/index.js already renders known errors nicely and
8
+ // sets process.exitCode. Anything reaching here is unexpected.
9
+ console.error(err?.stack || String(err));
10
+ process.exit(1);
11
+ });
package/package.json ADDED
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "@oppira/cli",
3
+ "version": "0.1.0",
4
+ "description": "Oppira CLI — terminal control for the adaptive marketing system (competitor intelligence, playbook, studio, ops).",
5
+ "type": "module",
6
+ "bin": {
7
+ "oppira": "bin/oppira.js"
8
+ },
9
+ "exports": {
10
+ ".": "./src/index.js"
11
+ },
12
+ "engines": {
13
+ "node": ">=20"
14
+ },
15
+ "files": [
16
+ "bin",
17
+ "src",
18
+ "README.md"
19
+ ],
20
+ "scripts": {
21
+ "start": "node bin/oppira.js",
22
+ "prepublishOnly": "node --check bin/oppira.js"
23
+ },
24
+ "keywords": [
25
+ "oppira",
26
+ "cli",
27
+ "competitor-analysis",
28
+ "marketing",
29
+ "competitive-intelligence"
30
+ ],
31
+ "homepage": "https://oppira.com",
32
+ "publishConfig": {
33
+ "access": "public"
34
+ },
35
+ "author": "Oppira",
36
+ "license": "UNLICENSED"
37
+ }
@@ -0,0 +1,174 @@
1
+ // admin: ops read surface (stats / AI usage / generation errors) over the
2
+ // admin REST endpoints, plus local user management and a runner that folds the
3
+ // existing ../scripts one-offs under one entry point. Consolidates what used to
4
+ // be ad-hoc scripts + manual mongosh.
5
+ import fs from 'node:fs';
6
+ import path from 'node:path';
7
+ import { spawn } from 'node:child_process';
8
+ import { apiRequest } from '../http.js';
9
+ import { withDb, loadModels, findUserOrThrow, backendDir } from '../local.js';
10
+ import { out, table, kv, info } from '../output.js';
11
+
12
+ const SCRIPTS_DIR = path.join(backendDir, 'scripts');
13
+
14
+ function listScripts() {
15
+ try {
16
+ return fs.readdirSync(SCRIPTS_DIR).filter((f) => f.endsWith('.js') || f.endsWith('.mjs')).sort();
17
+ } catch { return []; }
18
+ }
19
+
20
+ // Spawn `node scripts/<file>` from the backend root (scripts load their own
21
+ // dotenv). Resolves with the child exit code.
22
+ function runScript(file, args) {
23
+ return new Promise((resolve) => {
24
+ const child = spawn(process.execPath, [path.join(SCRIPTS_DIR, file), ...args], {
25
+ cwd: backendDir,
26
+ stdio: 'inherit',
27
+ });
28
+ child.on('close', (code) => resolve(code ?? 0));
29
+ child.on('error', (err) => { info(`✗ ${err.message}`); resolve(1); });
30
+ });
31
+ }
32
+
33
+ function resolveScriptFile(name) {
34
+ const files = listScripts();
35
+ // Accept exact, with-extension, or basename-without-extension.
36
+ return files.find((f) => f === name || f === `${name}.js` || f === `${name}.mjs` || f.replace(/\.(js|mjs)$/, '') === name);
37
+ }
38
+
39
+ export default {
40
+ description: 'Admin / ops — stats, AI usage, generation errors, user management.',
41
+ commands: {
42
+ stats: {
43
+ description: 'Platform stats.',
44
+ async handler(ctx) {
45
+ const res = await apiRequest(ctx.profileName, 'GET', '/admin/stats');
46
+ out(res?.data || res, ctx);
47
+ },
48
+ },
49
+
50
+ 'ai-usage': {
51
+ description: 'LLM cost / usage summary (add --by-plan to break down by plan).',
52
+ flags: [{ name: 'by-plan', boolean: true, description: 'Break down by subscription plan' }],
53
+ async handler(ctx) {
54
+ const path = ctx.bool('by-plan') ? '/admin/ai-usage/by-plan' : '/admin/ai-usage/summary';
55
+ const res = await apiRequest(ctx.profileName, 'GET', path);
56
+ out(res?.data || res, ctx);
57
+ },
58
+ },
59
+
60
+ errors: {
61
+ description: 'Recent AI generation errors.',
62
+ usage: 'admin errors [--limit 50]',
63
+ flags: [{ name: 'limit', description: 'Max rows' }],
64
+ async handler(ctx) {
65
+ const res = await apiRequest(ctx.profileName, 'GET', '/admin/generation-errors', {
66
+ query: { limit: ctx.flag('limit') },
67
+ });
68
+ const list = res?.data || res?.errors || res || [];
69
+ if (ctx.json) { out(list, ctx); return; }
70
+ const rows = (Array.isArray(list) ? list : []).map((e) => ({
71
+ when: e.createdAt || e.timestamp || '—',
72
+ subsystem: e.subsystem || '—',
73
+ flow: e.flow || '—',
74
+ user: e.userEmail || '—',
75
+ reason: e.reason || e.message || '—',
76
+ }));
77
+ table(rows, [
78
+ { key: 'when', label: 'WHEN' }, { key: 'subsystem', label: 'SUBSYSTEM' },
79
+ { key: 'flow', label: 'FLOW' }, { key: 'user', label: 'USER' },
80
+ { key: 'reason', label: 'REASON', max: 50 },
81
+ ], ctx);
82
+ },
83
+ },
84
+
85
+ users: {
86
+ description: 'List users (local/ops). Filter with --email substring.',
87
+ usage: 'admin users [--email foo]',
88
+ flags: [{ name: 'email', description: 'Filter by email substring' }],
89
+ async handler(ctx) {
90
+ const rows = await withDb(async () => {
91
+ const { User } = await loadModels();
92
+ const q = ctx.flag('email') ? { email: new RegExp(String(ctx.flag('email')), 'i') } : {};
93
+ const users = await User.find(q).select('email subscriptionPlan enabled emailVerified createdAt stripeCustomerId').limit(200);
94
+ return users.map((u) => ({
95
+ email: u.email,
96
+ plan: u.subscriptionPlan || 'free',
97
+ enabled: u.enabled ? 'yes' : 'no',
98
+ verified: u.emailVerified ? 'yes' : 'no',
99
+ stripe: u.stripeCustomerId ? 'yes' : 'no',
100
+ }));
101
+ });
102
+ table(rows, [
103
+ { key: 'email', label: 'EMAIL' }, { key: 'plan', label: 'PLAN' },
104
+ { key: 'enabled', label: 'ENABLED' }, { key: 'verified', label: 'VERIFIED' },
105
+ { key: 'stripe', label: 'STRIPE' },
106
+ ], ctx);
107
+ },
108
+ },
109
+
110
+ 'set-plan': {
111
+ description: 'Set a user\'s plan (local/ops). Refuses Stripe-managed accounts unless --force.',
112
+ usage: 'admin set-plan <email> <plan> [--force]',
113
+ flags: [{ name: 'force', boolean: true, description: 'Override the Stripe-managed guard' }],
114
+ async handler(ctx) {
115
+ const email = ctx.arg(0);
116
+ const plan = ctx.arg(1);
117
+ const allowed = ['free', 'basic', 'pro', 'data', 'admin'];
118
+ if (!email || !allowed.includes(plan)) throw new Error(`Usage: oppira admin set-plan <email> <${allowed.join('|')}>`);
119
+ const result = await withDb(async () => {
120
+ const user = await findUserOrThrow(email);
121
+ if (user.stripeCustomerId && !ctx.bool('force')) {
122
+ throw new Error(`${email} is Stripe-managed (stripeCustomerId set). Changing the plan here will drift from billing. Re-run with --force if you really mean it.`);
123
+ }
124
+ const prev = user.subscriptionPlan;
125
+ user.subscriptionPlan = plan;
126
+ await user.save();
127
+ return { email, from: prev, to: plan };
128
+ });
129
+ if (ctx.json) { out({ success: true, ...result }, ctx); return; }
130
+ info(`✓ ${result.email}: ${result.from} → ${result.to}`);
131
+ },
132
+ },
133
+
134
+ script: {
135
+ description: 'Run a backend ops script (folds ../scripts). No name → list them.',
136
+ usage: 'admin script <name> [-- <script args>]',
137
+ async handler(ctx) {
138
+ const name = ctx.arg(0);
139
+ const scripts = listScripts();
140
+ if (!name) {
141
+ if (ctx.json) { out(scripts, ctx); return; }
142
+ info('Available ops scripts (run: oppira admin script <name> -- <args>):');
143
+ for (const s of scripts) info(` ${s.replace(/\.(js|mjs)$/, '')}`);
144
+ return;
145
+ }
146
+ const file = resolveScriptFile(name);
147
+ if (!file) throw new Error(`Unknown script "${name}". One of: ${scripts.map((s) => s.replace(/\.(js|mjs)$/, '')).join(', ')}`);
148
+ // positionals[0] is the name; everything after (incl. what followed `--`) are script args.
149
+ const scriptArgs = ctx.positionals.slice(1);
150
+ info(`▶ node scripts/${file} ${scriptArgs.join(' ')}`.trim());
151
+ const code = await runScript(file, scriptArgs);
152
+ process.exitCode = code;
153
+ },
154
+ },
155
+
156
+ 'backfill-digests': {
157
+ description: 'Backfill CompetitorMonthlyDigest records (shortcut for the backfill script).',
158
+ usage: 'admin backfill-digests [--competitor <id>] [--force]',
159
+ flags: [
160
+ { name: 'competitor', description: 'Limit to one competitor id' },
161
+ { name: 'force', boolean: true, description: 'Ignore the checkpoint, redo everyone' },
162
+ ],
163
+ async handler(ctx) {
164
+ const file = resolveScriptFile('backfillCompetitorDigests');
165
+ if (!file) throw new Error('backfillCompetitorDigests script not found in ../scripts.');
166
+ const args = [];
167
+ if (ctx.flag('competitor')) args.push(`--competitorId=${ctx.flag('competitor')}`);
168
+ if (ctx.bool('force')) args.push('--force');
169
+ const code = await runScript(file, args);
170
+ process.exitCode = code;
171
+ },
172
+ },
173
+ },
174
+ };
@@ -0,0 +1,64 @@
1
+ // alerts: read (REST) + force-generate (local engine, cron-only otherwise).
2
+ import { apiRequest } from '../http.js';
3
+ import { getCredentials } from '../config.js';
4
+ import { withDb, findUserOrThrow, backendImport } from '../local.js';
5
+ import { out, table, info } from '../output.js';
6
+
7
+ function opsEmail(ctx) {
8
+ const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
9
+ if (!email) throw new Error('Provide --email (or log in) to select the target user.');
10
+ return email;
11
+ }
12
+
13
+ export default {
14
+ description: 'Alerts — list activity alerts and force-generate (ops).',
15
+ commands: {
16
+ list: {
17
+ description: 'List competitor-activity alerts.',
18
+ async handler(ctx) {
19
+ const res = await apiRequest(ctx.profileName, 'GET', '/users/alerts');
20
+ const alerts = res?.data || res?.alerts || res || [];
21
+ if (ctx.json) { out(alerts, ctx); return; }
22
+ const rows = (Array.isArray(alerts) ? alerts : []).map((a) => ({
23
+ severity: a.severity || a.priority || '—',
24
+ type: a.type || a.alertType || '—',
25
+ competitor: a.competitorName || a.competitor || '—',
26
+ message: a.message || a.title || '—',
27
+ read: a.read || a.isRead ? 'yes' : 'no',
28
+ }));
29
+ table(rows, [
30
+ { key: 'severity', label: 'SEV' }, { key: 'type', label: 'TYPE' },
31
+ { key: 'competitor', label: 'COMPETITOR' }, { key: 'message', label: 'MESSAGE', max: 55 },
32
+ { key: 'read', label: 'READ' },
33
+ ], ctx);
34
+ },
35
+ },
36
+
37
+ run: {
38
+ description: 'Force-generate alerts now (remote by default; --local runs the engine directly).',
39
+ usage: 'alerts run [--local] [--email you@co.com]',
40
+ flags: [
41
+ { name: 'local', boolean: true, description: 'Run the engine directly (ops; needs repo + .env)' },
42
+ { name: 'email', description: 'Target user in --local mode (defaults to logged-in profile)' },
43
+ ],
44
+ async handler(ctx) {
45
+ if (!ctx.useLocal) {
46
+ const res = await apiRequest(ctx.profileName, 'POST', '/alerts/generate', { body: {} });
47
+ if (ctx.json) { out(res, ctx); return; }
48
+ info('✓ Alert generation triggered.');
49
+ if (res?.result) out(res.result, ctx);
50
+ return;
51
+ }
52
+ const email = opsEmail(ctx);
53
+ const result = await withDb(async () => {
54
+ const user = await findUserOrThrow(email);
55
+ const { generateAlertsForUser } = await backendImport('utils/alertGenerator.js');
56
+ return generateAlertsForUser(user);
57
+ });
58
+ if (ctx.json) { out({ success: true, email, result }, ctx); return; }
59
+ info(`✓ Alert generation run for ${email} (local).`);
60
+ if (result) out(result, ctx);
61
+ },
62
+ },
63
+ },
64
+ };
@@ -0,0 +1,50 @@
1
+ // artifact: fetch a generated artifact (report/deck/diagram) and export it to
2
+ // a file. Export can be binary (pdf/pptx), so it bypasses the JSON client.
3
+ import fs from 'node:fs';
4
+ import { apiRequest } from '../http.js';
5
+ import { getProfile, getCredentials } from '../config.js';
6
+ import { out, info } from '../output.js';
7
+
8
+ function joinUrl(base, pathname) { return `${base.replace(/\/$/, '')}/${pathname.replace(/^\//, '')}`; }
9
+
10
+ export default {
11
+ description: 'Artifacts — fetch and export generated reports/decks/diagrams.',
12
+ commands: {
13
+ get: {
14
+ description: 'Fetch an artifact by id.',
15
+ usage: 'artifact get <id>',
16
+ async handler(ctx) {
17
+ const id = ctx.arg(0);
18
+ if (!id) throw new Error('Usage: oppira artifact get <id>');
19
+ const res = await apiRequest(ctx.profileName, 'GET', `/assistant/artifacts/${id}`);
20
+ out(res, ctx);
21
+ },
22
+ },
23
+
24
+ export: {
25
+ description: 'Export an artifact to a file (md/html/pdf/pptx).',
26
+ usage: 'artifact export <id> --format md [--out file]',
27
+ flags: [
28
+ { name: 'format', description: 'Export format (md, html, pdf, pptx, …)' },
29
+ { name: 'out', description: 'Output file path (default: <id>.<format>)' },
30
+ ],
31
+ async handler(ctx) {
32
+ const id = ctx.arg(0);
33
+ const format = ctx.flag('format') || 'md';
34
+ if (!id) throw new Error('Usage: oppira artifact export <id> --format md [--out file]');
35
+ const profile = getProfile(ctx.profileName);
36
+ const creds = getCredentials(profile.name);
37
+ const auth = creds.accessToken ? `Bearer ${creds.accessToken}` : (creds.apiKey ? `Bearer ${creds.apiKey}` : null);
38
+ const res = await fetch(joinUrl(profile.apiUrl, `/assistant/artifacts/${id}/export/${format}`), {
39
+ headers: auth ? { Authorization: auth } : {},
40
+ });
41
+ if (!res.ok) throw new Error(`Export failed (${res.status})`);
42
+ const buf = Buffer.from(await res.arrayBuffer());
43
+ const outPath = ctx.flag('out') || `${id}.${format}`;
44
+ fs.writeFileSync(outPath, buf);
45
+ if (ctx.json) { out({ success: true, file: outPath, bytes: buf.length }, ctx); return; }
46
+ info(`✓ Exported ${buf.length} bytes → ${outPath}`);
47
+ },
48
+ },
49
+ },
50
+ };
@@ -0,0 +1,53 @@
1
+ // ask: one-shot question to the unified agent (/agent/chat, SSE). The
2
+ // orchestrator picks intel / strategist / planner; we stream the reply to
3
+ // stdout. Reads the message from args or stdin (so you can pipe into it).
4
+ import { streamChat } from '../sse.js';
5
+ import { out, info } from '../output.js';
6
+
7
+ async function readStdin() {
8
+ if (process.stdin.isTTY) return '';
9
+ const chunks = [];
10
+ for await (const chunk of process.stdin) chunks.push(chunk);
11
+ return Buffer.concat(chunks).toString('utf8').trim();
12
+ }
13
+
14
+ export const askCommand = {
15
+ description: 'Ask the Oppira agent a question (streams the reply). Reads from args or stdin.',
16
+ usage: 'ask "compare Acme and Globex activity" [--conversation <id>] [--json]',
17
+ flags: [
18
+ { name: 'conversation', description: 'Continue an existing conversation by id' },
19
+ { name: 'json', boolean: true, description: 'Emit a single JSON object instead of streaming text' },
20
+ ],
21
+ async handler(ctx) {
22
+ let message = ctx.positionals.join(' ').trim();
23
+ if (!message) message = await readStdin();
24
+ if (!message) throw new Error('Nothing to ask. Provide a message or pipe text in.');
25
+
26
+ const conversationId = ctx.flag('conversation');
27
+ let agent = null;
28
+ let convId = null;
29
+ let text = '';
30
+
31
+ await streamChat(ctx.profileName, { message, conversationId }, (ev) => {
32
+ switch (ev.type) {
33
+ case 'conversation-id': convId = ev.id; break;
34
+ case 'agent':
35
+ agent = ev.agent;
36
+ if (!ctx.json) info(`[${ev.agent}]`);
37
+ break;
38
+ case 'text-delta':
39
+ text += ev.delta || '';
40
+ if (!ctx.json) process.stdout.write(ev.delta || '');
41
+ break;
42
+ default: break; // navigate & others: ignore for CLI
43
+ }
44
+ });
45
+
46
+ if (ctx.json) {
47
+ out({ conversationId: convId, agent, text }, ctx);
48
+ } else {
49
+ process.stdout.write('\n');
50
+ if (convId) info(`\n(conversation: ${convId} — continue with: oppira ask "..." --conversation ${convId})`);
51
+ }
52
+ },
53
+ };
@@ -0,0 +1,134 @@
1
+ // auth: login (JWT session), logout, whoami, and durable API-key management.
2
+ import readline from 'node:readline';
3
+ import { login, apiRequest, ApiError } from '../http.js';
4
+ import { getProfile, getCredentials, setCredentials, clearCredentials } from '../config.js';
5
+ import { out, kv, info } from '../output.js';
6
+
7
+ function question(query, { hidden } = {}) {
8
+ return new Promise((resolve) => {
9
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout, terminal: true });
10
+ if (hidden) {
11
+ rl._writeToOutput = (str) => {
12
+ if (str.includes(query)) rl.output.write(query);
13
+ else rl.output.write('');
14
+ };
15
+ }
16
+ rl.question(query, (answer) => {
17
+ rl.close();
18
+ if (hidden) rl.output.write('\n');
19
+ resolve(answer.trim());
20
+ });
21
+ });
22
+ }
23
+
24
+ export default {
25
+ description: 'Authentication — login, API keys, session info.',
26
+ commands: {
27
+ login: {
28
+ description: 'Log in with email + password (stores a JWT session for this profile).',
29
+ usage: 'auth login [--email you@co.com] [--password ***]',
30
+ flags: [
31
+ { name: 'email', description: 'Account email (prompted if omitted)' },
32
+ { name: 'password', description: 'Account password (prompted, hidden, if omitted)' },
33
+ ],
34
+ async handler(ctx) {
35
+ const profile = getProfile(ctx.profileName);
36
+ const email = ctx.flag('email') || await question('Email: ');
37
+ const password = ctx.flag('password') || await question('Password: ', { hidden: true });
38
+ const { user } = await login(profile.name, email, password);
39
+ const plan = user?.subscriptionPlan || 'free';
40
+ if (ctx.json) { out({ success: true, profile: profile.name, email: user?.email, plan }, ctx); return; }
41
+ info(`✓ Logged in as ${user?.email} (plan: ${plan}) on profile "${profile.name}" → ${profile.apiUrl}`);
42
+ },
43
+ },
44
+
45
+ logout: {
46
+ description: 'Clear stored credentials for the current profile.',
47
+ async handler(ctx) {
48
+ clearCredentials(ctx.profileName);
49
+ if (ctx.json) { out({ success: true, profile: ctx.profileName }, ctx); return; }
50
+ info(`✓ Logged out of profile "${ctx.profileName}".`);
51
+ },
52
+ },
53
+
54
+ whoami: {
55
+ description: 'Show the current account / session.',
56
+ async handler(ctx) {
57
+ const profile = getProfile(ctx.profileName);
58
+ const creds = getCredentials(profile.name);
59
+ if (!creds.accessToken && !creds.apiKey) {
60
+ info(`Not logged in on profile "${profile.name}". Run: oppira auth login`);
61
+ process.exitCode = 1;
62
+ return;
63
+ }
64
+ try {
65
+ const res = await apiRequest(profile.name, 'POST', '/users/validate-token', { body: {} });
66
+ const user = res?.user || res;
67
+ kv({
68
+ profile: profile.name,
69
+ apiUrl: profile.apiUrl,
70
+ email: user?.email || creds.email,
71
+ plan: user?.subscriptionPlan,
72
+ hasApiKey: Boolean(creds.apiKey),
73
+ }, ctx);
74
+ } catch (err) {
75
+ // Token check failed but we have stored creds — show what we know.
76
+ if (err instanceof ApiError) info(`(session check failed: ${err.message})`);
77
+ kv({ profile: profile.name, apiUrl: profile.apiUrl, email: creds.email, hasApiKey: Boolean(creds.apiKey) }, ctx);
78
+ }
79
+ },
80
+ },
81
+
82
+ key: {
83
+ description: 'Manage the durable API key: generate | set <key> | show | revoke.',
84
+ usage: 'auth key <generate|set <ca_...>|show|revoke>',
85
+ async handler(ctx) {
86
+ const action = ctx.arg(0);
87
+ const profile = getProfile(ctx.profileName);
88
+
89
+ if (action === 'generate') {
90
+ const res = await apiRequest(profile.name, 'POST', '/users/generate-api-key', { body: {} });
91
+ setCredentials(profile.name, { apiKey: res.apiKey });
92
+ if (ctx.json) { out({ success: true, apiKey: res.apiKey }, ctx); return; }
93
+ info('✓ API key generated and stored for this profile.');
94
+ info(` ${res.apiKey}`);
95
+ return;
96
+ }
97
+
98
+ // Store a key generated elsewhere (e.g. on the Oppira web app). This is
99
+ // the usual customer flow: create the key in the UI, paste it here.
100
+ if (action === 'set') {
101
+ const key = ctx.arg(1) || ctx.flag('key');
102
+ if (!key || !String(key).startsWith('ca_')) {
103
+ throw new Error('Provide the key: oppira auth key set ca_xxxxx');
104
+ }
105
+ setCredentials(profile.name, { apiKey: String(key) });
106
+ if (ctx.json) { out({ success: true }, ctx); return; }
107
+ info(`✓ API key stored for profile "${profile.name}".`);
108
+ info(' Note: REST commands accept this key only if the server has OPPIRA_CLI_APIKEY_REST=true;');
109
+ info(' otherwise the key is used for MCP/analytics and you log in with "oppira auth login" for REST.');
110
+ return;
111
+ }
112
+
113
+ if (action === 'show') {
114
+ const creds = getCredentials(profile.name);
115
+ if (!creds.apiKey) { info('No API key stored for this profile.'); process.exitCode = 1; return; }
116
+ const masked = `${creds.apiKey.slice(0, 6)}…${creds.apiKey.slice(-4)}`;
117
+ if (ctx.json) { out({ apiKey: masked }, ctx); return; }
118
+ info(`API key (profile "${profile.name}"): ${masked}`);
119
+ return;
120
+ }
121
+
122
+ if (action === 'revoke') {
123
+ await apiRequest(profile.name, 'DELETE', '/users/revoke-api-key', {});
124
+ setCredentials(profile.name, { apiKey: undefined });
125
+ if (ctx.json) { out({ success: true }, ctx); return; }
126
+ info('✓ API key revoked.');
127
+ return;
128
+ }
129
+
130
+ throw new Error('Usage: oppira auth key <generate|set <ca_...>|show|revoke>');
131
+ },
132
+ },
133
+ },
134
+ };