@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/src/index.js ADDED
@@ -0,0 +1,108 @@
1
+ // Program definition + dispatch. Assembles the command tree from the command
2
+ // modules and routes `oppira <group> <command> ...` (or a top-level command
3
+ // like `ask`) to the right handler, with generated help and error rendering.
4
+
5
+ import {
6
+ parseArgs, makeContext, collectBooleanFlags,
7
+ renderProgramHelp, renderGroupHelp, renderCommandHelp,
8
+ } from './framework.js';
9
+ import { resolveProfileName } from './config.js';
10
+ import { renderError } from './output.js';
11
+
12
+ import authGroup from './commands/auth.js';
13
+ import configGroup from './commands/config.js';
14
+ import competitorsGroup from './commands/competitors.js';
15
+ import discoverGroup from './commands/discover.js';
16
+ import scrapeGroup from './commands/scrape.js';
17
+ import insightsGroup from './commands/insights.js';
18
+ import alertsGroup from './commands/alerts.js';
19
+ import battlecardGroup from './commands/battlecard.js';
20
+ import playbookGroup from './commands/playbook.js';
21
+ import studioGroup from './commands/studio.js';
22
+ import artifactGroup from './commands/artifact.js';
23
+ import adminGroup from './commands/admin.js';
24
+ import { askCommand } from './commands/ask.js';
25
+
26
+ export function buildProgram() {
27
+ return {
28
+ name: 'oppira',
29
+ version: '0.1.0',
30
+ description: 'Terminal control for Oppira — competitor intelligence, playbook, studio & ops.',
31
+ groups: {
32
+ auth: authGroup,
33
+ config: configGroup,
34
+ competitors: competitorsGroup,
35
+ comp: competitorsGroup, // alias
36
+ discover: discoverGroup,
37
+ scrape: scrapeGroup,
38
+ insights: insightsGroup,
39
+ alerts: alertsGroup,
40
+ battlecard: battlecardGroup,
41
+ playbook: playbookGroup,
42
+ studio: studioGroup,
43
+ artifact: artifactGroup,
44
+ admin: adminGroup,
45
+ },
46
+ topLevel: {
47
+ ask: askCommand,
48
+ },
49
+ };
50
+ }
51
+
52
+ export async function main(argv) {
53
+ const program = buildProgram();
54
+ const tokens = argv;
55
+
56
+ if (tokens.length === 0 || tokens[0] === '--help' || tokens[0] === '-h') {
57
+ console.log(renderProgramHelp(program));
58
+ return;
59
+ }
60
+ if (tokens[0] === '--version' || tokens[0] === '-v') {
61
+ console.log(program.version);
62
+ return;
63
+ }
64
+
65
+ const first = tokens[0];
66
+ let cmd, cmdPath, rest;
67
+
68
+ if (program.groups[first]) {
69
+ const group = program.groups[first];
70
+ const sub = tokens[1];
71
+ if (!sub || sub === '--help' || sub === '-h') {
72
+ console.log(renderGroupHelp(program, first, group));
73
+ return;
74
+ }
75
+ cmd = group.commands[sub];
76
+ if (!cmd) {
77
+ renderError(new Error(`Unknown command: ${first} ${sub}`));
78
+ console.log('\n' + renderGroupHelp(program, first, group));
79
+ return;
80
+ }
81
+ cmdPath = `${first} ${sub}`;
82
+ rest = tokens.slice(2);
83
+ } else if (program.topLevel[first]) {
84
+ cmd = program.topLevel[first];
85
+ cmdPath = first;
86
+ rest = tokens.slice(1);
87
+ } else {
88
+ renderError(new Error(`Unknown command: ${first}`));
89
+ console.log('\n' + renderProgramHelp(program));
90
+ return;
91
+ }
92
+
93
+ const { positionals, flags } = parseArgs(rest, collectBooleanFlags(cmd));
94
+ if (flags.help) {
95
+ console.log(renderCommandHelp(program, cmdPath, cmd));
96
+ return;
97
+ }
98
+
99
+ const ctx = makeContext({ positionals, flags });
100
+ ctx.profileName = resolveProfileName(flags);
101
+ ctx.program = program;
102
+
103
+ try {
104
+ await cmd.handler(ctx);
105
+ } catch (err) {
106
+ renderError(err);
107
+ }
108
+ }
package/src/local.js ADDED
@@ -0,0 +1,99 @@
1
+ // Ops / "local mode". Runs commands against MongoDB + the backend's engine
2
+ // functions directly, instead of over HTTP. This is how the CLI reaches the
3
+ // cron-only generators (battlecard / insights / alerts / summary) and the
4
+ // intelligent scraper without needing new HTTP endpoints — mirroring the
5
+ // existing one-off scripts in ../scripts (dotenv + mongoose.connect + engine).
6
+ //
7
+ // Only usable from a repo checkout with MONGO_URI available; the published
8
+ // customer build never exercises this path.
9
+
10
+ import fs from 'node:fs';
11
+ import path from 'node:path';
12
+ import { fileURLToPath } from 'node:url';
13
+
14
+ const BACKEND_ROOT = fileURLToPath(new URL('../../', import.meta.url));
15
+
16
+ // Absolute path to the backend repo root (for spawning ../scripts, etc.).
17
+ export const backendDir = BACKEND_ROOT;
18
+
19
+ // Dynamic import of a module inside the backend, resolved from the repo root.
20
+ export async function backendImport(relPath) {
21
+ const abs = path.join(BACKEND_ROOT, relPath);
22
+ return import(pathToFileUrl(abs));
23
+ }
24
+
25
+ function pathToFileUrl(abs) {
26
+ // Cross-platform file URL (Windows paths need this).
27
+ const url = new URL('file://');
28
+ url.pathname = abs.replace(/\\/g, '/');
29
+ // On Windows the drive letter needs a leading slash.
30
+ return /^[a-zA-Z]:/.test(abs) ? `file:///${abs.replace(/\\/g, '/')}` : url.href;
31
+ }
32
+
33
+ let connected = false;
34
+
35
+ export async function connectDb() {
36
+ if (connected) return;
37
+ // Local/ops mode drives the backend engines directly — those modules read
38
+ // secrets (MONGO_URI, RESEND_API_KEY, APIFY_API_KEY, LLM keys) at import time,
39
+ // so the backend .env must be present. Fail early and clearly if it isn't,
40
+ // instead of surfacing a downstream client's cryptic "missing API key".
41
+ const envPath = path.join(BACKEND_ROOT, '.env');
42
+ if (!fs.existsSync(envPath) && !process.env.MONGO_URI) {
43
+ throw new Error(
44
+ `Local/ops mode needs the backend .env (looked in ${BACKEND_ROOT}). Run from a repo checkout with secrets, or use the remote (HTTP) commands instead.`
45
+ );
46
+ }
47
+ const dotenv = await import('dotenv');
48
+ dotenv.config({ path: envPath });
49
+ if (!process.env.MONGO_URI) {
50
+ throw new Error('MONGO_URI not set. Local/ops mode needs the backend .env (run from the repo).');
51
+ }
52
+ const mongoose = (await import('mongoose')).default;
53
+ await mongoose.connect(process.env.MONGO_URI);
54
+ connected = true;
55
+ return mongoose;
56
+ }
57
+
58
+ export async function disconnectDb() {
59
+ if (!connected) return;
60
+ const mongoose = (await import('mongoose')).default;
61
+ await mongoose.disconnect();
62
+ connected = false;
63
+ }
64
+
65
+ // Run fn with a live DB connection, always disconnecting afterwards.
66
+ export async function withDb(fn) {
67
+ await connectDb();
68
+ try {
69
+ return await fn();
70
+ } finally {
71
+ await disconnectDb();
72
+ }
73
+ }
74
+
75
+ // Convenience loaders for the models + engines the CLI drives.
76
+ export async function loadModels() {
77
+ const [{ User }, { Competitor }] = await Promise.all([
78
+ backendImport('models/userModel.js'),
79
+ backendImport('models/competitorModel.js'),
80
+ ]);
81
+ return { User, Competitor };
82
+ }
83
+
84
+ export async function findUserOrThrow(email) {
85
+ const { User } = await loadModels();
86
+ const user = await User.findOne({ email });
87
+ if (!user) throw new Error(`No user found with email ${email}`);
88
+ return user;
89
+ }
90
+
91
+ // Resolve competitors for a user by name/handle substring, or all if omitted.
92
+ export async function findCompetitorsForUser(user, nameQuery) {
93
+ const { Competitor } = await loadModels();
94
+ const q = { userEmails: user.email };
95
+ if (nameQuery) q.name = new RegExp(escapeRegex(nameQuery), 'i');
96
+ return Competitor.find(q);
97
+ }
98
+
99
+ function escapeRegex(s) { return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); }
package/src/output.js ADDED
@@ -0,0 +1,74 @@
1
+ // Output helpers. Two modes: human (tables + key/value) and --json (raw JSON
2
+ // for scripts/agents). Errors are rendered with a helpful hint where we have
3
+ // one (quota / plan gates carry a suggestedPlan), and always set exit code 1.
4
+
5
+ import { ApiError } from './http.js';
6
+
7
+ export function out(data, ctx) {
8
+ if (ctx?.json) {
9
+ process.stdout.write(JSON.stringify(data, null, 2) + '\n');
10
+ return;
11
+ }
12
+ if (typeof data === 'string') { console.log(data); return; }
13
+ console.log(JSON.stringify(data, null, 2));
14
+ }
15
+
16
+ export function kv(obj, ctx) {
17
+ if (ctx?.json) { out(obj, ctx); return; }
18
+ const width = Math.max(...Object.keys(obj).map((k) => k.length)) + 2;
19
+ for (const [k, v] of Object.entries(obj)) {
20
+ const val = v === null || v === undefined ? '—' : (typeof v === 'object' ? JSON.stringify(v) : String(v));
21
+ console.log(`${k.padEnd(width)}${val}`);
22
+ }
23
+ }
24
+
25
+ export function table(rows, columns, ctx) {
26
+ if (ctx?.json) { out(rows, ctx); return; }
27
+ if (!rows || rows.length === 0) { console.log('(no results)'); return; }
28
+ const cols = columns || Object.keys(rows[0]).map((k) => ({ key: k, label: k }));
29
+ const widths = cols.map((c) => {
30
+ const header = c.label || c.key;
31
+ const cellMax = Math.max(...rows.map((r) => cellStr(r, c).length));
32
+ return Math.max(header.length, cellMax);
33
+ });
34
+ const header = cols.map((c, i) => (c.label || c.key).padEnd(widths[i])).join(' ');
35
+ console.log(header);
36
+ console.log(cols.map((_, i) => '-'.repeat(widths[i])).join(' '));
37
+ for (const r of rows) {
38
+ console.log(cols.map((c, i) => cellStr(r, c).padEnd(widths[i])).join(' '));
39
+ }
40
+ }
41
+
42
+ function cellStr(row, col) {
43
+ let v = col.get ? col.get(row) : row[col.key];
44
+ if (v === null || v === undefined) return '—';
45
+ if (typeof v === 'object') v = JSON.stringify(v);
46
+ v = String(v);
47
+ const max = col.max || 48;
48
+ return v.length > max ? v.slice(0, max - 1) + '…' : v;
49
+ }
50
+
51
+ export function renderError(err) {
52
+ process.exitCode = 1;
53
+ if (err instanceof ApiError) {
54
+ console.error(`✗ ${err.message}`);
55
+ const b = err.body;
56
+ if (b?.error === 'quota-exceeded') {
57
+ const parts = [];
58
+ if (b.scope) parts.push(`scope: ${b.scope}`);
59
+ if (b.limit !== undefined) parts.push(`limit: ${b.limit}`);
60
+ if (typeof b.retryAfter === 'number') {
61
+ const h = Math.ceil(b.retryAfter / 3600);
62
+ parts.push(h >= 1 ? `retry in ~${h}h` : `retry in ~${Math.ceil(b.retryAfter / 60)}m`);
63
+ }
64
+ if (b.suggestedPlan) parts.push(`suggested plan: ${b.suggestedPlan}`);
65
+ if (parts.length) console.error(` ${parts.join(' · ')}`);
66
+ } else if (err.status === 403) {
67
+ console.error(' (plan gate or permission — check your subscription / account role.)');
68
+ }
69
+ return;
70
+ }
71
+ console.error(`✗ ${err?.message || String(err)}`);
72
+ }
73
+
74
+ export function info(msg) { console.error(msg); } // stderr so --json stdout stays clean
package/src/sse.js ADDED
@@ -0,0 +1,52 @@
1
+ // SSE client for POST /agent/chat. The backend streams `data: {json}\n\n`
2
+ // frames (types: conversation-id, agent, navigate, text-delta) terminated by a
3
+ // literal `data: [DONE]\n\n`. We parse frames off the byte stream and hand each
4
+ // decoded event to onEvent.
5
+
6
+ import { getProfile, getCredentials } from './config.js';
7
+ import { ApiError } from './http.js';
8
+
9
+ function joinUrl(base, pathname) {
10
+ return `${base.replace(/\/$/, '')}/${pathname.replace(/^\//, '')}`;
11
+ }
12
+
13
+ export async function streamChat(profileName, { message, conversationId, pageContext }, onEvent) {
14
+ const profile = getProfile(profileName);
15
+ const creds = getCredentials(profile.name);
16
+ const auth = creds.accessToken ? `Bearer ${creds.accessToken}` : (creds.apiKey ? `Bearer ${creds.apiKey}` : null);
17
+ if (!auth) throw new ApiError('Not logged in. Run: oppira auth login');
18
+
19
+ const res = await fetch(joinUrl(profile.apiUrl, '/agent/chat'), {
20
+ method: 'POST',
21
+ headers: {
22
+ 'Content-Type': 'application/json',
23
+ Accept: 'text/event-stream',
24
+ Authorization: auth,
25
+ 'X-Oppira-Client': 'oppira-cli',
26
+ },
27
+ body: JSON.stringify({ message, conversationId, pageContext }),
28
+ });
29
+
30
+ if (!res.ok) {
31
+ let body = null;
32
+ try { body = JSON.parse(await res.text()); } catch { /* non-json error */ }
33
+ throw new ApiError(body?.message || `Chat failed (${res.status})`, { status: res.status, body });
34
+ }
35
+ if (!res.body) throw new ApiError('No response stream from /agent/chat');
36
+
37
+ const decoder = new TextDecoder();
38
+ let buffer = '';
39
+ for await (const chunk of res.body) {
40
+ buffer += decoder.decode(chunk, { stream: true });
41
+ let idx;
42
+ while ((idx = buffer.indexOf('\n\n')) !== -1) {
43
+ const frame = buffer.slice(0, idx);
44
+ buffer = buffer.slice(idx + 2);
45
+ const dataLine = frame.split('\n').find((l) => l.startsWith('data:'));
46
+ if (!dataLine) continue;
47
+ const payload = dataLine.slice(5).trim();
48
+ if (payload === '[DONE]') return;
49
+ try { onEvent(JSON.parse(payload)); } catch { /* ignore malformed frame */ }
50
+ }
51
+ }
52
+ }