@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 +147 -0
- package/bin/oppira.js +11 -0
- package/package.json +37 -0
- package/src/commands/admin.js +174 -0
- package/src/commands/alerts.js +64 -0
- package/src/commands/artifact.js +50 -0
- package/src/commands/ask.js +53 -0
- package/src/commands/auth.js +134 -0
- package/src/commands/battlecard.js +84 -0
- package/src/commands/competitors.js +276 -0
- package/src/commands/config.js +64 -0
- package/src/commands/discover.js +87 -0
- package/src/commands/insights.js +76 -0
- package/src/commands/playbook.js +79 -0
- package/src/commands/scrape.js +93 -0
- package/src/commands/studio.js +185 -0
- package/src/config.js +97 -0
- package/src/framework.js +121 -0
- package/src/http.js +157 -0
- package/src/index.js +108 -0
- package/src/local.js +99 -0
- package/src/output.js +74 -0
- package/src/sse.js +52 -0
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// battlecard: read latest/history (REST) + force-regenerate (local engine).
|
|
2
|
+
import { apiRequest } from '../http.js';
|
|
3
|
+
import { getCredentials } from '../config.js';
|
|
4
|
+
import { withDb, findUserOrThrow, findCompetitorsForUser, backendImport } from '../local.js';
|
|
5
|
+
import { resolveCompetitor } from './competitors.js';
|
|
6
|
+
import { out, kv, table, info } from '../output.js';
|
|
7
|
+
|
|
8
|
+
function opsEmail(ctx) {
|
|
9
|
+
const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
|
|
10
|
+
if (!email) throw new Error('Provide --email (or log in) to select the target user.');
|
|
11
|
+
return email;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export default {
|
|
15
|
+
description: 'Battlecards — show, history, and force-regenerate (ops).',
|
|
16
|
+
commands: {
|
|
17
|
+
show: {
|
|
18
|
+
description: 'Show the current battlecard for a competitor.',
|
|
19
|
+
usage: 'battlecard show <name|id>',
|
|
20
|
+
async handler(ctx) {
|
|
21
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
22
|
+
const res = await apiRequest(ctx.profileName, 'GET', `/battlecards/${c._id}`);
|
|
23
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
24
|
+
const bc = res?.data || res?.battlecard || res;
|
|
25
|
+
if (!bc) { info('(no battlecard yet)'); return; }
|
|
26
|
+
kv({ competitor: c.name, generatedAt: bc.createdAt || bc.generatedAt || '—', version: bc.version || bc.generatorVersion || '—' }, ctx);
|
|
27
|
+
info('');
|
|
28
|
+
out(bc, { json: true });
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
history: {
|
|
33
|
+
description: 'List battlecard versions for a competitor.',
|
|
34
|
+
usage: 'battlecard history <name|id>',
|
|
35
|
+
async handler(ctx) {
|
|
36
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
37
|
+
const res = await apiRequest(ctx.profileName, 'GET', `/battlecards/${c._id}/history`);
|
|
38
|
+
const versions = res?.data || res?.history || res || [];
|
|
39
|
+
if (ctx.json) { out(versions, ctx); return; }
|
|
40
|
+
const rows = (Array.isArray(versions) ? versions : []).map((v) => ({
|
|
41
|
+
id: String(v._id || v.battlecardId || '—'),
|
|
42
|
+
created: v.createdAt || '—',
|
|
43
|
+
version: v.version || v.generatorVersion || '—',
|
|
44
|
+
}));
|
|
45
|
+
table(rows, [{ key: 'id', label: 'ID' }, { key: 'created', label: 'CREATED' }, { key: 'version', label: 'VERSION' }], ctx);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
regen: {
|
|
50
|
+
description: 'Force-regenerate a battlecard now (remote by default; --local runs the engine directly).',
|
|
51
|
+
usage: 'battlecard regen <name> [--local] [--email you@co.com]',
|
|
52
|
+
flags: [
|
|
53
|
+
{ name: 'local', boolean: true, description: 'Run the engine directly (ops; needs repo + .env)' },
|
|
54
|
+
{ name: 'email', description: 'Target user in --local mode (defaults to logged-in profile)' },
|
|
55
|
+
],
|
|
56
|
+
async handler(ctx) {
|
|
57
|
+
const nameQuery = ctx.arg(0);
|
|
58
|
+
if (!nameQuery) throw new Error('Usage: oppira battlecard regen <competitor-name>');
|
|
59
|
+
if (!ctx.useLocal) {
|
|
60
|
+
// Remote: resolve the competitor id via the REST list, then hit the
|
|
61
|
+
// owner-scoped regenerate endpoint.
|
|
62
|
+
const c = await resolveCompetitor(ctx.profileName, nameQuery);
|
|
63
|
+
const res = await apiRequest(ctx.profileName, 'POST', `/battlecards/${c._id}/regenerate`, { body: {} });
|
|
64
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
65
|
+
info(`✓ Battlecard regeneration triggered for "${c.name}".`);
|
|
66
|
+
if (res?.result) out(res.result, ctx);
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
const email = opsEmail(ctx);
|
|
70
|
+
const result = await withDb(async () => {
|
|
71
|
+
const user = await findUserOrThrow(email);
|
|
72
|
+
const competitors = await findCompetitorsForUser(user, nameQuery);
|
|
73
|
+
if (competitors.length === 0) throw new Error(`No competitor matches "${nameQuery}" for ${email}.`);
|
|
74
|
+
if (competitors.length > 1) throw new Error(`Ambiguous: ${competitors.map((c) => c.name).join(', ')}.`);
|
|
75
|
+
const { generateBattlecard } = await backendImport('utils/battlecardGenerator.js');
|
|
76
|
+
return generateBattlecard({ user, competitor: competitors[0], force: true });
|
|
77
|
+
});
|
|
78
|
+
if (ctx.json) { out({ success: true, email, result }, ctx); return; }
|
|
79
|
+
info(`✓ Battlecard regeneration run for "${nameQuery}" (${email}, local).`);
|
|
80
|
+
if (result) out(result, ctx);
|
|
81
|
+
},
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
};
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// competitors (alias: comp): list / show / add / track / toggle / remove,
|
|
2
|
+
// plus local-mode analytics (timeline / compare / top-posts / tone) that reuse
|
|
3
|
+
// the MCP tool implementations directly against Mongo.
|
|
4
|
+
import { apiRequest } from '../http.js';
|
|
5
|
+
import { getCredentials } from '../config.js';
|
|
6
|
+
import { withDb, findUserOrThrow, findCompetitorsForUser, backendImport } from '../local.js';
|
|
7
|
+
import { out, kv, table, info } from '../output.js';
|
|
8
|
+
|
|
9
|
+
// Resolve a single competitor for a user (local mode), returning the mongo _id.
|
|
10
|
+
async function resolveLocalId(user, nameQuery) {
|
|
11
|
+
const competitors = await findCompetitorsForUser(user, nameQuery);
|
|
12
|
+
if (competitors.length === 0) throw new Error(`No competitor matches "${nameQuery}".`);
|
|
13
|
+
if (competitors.length > 1) throw new Error(`Ambiguous: ${competitors.map((c) => c.name).join(', ')}. Be more specific.`);
|
|
14
|
+
return { id: String(competitors[0]._id), name: competitors[0].name };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function opsEmail(ctx) {
|
|
18
|
+
const email = ctx.flag('email') || getCredentials(ctx.profileName).email;
|
|
19
|
+
if (!email) throw new Error('Provide --email (or log in) to select the target user.');
|
|
20
|
+
return email;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const isObjectId = (s) => typeof s === 'string' && /^[a-f0-9]{24}$/i.test(s);
|
|
24
|
+
|
|
25
|
+
async function fetchCompetitors(profileName) {
|
|
26
|
+
const res = await apiRequest(profileName, 'GET', '/competitors');
|
|
27
|
+
return res?.data || res?.competitors || [];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Accept an ObjectId directly, or resolve a (case-insensitive) name substring.
|
|
31
|
+
async function resolveCompetitor(profileName, nameOrId) {
|
|
32
|
+
if (!nameOrId) throw new Error('A competitor name or id is required.');
|
|
33
|
+
const list = await fetchCompetitors(profileName);
|
|
34
|
+
if (isObjectId(nameOrId)) {
|
|
35
|
+
const byId = list.find((c) => String(c._id) === nameOrId);
|
|
36
|
+
return byId || { _id: nameOrId, name: '(unknown)' };
|
|
37
|
+
}
|
|
38
|
+
const q = nameOrId.toLowerCase();
|
|
39
|
+
const matches = list.filter((c) => (c.name || '').toLowerCase().includes(q));
|
|
40
|
+
if (matches.length === 0) throw new Error(`No tracked competitor matches "${nameOrId}".`);
|
|
41
|
+
if (matches.length > 1) {
|
|
42
|
+
throw new Error(`Ambiguous — "${nameOrId}" matches: ${matches.map((c) => c.name).join(', ')}. Use the id.`);
|
|
43
|
+
}
|
|
44
|
+
return matches[0];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const PLATFORM_FLAGS = [
|
|
48
|
+
['facebook', 'facebookUrl'], ['instagram', 'instagramUrl'], ['x', 'xUrl'],
|
|
49
|
+
['linkedin', 'linkedinUrl'], ['trustpilot', 'trustpilotUrl'],
|
|
50
|
+
['facebook-ads', 'facebookAdsUrl'], ['google-ads', 'googleAdsUrl'], ['website', 'websiteUrl'],
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
export default {
|
|
54
|
+
description: 'Competitors — list, add, track, toggle insights/alerts, remove.',
|
|
55
|
+
commands: {
|
|
56
|
+
list: {
|
|
57
|
+
description: 'List tracked competitors.',
|
|
58
|
+
async handler(ctx) {
|
|
59
|
+
const list = await fetchCompetitors(ctx.profileName);
|
|
60
|
+
if (ctx.json) { out(list, ctx); return; }
|
|
61
|
+
const rows = list.map((c) => ({
|
|
62
|
+
name: c.name,
|
|
63
|
+
id: String(c._id),
|
|
64
|
+
tracking: c.isTracking ? 'on' : 'off',
|
|
65
|
+
insights: c.insightsEnabled ? 'on' : 'off',
|
|
66
|
+
alerts: c.alertsEnabled ? 'on' : 'off',
|
|
67
|
+
kind: c.isUserCompany ? 'company' : (c.watchOnly ? 'watch' : 'competitor'),
|
|
68
|
+
}));
|
|
69
|
+
table(rows, [
|
|
70
|
+
{ key: 'name', label: 'NAME' }, { key: 'id', label: 'ID' },
|
|
71
|
+
{ key: 'tracking', label: 'TRACK' }, { key: 'insights', label: 'INSIGHTS' },
|
|
72
|
+
{ key: 'alerts', label: 'ALERTS' }, { key: 'kind', label: 'KIND' },
|
|
73
|
+
], ctx);
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
|
|
77
|
+
show: {
|
|
78
|
+
description: 'Show one competitor (name substring or id).',
|
|
79
|
+
usage: 'comp show <name|id>',
|
|
80
|
+
async handler(ctx) {
|
|
81
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
82
|
+
if (ctx.json) { out(c, ctx); return; }
|
|
83
|
+
kv({
|
|
84
|
+
name: c.name, id: String(c._id),
|
|
85
|
+
tracking: c.isTracking ? 'on' : 'off',
|
|
86
|
+
insights: c.insightsEnabled ? 'on' : 'off',
|
|
87
|
+
alerts: c.alertsEnabled ? 'on' : 'off',
|
|
88
|
+
kind: c.isUserCompany ? 'company' : (c.watchOnly ? 'watch-only' : 'competitor'),
|
|
89
|
+
website: (c.websiteUrls && c.websiteUrls[0]) || '—',
|
|
90
|
+
}, ctx);
|
|
91
|
+
if (c.socialSummary) {
|
|
92
|
+
info('');
|
|
93
|
+
const s = c.socialSummary;
|
|
94
|
+
const rows = Object.entries(s).map(([platform, v]) => ({
|
|
95
|
+
platform,
|
|
96
|
+
posts: v.totalPosts ?? 0,
|
|
97
|
+
followers: v.followers ?? v.totalFollowers ?? 0,
|
|
98
|
+
engagement: v.engagementRate ?? 0,
|
|
99
|
+
}));
|
|
100
|
+
table(rows, [
|
|
101
|
+
{ key: 'platform', label: 'PLATFORM' }, { key: 'posts', label: 'POSTS' },
|
|
102
|
+
{ key: 'followers', label: 'FOLLOWERS' }, { key: 'engagement', label: 'ENGAGE%' },
|
|
103
|
+
], ctx);
|
|
104
|
+
}
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
add: {
|
|
109
|
+
description: 'Add a competitor. Provide at least one channel URL.',
|
|
110
|
+
usage: 'comp add <name> [--facebook url] [--instagram url] [--x url] [--linkedin url] [--trustpilot url] [--website url] [--company]',
|
|
111
|
+
flags: [
|
|
112
|
+
...PLATFORM_FLAGS.map(([f]) => ({ name: f, description: `${f} URL` })),
|
|
113
|
+
{ name: 'company', boolean: true, description: 'Mark as your own company (isUserCompany)' },
|
|
114
|
+
],
|
|
115
|
+
async handler(ctx) {
|
|
116
|
+
const name = ctx.arg(0);
|
|
117
|
+
if (!name) throw new Error('Usage: oppira comp add <name> [--facebook url] ...');
|
|
118
|
+
const body = { name, isUserCompany: ctx.bool('company') };
|
|
119
|
+
for (const [flag, field] of PLATFORM_FLAGS) {
|
|
120
|
+
const v = ctx.flag(flag);
|
|
121
|
+
if (v) body[field] = v;
|
|
122
|
+
}
|
|
123
|
+
const res = await apiRequest(ctx.profileName, 'POST', '/competitors', { body });
|
|
124
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
125
|
+
info(`✓ Created competitor "${name}"${res?.data?._id ? ` (${res.data._id})` : ''}. Scraping kicks off in the background.`);
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
|
|
129
|
+
track: {
|
|
130
|
+
description: 'Start tracking an existing competitor by id.',
|
|
131
|
+
usage: 'comp track <competitorId>',
|
|
132
|
+
async handler(ctx) {
|
|
133
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
134
|
+
const res = await apiRequest(ctx.profileName, 'POST', '/competitors/track', { body: { competitorId: String(c._id) } });
|
|
135
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
136
|
+
info(`✓ Tracking "${c.name}".`);
|
|
137
|
+
},
|
|
138
|
+
},
|
|
139
|
+
|
|
140
|
+
'toggle-tracking': {
|
|
141
|
+
description: 'Flip tracking on/off (subject to the 24h cooldown).',
|
|
142
|
+
usage: 'comp toggle-tracking <name|id>',
|
|
143
|
+
async handler(ctx) {
|
|
144
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
145
|
+
const res = await apiRequest(ctx.profileName, 'PATCH', `/competitors/${c._id}/toggle-tracking`, { body: {} });
|
|
146
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
147
|
+
info(`✓ Toggled tracking for "${c.name}". ${res?.message || ''}`);
|
|
148
|
+
},
|
|
149
|
+
},
|
|
150
|
+
|
|
151
|
+
insights: {
|
|
152
|
+
description: 'Toggle insight generation for a competitor.',
|
|
153
|
+
usage: 'comp insights <name|id>',
|
|
154
|
+
async handler(ctx) {
|
|
155
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
156
|
+
const res = await apiRequest(ctx.profileName, 'PATCH', `/competitors/${c._id}/insights`, { body: {} });
|
|
157
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
158
|
+
info(`✓ Toggled insights for "${c.name}". ${res?.message || ''}`);
|
|
159
|
+
},
|
|
160
|
+
},
|
|
161
|
+
|
|
162
|
+
alerts: {
|
|
163
|
+
description: 'Toggle alert generation for a competitor.',
|
|
164
|
+
usage: 'comp alerts <name|id>',
|
|
165
|
+
async handler(ctx) {
|
|
166
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
167
|
+
const res = await apiRequest(ctx.profileName, 'PATCH', `/competitors/${c._id}/alerts`, { body: {} });
|
|
168
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
169
|
+
info(`✓ Toggled alerts for "${c.name}". ${res?.message || ''}`);
|
|
170
|
+
},
|
|
171
|
+
},
|
|
172
|
+
|
|
173
|
+
rm: {
|
|
174
|
+
description: 'Delete a competitor.',
|
|
175
|
+
usage: 'comp rm <name|id> [--yes]',
|
|
176
|
+
flags: [{ name: 'yes', boolean: true, description: 'Skip confirmation' }],
|
|
177
|
+
async handler(ctx) {
|
|
178
|
+
const c = await resolveCompetitor(ctx.profileName, ctx.arg(0));
|
|
179
|
+
if (!ctx.bool('yes')) {
|
|
180
|
+
throw new Error(`Refusing to delete "${c.name}" without --yes.`);
|
|
181
|
+
}
|
|
182
|
+
const res = await apiRequest(ctx.profileName, 'DELETE', `/competitors/${c._id}`, {});
|
|
183
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
184
|
+
info(`✓ Deleted "${c.name}".`);
|
|
185
|
+
},
|
|
186
|
+
},
|
|
187
|
+
|
|
188
|
+
// --- Analytics (local/ops mode — reuse the MCP tool implementations) ---
|
|
189
|
+
timeline: {
|
|
190
|
+
description: 'Per-month post metrics for a competitor (local mode).',
|
|
191
|
+
usage: 'comp timeline <name> [--months 6] [--email ...]',
|
|
192
|
+
flags: [
|
|
193
|
+
{ name: 'months', description: 'Number of months (default engine max)' },
|
|
194
|
+
{ name: 'email', description: 'User whose data to read (defaults to profile)' },
|
|
195
|
+
],
|
|
196
|
+
async handler(ctx) {
|
|
197
|
+
const email = opsEmail(ctx);
|
|
198
|
+
const months = ctx.flag('months') ? Number(ctx.flag('months')) : undefined;
|
|
199
|
+
const result = await withDb(async () => {
|
|
200
|
+
const user = await findUserOrThrow(email);
|
|
201
|
+
const { id } = await resolveLocalId(user, ctx.arg(0));
|
|
202
|
+
const { getCompetitorTimeline } = await backendImport('mcp/mcpTools.js');
|
|
203
|
+
return getCompetitorTimeline({ competitorId: id, months }, { user });
|
|
204
|
+
});
|
|
205
|
+
out(result, ctx);
|
|
206
|
+
},
|
|
207
|
+
},
|
|
208
|
+
|
|
209
|
+
compare: {
|
|
210
|
+
description: 'Compare two competitors\' timelines (local mode).',
|
|
211
|
+
usage: 'comp compare <nameA> <nameB> [--months 6] [--email ...]',
|
|
212
|
+
flags: [
|
|
213
|
+
{ name: 'months', description: 'Number of months' },
|
|
214
|
+
{ name: 'email', description: 'User whose data to read (defaults to profile)' },
|
|
215
|
+
],
|
|
216
|
+
async handler(ctx) {
|
|
217
|
+
const email = opsEmail(ctx);
|
|
218
|
+
const months = ctx.flag('months') ? Number(ctx.flag('months')) : undefined;
|
|
219
|
+
if (!ctx.arg(0) || !ctx.arg(1)) throw new Error('Usage: oppira comp compare <nameA> <nameB>');
|
|
220
|
+
const result = await withDb(async () => {
|
|
221
|
+
const user = await findUserOrThrow(email);
|
|
222
|
+
const a = await resolveLocalId(user, ctx.arg(0));
|
|
223
|
+
const b = await resolveLocalId(user, ctx.arg(1));
|
|
224
|
+
const { compareCompetitorsTimeline } = await backendImport('mcp/mcpTools.js');
|
|
225
|
+
return compareCompetitorsTimeline({ competitorIds: [a.id, b.id], months }, { user });
|
|
226
|
+
});
|
|
227
|
+
out(result, ctx);
|
|
228
|
+
},
|
|
229
|
+
},
|
|
230
|
+
|
|
231
|
+
'top-posts': {
|
|
232
|
+
description: 'Top posts across the period for a competitor (local mode).',
|
|
233
|
+
usage: 'comp top-posts <name> [--platform facebook] [--limit 10] [--email ...]',
|
|
234
|
+
flags: [
|
|
235
|
+
{ name: 'platform', description: 'Filter by platform' },
|
|
236
|
+
{ name: 'limit', description: 'Max posts' },
|
|
237
|
+
{ name: 'email', description: 'User whose data to read (defaults to profile)' },
|
|
238
|
+
],
|
|
239
|
+
async handler(ctx) {
|
|
240
|
+
const email = opsEmail(ctx);
|
|
241
|
+
const result = await withDb(async () => {
|
|
242
|
+
const user = await findUserOrThrow(email);
|
|
243
|
+
const { id } = await resolveLocalId(user, ctx.arg(0));
|
|
244
|
+
const { getTopPostsAcrossPeriod } = await backendImport('mcp/mcpTools.js');
|
|
245
|
+
return getTopPostsAcrossPeriod({
|
|
246
|
+
competitorId: id,
|
|
247
|
+
platform: ctx.flag('platform'),
|
|
248
|
+
limit: ctx.flag('limit') ? Number(ctx.flag('limit')) : undefined,
|
|
249
|
+
}, { user });
|
|
250
|
+
});
|
|
251
|
+
out(result, ctx);
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
|
|
255
|
+
tone: {
|
|
256
|
+
description: 'Tone / voice evolution for a competitor (local mode).',
|
|
257
|
+
usage: 'comp tone <name> [--limit 6] [--email ...]',
|
|
258
|
+
flags: [
|
|
259
|
+
{ name: 'limit', description: 'Max tone profiles' },
|
|
260
|
+
{ name: 'email', description: 'User whose data to read (defaults to profile)' },
|
|
261
|
+
],
|
|
262
|
+
async handler(ctx) {
|
|
263
|
+
const email = opsEmail(ctx);
|
|
264
|
+
const result = await withDb(async () => {
|
|
265
|
+
const user = await findUserOrThrow(email);
|
|
266
|
+
const { id } = await resolveLocalId(user, ctx.arg(0));
|
|
267
|
+
const { getToneEvolution } = await backendImport('mcp/mcpTools.js');
|
|
268
|
+
return getToneEvolution({ competitorId: id, limit: ctx.flag('limit') ? Number(ctx.flag('limit')) : undefined }, { user });
|
|
269
|
+
});
|
|
270
|
+
out(result, ctx);
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
};
|
|
275
|
+
|
|
276
|
+
export { resolveCompetitor, fetchCompetitors };
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// config: manage profiles (prod/staging, multiple accounts) and defaults.
|
|
2
|
+
import { loadConfig, getProfile, setProfileValue, useProfile } from '../config.js';
|
|
3
|
+
import { out, kv, table, info } from '../output.js';
|
|
4
|
+
|
|
5
|
+
export default {
|
|
6
|
+
description: 'Config & profiles — API URL, default output, account switching.',
|
|
7
|
+
commands: {
|
|
8
|
+
show: {
|
|
9
|
+
description: 'Show the current profile and its settings.',
|
|
10
|
+
async handler(ctx) {
|
|
11
|
+
const cfg = loadConfig();
|
|
12
|
+
const profile = getProfile(ctx.profileName);
|
|
13
|
+
kv({ currentProfile: cfg.currentProfile, name: profile.name, apiUrl: profile.apiUrl, output: profile.output || 'table' }, ctx);
|
|
14
|
+
},
|
|
15
|
+
},
|
|
16
|
+
|
|
17
|
+
list: {
|
|
18
|
+
description: 'List all configured profiles.',
|
|
19
|
+
async handler(ctx) {
|
|
20
|
+
const cfg = loadConfig();
|
|
21
|
+
const rows = Object.entries(cfg.profiles).map(([name, p]) => ({
|
|
22
|
+
profile: name === cfg.currentProfile ? `* ${name}` : name,
|
|
23
|
+
apiUrl: p.apiUrl,
|
|
24
|
+
output: p.output || 'table',
|
|
25
|
+
}));
|
|
26
|
+
table(rows, [{ key: 'profile', label: 'PROFILE' }, { key: 'apiUrl', label: 'API URL' }, { key: 'output', label: 'OUTPUT' }], ctx);
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
'set-url': {
|
|
31
|
+
description: 'Set the API base URL for a profile.',
|
|
32
|
+
usage: 'config set-url <url> [--profile name]',
|
|
33
|
+
async handler(ctx) {
|
|
34
|
+
const url = ctx.arg(0);
|
|
35
|
+
if (!url) throw new Error('Usage: oppira config set-url <url>');
|
|
36
|
+
setProfileValue(ctx.profileName, 'apiUrl', url);
|
|
37
|
+
info(`✓ ${ctx.profileName}.apiUrl = ${url}`);
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
'set-output': {
|
|
42
|
+
description: 'Set default output format for a profile (table|json).',
|
|
43
|
+
usage: 'config set-output <table|json>',
|
|
44
|
+
async handler(ctx) {
|
|
45
|
+
const fmt = ctx.arg(0);
|
|
46
|
+
if (!['table', 'json'].includes(fmt)) throw new Error('Output must be "table" or "json".');
|
|
47
|
+
setProfileValue(ctx.profileName, 'output', fmt);
|
|
48
|
+
info(`✓ ${ctx.profileName}.output = ${fmt}`);
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
use: {
|
|
53
|
+
description: 'Switch the active profile.',
|
|
54
|
+
usage: 'config use <name>',
|
|
55
|
+
async handler(ctx) {
|
|
56
|
+
const name = ctx.arg(0);
|
|
57
|
+
if (!name) throw new Error('Usage: oppira config use <name>');
|
|
58
|
+
useProfile(name);
|
|
59
|
+
if (ctx.json) { out({ success: true, currentProfile: name }, ctx); return; }
|
|
60
|
+
info(`✓ Switched to profile "${name}".`);
|
|
61
|
+
},
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
};
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
// discover: find social profiles for a company (async job) + manage the
|
|
2
|
+
// weekly competitor suggestions.
|
|
3
|
+
import { apiRequest } from '../http.js';
|
|
4
|
+
import { out, table, info } from '../output.js';
|
|
5
|
+
|
|
6
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
7
|
+
|
|
8
|
+
export default {
|
|
9
|
+
description: 'Discovery — find profiles for a company and manage suggestions.',
|
|
10
|
+
commands: {
|
|
11
|
+
profiles: {
|
|
12
|
+
description: 'Discover social profiles for a company (async job).',
|
|
13
|
+
usage: 'discover profiles --domain acme.com [--name Acme] [--region US] [--no-wait]',
|
|
14
|
+
flags: [
|
|
15
|
+
{ name: 'domain', description: 'Company domain (e.g. acme.com)' },
|
|
16
|
+
{ name: 'name', description: 'Company name' },
|
|
17
|
+
{ name: 'region', description: 'Region hint (e.g. US, EU)' },
|
|
18
|
+
{ name: 'no-wait', boolean: true, description: 'Enqueue and return the job id without polling' },
|
|
19
|
+
],
|
|
20
|
+
async handler(ctx) {
|
|
21
|
+
const domain = ctx.flag('domain');
|
|
22
|
+
const name = ctx.flag('name');
|
|
23
|
+
if (!domain && !name) throw new Error('Provide at least --domain or --name.');
|
|
24
|
+
const res = await apiRequest(ctx.profileName, 'POST', '/discovery/profiles', {
|
|
25
|
+
body: { domain, name, region: ctx.flag('region') },
|
|
26
|
+
});
|
|
27
|
+
const jobId = res?.jobId || res?.data?.jobId || res?.id;
|
|
28
|
+
if (!jobId) { out(res, ctx); return; }
|
|
29
|
+
if (ctx.bool('no-wait')) {
|
|
30
|
+
if (ctx.json) { out({ jobId }, ctx); return; }
|
|
31
|
+
info(`✓ Discovery job queued: ${jobId} (poll: oppira discover job ${jobId})`);
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
info(`Discovery job ${jobId} queued — polling…`);
|
|
35
|
+
for (let i = 0; i < 20; i++) {
|
|
36
|
+
await sleep(3000);
|
|
37
|
+
const job = await apiRequest(ctx.profileName, 'GET', `/discovery/jobs/${jobId}`);
|
|
38
|
+
const status = job?.status || job?.data?.status;
|
|
39
|
+
if (status && !['pending', 'processing', 'queued', 'running'].includes(status)) {
|
|
40
|
+
out(job, ctx);
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
if (!ctx.json) info(` …${status || 'working'}`);
|
|
44
|
+
}
|
|
45
|
+
info('Still running — check later with: oppira discover job ' + jobId);
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
job: {
|
|
50
|
+
description: 'Check a discovery job by id.',
|
|
51
|
+
usage: 'discover job <jobId>',
|
|
52
|
+
async handler(ctx) {
|
|
53
|
+
const jobId = ctx.arg(0);
|
|
54
|
+
if (!jobId) throw new Error('Usage: oppira discover job <jobId>');
|
|
55
|
+
const job = await apiRequest(ctx.profileName, 'GET', `/discovery/jobs/${jobId}`);
|
|
56
|
+
out(job, ctx);
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
|
|
60
|
+
suggestions: {
|
|
61
|
+
description: 'List competitor suggestions.',
|
|
62
|
+
async handler(ctx) {
|
|
63
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/competitor-suggestions');
|
|
64
|
+
const list = res?.data || res?.suggestions || res || [];
|
|
65
|
+
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);
|
|
72
|
+
},
|
|
73
|
+
},
|
|
74
|
+
|
|
75
|
+
dismiss: {
|
|
76
|
+
description: 'Dismiss a competitor suggestion by id.',
|
|
77
|
+
usage: 'discover dismiss <id>',
|
|
78
|
+
async handler(ctx) {
|
|
79
|
+
const id = ctx.arg(0);
|
|
80
|
+
if (!id) throw new Error('Usage: oppira discover dismiss <id>');
|
|
81
|
+
const res = await apiRequest(ctx.profileName, 'POST', `/competitor-suggestions/${id}/dismiss`, { body: {} });
|
|
82
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
83
|
+
info(`✓ Dismissed suggestion ${id}.`);
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
},
|
|
87
|
+
};
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// insights: 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, kv } 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: 'Insights — list cards, weekly summary, and force-generate (ops).',
|
|
15
|
+
commands: {
|
|
16
|
+
list: {
|
|
17
|
+
description: 'List AI insight cards.',
|
|
18
|
+
async handler(ctx) {
|
|
19
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/insights');
|
|
20
|
+
const cards = res?.data || res?.insights || res || [];
|
|
21
|
+
if (ctx.json) { out(cards, ctx); return; }
|
|
22
|
+
const rows = (Array.isArray(cards) ? cards : []).map((c) => ({
|
|
23
|
+
type: c.type || c.category || '—',
|
|
24
|
+
title: c.title || c.headline || '—',
|
|
25
|
+
competitor: c.competitorName || c.competitor || '—',
|
|
26
|
+
read: c.read || c.isRead ? 'yes' : 'no',
|
|
27
|
+
}));
|
|
28
|
+
table(rows, [
|
|
29
|
+
{ key: 'type', label: 'TYPE' }, { key: 'title', label: 'TITLE', max: 60 },
|
|
30
|
+
{ key: 'competitor', label: 'COMPETITOR' }, { key: 'read', label: 'READ' },
|
|
31
|
+
], ctx);
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
|
|
35
|
+
summary: {
|
|
36
|
+
description: 'Show the weekly actionable summary.',
|
|
37
|
+
async handler(ctx) {
|
|
38
|
+
const res = await apiRequest(ctx.profileName, 'GET', '/insights/actionable-summary');
|
|
39
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
40
|
+
const s = res?.data || res?.summary || res;
|
|
41
|
+
if (!s) { info('(no summary yet)'); return; }
|
|
42
|
+
kv({ generatedAt: s.createdAt || s.generatedAt || '—', title: s.title || '—' }, ctx);
|
|
43
|
+
if (s.body || s.content) { info(''); info(s.body || s.content); }
|
|
44
|
+
else out(s, ctx);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
generate: {
|
|
49
|
+
description: 'Force-generate insights now (remote by default; --local runs the engine directly).',
|
|
50
|
+
usage: 'insights generate [--local] [--email you@co.com]',
|
|
51
|
+
flags: [
|
|
52
|
+
{ name: 'local', boolean: true, description: 'Run the engine directly (ops; needs repo + .env)' },
|
|
53
|
+
{ name: 'email', description: 'Target user in --local mode (defaults to logged-in profile)' },
|
|
54
|
+
],
|
|
55
|
+
async handler(ctx) {
|
|
56
|
+
if (!ctx.useLocal) {
|
|
57
|
+
// Remote: the authenticated user generates their own insights.
|
|
58
|
+
const res = await apiRequest(ctx.profileName, 'POST', '/insights/generate', { body: {} });
|
|
59
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
60
|
+
info('✓ Insights generation triggered.');
|
|
61
|
+
if (res?.result) out(res.result, ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
const email = opsEmail(ctx);
|
|
65
|
+
const result = await withDb(async () => {
|
|
66
|
+
const user = await findUserOrThrow(email);
|
|
67
|
+
const { generateInsightsForUser } = await backendImport('utils/weeklyInsightsGenerator.js');
|
|
68
|
+
return generateInsightsForUser(user);
|
|
69
|
+
});
|
|
70
|
+
if (ctx.json) { out({ success: true, email, result }, ctx); return; }
|
|
71
|
+
info(`✓ Insights generation run for ${email} (local).`);
|
|
72
|
+
if (result) out(result, ctx);
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
},
|
|
76
|
+
};
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// playbook: read the strategy doc, bootstrap it, and refresh sections/clusters.
|
|
2
|
+
// All REST (JWT), gated by requirePlaybookAccess + quota on the backend.
|
|
3
|
+
import { apiRequest } from '../http.js';
|
|
4
|
+
import { getCredentials } from '../config.js';
|
|
5
|
+
import { out, kv, info } from '../output.js';
|
|
6
|
+
|
|
7
|
+
function playbookEmail(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 playbook owner.');
|
|
10
|
+
return encodeURIComponent(email);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export default {
|
|
14
|
+
description: 'Marketing playbook — show, bootstrap, refresh sections/clusters.',
|
|
15
|
+
commands: {
|
|
16
|
+
show: {
|
|
17
|
+
description: 'Show the playbook (use --json for the full document).',
|
|
18
|
+
usage: 'playbook show [--email you@co.com]',
|
|
19
|
+
flags: [{ name: 'email', description: 'Playbook owner (defaults to logged-in profile)' }],
|
|
20
|
+
async handler(ctx) {
|
|
21
|
+
const email = playbookEmail(ctx);
|
|
22
|
+
const res = await apiRequest(ctx.profileName, 'GET', `/playbook/${email}`);
|
|
23
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
24
|
+
const pb = res?.data || res?.playbook || res;
|
|
25
|
+
if (!pb) { info('(no playbook yet — run: oppira playbook bootstrap)'); return; }
|
|
26
|
+
kv({
|
|
27
|
+
updatedAt: pb.updatedAt || '—',
|
|
28
|
+
sections: Array.isArray(pb.sections) ? pb.sections.length : (pb.sections ? Object.keys(pb.sections).length : '—'),
|
|
29
|
+
status: pb.status || '—',
|
|
30
|
+
}, ctx);
|
|
31
|
+
if (Array.isArray(pb.sections)) {
|
|
32
|
+
info('');
|
|
33
|
+
for (const s of pb.sections) info(`- ${s.id || s.key || ''}: ${s.title || ''}`);
|
|
34
|
+
}
|
|
35
|
+
info('\n(use --json for the full document)');
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
bootstrap: {
|
|
40
|
+
description: 'Bootstrap / (re)build the playbook.',
|
|
41
|
+
usage: 'playbook bootstrap [--email you@co.com]',
|
|
42
|
+
flags: [{ name: 'email', description: 'Playbook owner (defaults to logged-in profile)' }],
|
|
43
|
+
async handler(ctx) {
|
|
44
|
+
const email = playbookEmail(ctx);
|
|
45
|
+
const res = await apiRequest(ctx.profileName, 'POST', `/playbook/${email}/bootstrap`, { body: {} });
|
|
46
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
47
|
+
info(`✓ Playbook bootstrap triggered. ${res?.message || ''}`);
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
|
|
51
|
+
'refresh-section': {
|
|
52
|
+
description: 'Refresh a single playbook section.',
|
|
53
|
+
usage: 'playbook refresh-section <sectionId> [--email ...]',
|
|
54
|
+
flags: [{ name: 'email', description: 'Playbook owner' }],
|
|
55
|
+
async handler(ctx) {
|
|
56
|
+
const email = playbookEmail(ctx);
|
|
57
|
+
const sectionId = ctx.arg(0);
|
|
58
|
+
if (!sectionId) throw new Error('Usage: oppira playbook refresh-section <sectionId>');
|
|
59
|
+
const res = await apiRequest(ctx.profileName, 'POST', `/playbook/${email}/section/${encodeURIComponent(sectionId)}/refresh`, { body: {} });
|
|
60
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
61
|
+
info(`✓ Section "${sectionId}" refresh triggered. ${res?.message || ''}`);
|
|
62
|
+
},
|
|
63
|
+
},
|
|
64
|
+
|
|
65
|
+
'refresh-cluster': {
|
|
66
|
+
description: 'Refresh a playbook cluster (c1/c2/c4).',
|
|
67
|
+
usage: 'playbook refresh-cluster <clusterId> [--email ...]',
|
|
68
|
+
flags: [{ name: 'email', description: 'Playbook owner' }],
|
|
69
|
+
async handler(ctx) {
|
|
70
|
+
const email = playbookEmail(ctx);
|
|
71
|
+
const clusterId = ctx.arg(0);
|
|
72
|
+
if (!clusterId) throw new Error('Usage: oppira playbook refresh-cluster <clusterId>');
|
|
73
|
+
const res = await apiRequest(ctx.profileName, 'POST', `/playbook/${email}/cluster/${encodeURIComponent(clusterId)}/refresh`, { body: {} });
|
|
74
|
+
if (ctx.json) { out(res, ctx); return; }
|
|
75
|
+
info(`✓ Cluster "${clusterId}" refresh triggered. ${res?.message || ''}`);
|
|
76
|
+
},
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
};
|