@oppira/cli 0.1.1 → 0.1.2

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 CHANGED
@@ -53,6 +53,7 @@ Pick either method per profile:
53
53
  | `oppira alerts` | `list`, `run` |
54
54
  | `oppira battlecard` | `show`, `history`, `regen` |
55
55
  | `oppira playbook` | `show`, `bootstrap`, `refresh-section`, `refresh-cluster` |
56
+ | `oppira ads` | `accounts`, `performance`, `campaigns` — your own connected ad accounts *(Pro)* |
56
57
  | `oppira discover` | `profiles`, `suggestions`, `dismiss` |
57
58
  | `oppira studio` | posts, calendars, comments, templates, image generation, publishing *(Pro)* |
58
59
  | `oppira artifact` | `get`, `export` (reports / decks / diagrams) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@oppira/cli",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Oppira CLI — terminal control for the adaptive marketing system (competitor intelligence, playbook, studio, ops).",
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,130 @@
1
+ // ads: read the user's OWN connected ad-account data (Meta / Google / TikTok /
2
+ // etc., via the Studio ads integration) — NOT competitor ads (those live under
3
+ // `comp` / the get_competitor_* MCP tools). All REST (JWT), gated on the backend
4
+ // by requireStudioEnabled (Pro/admin) + quota. Degrades to an empty / hasAds:false
5
+ // result when no ad account is connected, so the commands never hard-error.
6
+ import { apiRequest } from '../http.js';
7
+ import { out, kv, table, info } from '../output.js';
8
+
9
+ export default {
10
+ description: 'Own ad accounts — connected accounts, performance, campaigns (Pro/admin).',
11
+ commands: {
12
+ accounts: {
13
+ description: 'List your connected ad accounts.',
14
+ usage: 'ads accounts',
15
+ async handler(ctx) {
16
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-channels');
17
+ const accounts = res?.accounts || [];
18
+ if (ctx.json) { out(res, ctx); return; }
19
+ if (accounts.length === 0) { info('(no ad accounts connected — connect one in Studio → Ads)'); return; }
20
+ const rows = accounts.map((a) => ({
21
+ platform: a.platform || '—',
22
+ handle: a.handle || a.displayName || '—',
23
+ status: a.status || '—',
24
+ accountId: String(a.accountId || '—'),
25
+ }));
26
+ table(rows, [
27
+ { key: 'platform', label: 'PLATFORM' },
28
+ { key: 'handle', label: 'HANDLE' },
29
+ { key: 'status', label: 'STATUS' },
30
+ { key: 'accountId', label: 'ACCOUNT ID' },
31
+ ], ctx);
32
+ },
33
+ },
34
+
35
+ performance: {
36
+ description: 'Distilled own-ads performance (totals, per-platform, top campaigns, WoW).',
37
+ usage: 'ads performance [--from 2026-06-01] [--to 2026-06-30]',
38
+ flags: [
39
+ { name: 'from', description: 'Window start (YYYY-MM-DD); defaults to 30 days ago' },
40
+ { name: 'to', description: 'Window end (YYYY-MM-DD); defaults to today' },
41
+ ],
42
+ async handler(ctx) {
43
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-performance', {
44
+ query: { fromDate: ctx.flag('from'), toDate: ctx.flag('to') },
45
+ });
46
+ if (ctx.json) { out(res, ctx); return; }
47
+ if (!res?.hasAds) { info('(no connected ad accounts, or ad data is unavailable right now)'); return; }
48
+ const t = res.totals || {};
49
+ kv({
50
+ window: res.window ? `${res.window.fromDate} → ${res.window.toDate}` : '—',
51
+ spend: t.spend,
52
+ impressions: t.impressions,
53
+ clicks: t.clicks,
54
+ ctr: t.ctr !== undefined ? `${t.ctr}%` : '—',
55
+ cpc: t.cpc,
56
+ conversions: t.conversions,
57
+ roas: t.roas,
58
+ campaigns: `${res.activeCampaignCount ?? '—'} active / ${res.campaignCount ?? '—'} total`,
59
+ }, ctx);
60
+ const byPlatform = res.byPlatform || {};
61
+ const pRows = Object.entries(byPlatform).map(([platform, m]) => ({
62
+ platform, spend: m.spend, conversions: m.conversions, roas: m.roas,
63
+ }));
64
+ if (pRows.length) {
65
+ info('\nBy platform:');
66
+ table(pRows, [
67
+ { key: 'platform', label: 'PLATFORM' },
68
+ { key: 'spend', label: 'SPEND' },
69
+ { key: 'conversions', label: 'CONV' },
70
+ { key: 'roas', label: 'ROAS' },
71
+ ], ctx);
72
+ }
73
+ const top = res.activeCampaigns || [];
74
+ if (top.length) {
75
+ info('\nTop active campaigns:');
76
+ table(top.map((c) => ({
77
+ name: c.name, platform: c.platform, spend: c.spend, roas: c.roas, conversions: c.conversions,
78
+ })), [
79
+ { key: 'name', label: 'CAMPAIGN', max: 34 },
80
+ { key: 'platform', label: 'PLATFORM' },
81
+ { key: 'spend', label: 'SPEND' },
82
+ { key: 'roas', label: 'ROAS' },
83
+ { key: 'conversions', label: 'CONV' },
84
+ ], ctx);
85
+ }
86
+ info('\n(use --json for the full aggregate incl. WoW deltas)');
87
+ },
88
+ },
89
+
90
+ campaigns: {
91
+ description: 'List your ad campaigns with per-campaign metrics.',
92
+ usage: 'ads campaigns [--status active] [--from ...] [--to ...] [--limit 25]',
93
+ flags: [
94
+ { name: 'status', description: 'Status filter, e.g. active / paused' },
95
+ { name: 'from', description: 'Window start (YYYY-MM-DD)' },
96
+ { name: 'to', description: 'Window end (YYYY-MM-DD)' },
97
+ { name: 'limit', description: 'Max campaigns (default 25)' },
98
+ ],
99
+ async handler(ctx) {
100
+ const res = await apiRequest(ctx.profileName, 'GET', '/studio/ads-channels/campaigns', {
101
+ query: {
102
+ status: ctx.flag('status'),
103
+ fromDate: ctx.flag('from'),
104
+ toDate: ctx.flag('to'),
105
+ limit: ctx.flag('limit'),
106
+ },
107
+ });
108
+ const campaigns = res?.campaigns || res?.data || [];
109
+ if (ctx.json) { out(res, ctx); return; }
110
+ if (!Array.isArray(campaigns) || campaigns.length === 0) { info('(no campaigns)'); return; }
111
+ const rows = campaigns.map((c) => ({
112
+ name: c.name || '(unnamed)',
113
+ platform: c.platform || '—',
114
+ status: c.status || '—',
115
+ spend: c.metrics?.spend ?? c.spend ?? '—',
116
+ roas: c.metrics?.roas ?? c.roas ?? '—',
117
+ conversions: c.metrics?.conversions ?? c.conversions ?? '—',
118
+ }));
119
+ table(rows, [
120
+ { key: 'name', label: 'CAMPAIGN', max: 34 },
121
+ { key: 'platform', label: 'PLATFORM' },
122
+ { key: 'status', label: 'STATUS' },
123
+ { key: 'spend', label: 'SPEND' },
124
+ { key: 'roas', label: 'ROAS' },
125
+ { key: 'conversions', label: 'CONV' },
126
+ ], ctx);
127
+ },
128
+ },
129
+ },
130
+ };
@@ -1,10 +1,29 @@
1
1
  // discover: find social profiles for a company (async job) + manage the
2
2
  // weekly competitor suggestions.
3
3
  import { apiRequest } from '../http.js';
4
+ import { getCredentials } from '../config.js';
5
+ import { withDb, findUserOrThrow, backendImport } from '../local.js';
4
6
  import { out, table, info } from '../output.js';
5
7
 
6
8
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
7
9
 
10
+ function opsEmail(ctx) {
11
+ const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
12
+ if (!email) throw new Error('Provide --email (or log in) to select the target user.');
13
+ return email;
14
+ }
15
+
16
+ // Render a pending-suggestions list as a table (shared by `suggestions` and
17
+ // the post-generation output).
18
+ function renderSuggestions(list, ctx) {
19
+ const rows = (Array.isArray(list) ? list : []).map((s) => ({
20
+ id: String(s._id || s.id || '—'),
21
+ name: s.candidateName || s.name || s.companyName || '—',
22
+ reason: s.reason || s.rationale || '—',
23
+ }));
24
+ table(rows, [{ key: 'id', label: 'ID' }, { key: 'name', label: 'NAME' }, { key: 'reason', label: 'REASON', max: 60 }], ctx);
25
+ }
26
+
8
27
  export default {
9
28
  description: 'Discovery — find profiles for a company and manage suggestions.',
10
29
  commands: {
@@ -58,17 +77,40 @@ export default {
58
77
  },
59
78
 
60
79
  suggestions: {
61
- description: 'List competitor suggestions.',
80
+ description: 'List competitor suggestions (add --generate to force a fresh run first).',
81
+ usage: 'discover suggestions [--generate] [--local] [--email you@co.com]',
82
+ flags: [
83
+ { name: 'generate', boolean: true, description: 'Force a fresh suggestion run before listing (LLM + web search; rate-limited)' },
84
+ { name: 'local', boolean: true, description: 'Run the generator engine directly (ops; needs repo + .env)' },
85
+ { name: 'email', description: 'Target user in --local mode (defaults to logged-in profile)' },
86
+ ],
62
87
  async handler(ctx) {
88
+ if (ctx.bool('generate')) {
89
+ if (ctx.useLocal) {
90
+ const email = opsEmail(ctx);
91
+ const result = await withDb(async () => {
92
+ const user = await findUserOrThrow(email);
93
+ const { generateSuggestionsForUser } = await backendImport('utils/competitorSuggestionGenerator.js');
94
+ return generateSuggestionsForUser(user, { debounceMinutes: 0 });
95
+ });
96
+ if (ctx.json) { out({ success: true, email, result }, ctx); return; }
97
+ info(`✓ Suggestion generation run for ${email} (local). ${result?.skipped ? `Skipped: ${result.skipped}.` : `Created: ${result?.created ?? 0}.`}`);
98
+ return;
99
+ }
100
+ info('Generating suggestions (LLM + web search — this can take up to a minute)…');
101
+ const gen = await apiRequest(ctx.profileName, 'POST', '/competitor-suggestions/generate', { body: {} });
102
+ const list = gen?.data || [];
103
+ if (ctx.json) { out(gen, ctx); return; }
104
+ if (gen?.result?.skipped) info(`(generation skipped: ${gen.result.skipped})`);
105
+ else info(`✓ Generated. ${gen?.result?.created ?? 0} new suggestion(s).`);
106
+ renderSuggestions(list, ctx);
107
+ return;
108
+ }
109
+
63
110
  const res = await apiRequest(ctx.profileName, 'GET', '/competitor-suggestions');
64
111
  const list = res?.data || res?.suggestions || res || [];
65
112
  if (ctx.json) { out(list, ctx); return; }
66
- const rows = (Array.isArray(list) ? list : []).map((s) => ({
67
- id: String(s._id || s.id || '—'),
68
- name: s.name || s.companyName || '—',
69
- reason: s.reason || s.rationale || '—',
70
- }));
71
- table(rows, [{ key: 'id', label: 'ID' }, { key: 'name', label: 'NAME' }, { key: 'reason', label: 'REASON', max: 60 }], ctx);
113
+ renderSuggestions(list, ctx);
72
114
  },
73
115
  },
74
116
 
package/src/index.js CHANGED
@@ -13,11 +13,11 @@ import authGroup from './commands/auth.js';
13
13
  import configGroup from './commands/config.js';
14
14
  import competitorsGroup from './commands/competitors.js';
15
15
  import discoverGroup from './commands/discover.js';
16
- import scrapeGroup from './commands/scrape.js';
17
16
  import insightsGroup from './commands/insights.js';
18
17
  import alertsGroup from './commands/alerts.js';
19
18
  import battlecardGroup from './commands/battlecard.js';
20
19
  import playbookGroup from './commands/playbook.js';
20
+ import adsGroup from './commands/ads.js';
21
21
  import studioGroup from './commands/studio.js';
22
22
  import artifactGroup from './commands/artifact.js';
23
23
  import adminGroup from './commands/admin.js';
@@ -26,7 +26,7 @@ import { askCommand } from './commands/ask.js';
26
26
  export function buildProgram() {
27
27
  return {
28
28
  name: 'oppira',
29
- version: '0.1.0',
29
+ version: '0.1.2',
30
30
  description: 'Terminal control for Oppira — competitor intelligence, playbook, studio & ops.',
31
31
  groups: {
32
32
  auth: authGroup,
@@ -34,11 +34,11 @@ export function buildProgram() {
34
34
  competitors: competitorsGroup,
35
35
  comp: competitorsGroup, // alias
36
36
  discover: discoverGroup,
37
- scrape: scrapeGroup,
38
37
  insights: insightsGroup,
39
38
  alerts: alertsGroup,
40
39
  battlecard: battlecardGroup,
41
40
  playbook: playbookGroup,
41
+ ads: adsGroup,
42
42
  studio: studioGroup,
43
43
  artifact: artifactGroup,
44
44
  admin: adminGroup,
package/src/local.js CHANGED
@@ -1,8 +1,8 @@
1
1
  // Ops / "local mode". Runs commands against MongoDB + the backend's engine
2
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).
3
+ // cron-only generators (battlecard / insights / alerts / summary) without
4
+ // needing new HTTP endpoints — mirroring the existing one-off scripts in
5
+ // ../scripts (dotenv + mongoose.connect + engine).
6
6
  //
7
7
  // Only usable from a repo checkout with MONGO_URI available; the published
8
8
  // customer build never exercises this path.
@@ -1,93 +0,0 @@
1
- // scrape: on-demand intelligent scrape (local engine) + raw per-platform
2
- // endpoints (remote). The intelligent path is the one that mirrors S3, recomputes
3
- // summaries, and fires battlecard/profile triggers — the raw path hits a single
4
- // Apify actor and bypasses that pipeline (and is currently unauthenticated).
5
- import { apiRequest } from '../http.js';
6
- import { getCredentials } from '../config.js';
7
- import { withDb, findUserOrThrow, findCompetitorsForUser, backendImport } from '../local.js';
8
- import { out, info } from '../output.js';
9
-
10
- // CLI short form → canonical platform key used by scrapeCompetitorIntelligent.
11
- const PLATFORM_ALIASES = {
12
- fb: 'facebook', facebook: 'facebook',
13
- ig: 'instagram', instagram: 'instagram',
14
- x: 'x', twitter: 'x',
15
- li: 'linkedin', linkedin: 'linkedin',
16
- };
17
-
18
- const RAW_ENDPOINTS = {
19
- facebook: '/scrape-facebook',
20
- 'facebook-posts': '/scrape-facebook-posts',
21
- 'facebook-ads': '/scrape-facebook-ads',
22
- 'google-ads': '/scrape-google-ads',
23
- instagram: '/scrape-instagram',
24
- twitter: '/scrape-twitter',
25
- linkedin: '/scrape-linkedin',
26
- trustpilot: '/scrape-trustpilot',
27
- };
28
-
29
- function opsEmail(ctx) {
30
- const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
31
- if (!email) throw new Error('Provide --email (or log in) to select the target user.');
32
- return email;
33
- }
34
-
35
- export default {
36
- description: 'Scraping — on-demand intelligent scrape (ops) + raw per-platform.',
37
- commands: {
38
- run: {
39
- description: 'Intelligent on-demand scrape of a competitor now (ops / local mode).',
40
- usage: 'scrape run <competitor> [--platforms fb,ig,x,li] [--email you@co.com]',
41
- flags: [
42
- { name: 'platforms', description: 'Comma list to limit (fb,ig,x,li); default all social' },
43
- { name: 'email', description: 'Target user (defaults to logged-in profile)' },
44
- ],
45
- async handler(ctx) {
46
- const email = opsEmail(ctx);
47
- const nameQuery = ctx.arg(0);
48
- if (!nameQuery) throw new Error('Usage: oppira scrape run <competitor> [--platforms fb,ig]');
49
-
50
- let allowedPlatforms = null;
51
- if (ctx.flag('platforms')) {
52
- allowedPlatforms = String(ctx.flag('platforms')).split(',')
53
- .map((p) => PLATFORM_ALIASES[p.trim().toLowerCase()])
54
- .filter(Boolean);
55
- if (allowedPlatforms.length === 0) throw new Error('No valid platforms in --platforms (use fb,ig,x,li).');
56
- }
57
-
58
- const result = await withDb(async () => {
59
- const user = await findUserOrThrow(email);
60
- const competitors = await findCompetitorsForUser(user, nameQuery);
61
- if (competitors.length === 0) throw new Error(`No competitor matches "${nameQuery}" for ${email}.`);
62
- if (competitors.length > 1) throw new Error(`Ambiguous: ${competitors.map((c) => c.name).join(', ')}.`);
63
- const { scrapeCompetitorIntelligent } = await backendImport('utils/scraperUtils.js');
64
- info(`Scraping "${competitors[0].name}"${allowedPlatforms ? ` (${allowedPlatforms.join(', ')})` : ''}…`);
65
- // requireScrapingFlag:false → on-demand semantics (scrape platforms not
66
- // currently mid-scrape), rather than the cron's flag-driven path.
67
- return scrapeCompetitorIntelligent(competitors[0], user, allowedPlatforms, { requireScrapingFlag: false });
68
- });
69
- if (ctx.json) { out({ success: true, email, competitor: nameQuery, result }, ctx); return; }
70
- info(`✓ Scrape complete for "${nameQuery}".`);
71
- },
72
- },
73
-
74
- raw: {
75
- description: 'Call a single raw platform scraper (remote). Bypasses the intelligent pipeline.',
76
- usage: 'scrape raw <platform> --url <url> (platforms: ' + Object.keys(RAW_ENDPOINTS).join(', ') + ')',
77
- flags: [{ name: 'url', description: 'Profile / page URL to scrape' }],
78
- async handler(ctx) {
79
- const platform = ctx.arg(0);
80
- const endpoint = RAW_ENDPOINTS[platform];
81
- if (!endpoint) throw new Error(`Unknown platform "${platform}". One of: ${Object.keys(RAW_ENDPOINTS).join(', ')}`);
82
- const url = ctx.flag('url');
83
- if (!url) throw new Error('Usage: oppira scrape raw <platform> --url <url>');
84
- const email = getCredentials(ctx.profileName).email;
85
- const res = await apiRequest(ctx.profileName, 'POST', endpoint, {
86
- body: { url, userEmails: email || '' },
87
- });
88
- if (ctx.json) { out(res, ctx); return; }
89
- info(`✓ Raw ${platform} scrape done.`);
90
- },
91
- },
92
- },
93
- };