@wildbappy/bappy-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 +30 -0
- package/bin/bappy.js +323 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# bappy CLI
|
|
2
|
+
|
|
3
|
+
Command-line access to Bappy's competitive intelligence dashboard subagent API.
|
|
4
|
+
|
|
5
|
+
## Install
|
|
6
|
+
|
|
7
|
+
```bash
|
|
8
|
+
npm install -g @wildbappy/bappy-cli
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
## First Run
|
|
12
|
+
|
|
13
|
+
```bash
|
|
14
|
+
bappy "how many recent blog posts has peer AI made?"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
On first run, the CLI prompts for your token:
|
|
18
|
+
|
|
19
|
+
- Get token from dashboard settings: `http://149-28-213-47.nip.io/`
|
|
20
|
+
- Paste token when prompted.
|
|
21
|
+
|
|
22
|
+
## Commands
|
|
23
|
+
|
|
24
|
+
```bash
|
|
25
|
+
bappy auth
|
|
26
|
+
bappy logout
|
|
27
|
+
bappy update
|
|
28
|
+
bappy --json "question"
|
|
29
|
+
bappy --url http://149-28-213-47.nip.io "question"
|
|
30
|
+
```
|
package/bin/bappy.js
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const fs = require('fs/promises');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const readline = require('readline/promises');
|
|
7
|
+
const { spawn } = require('child_process');
|
|
8
|
+
const { stdin, stdout, stderr, exit } = require('process');
|
|
9
|
+
|
|
10
|
+
const DEFAULT_BASE_URL = 'http://149-28-213-47.nip.io';
|
|
11
|
+
const TOKEN_PATTERN = /^cta_[A-Za-z0-9_-]{20,}$/;
|
|
12
|
+
|
|
13
|
+
function notifyIfUpdateAvailable() {
|
|
14
|
+
try {
|
|
15
|
+
const updateNotifier = require('update-notifier');
|
|
16
|
+
const pkg = require('../package.json');
|
|
17
|
+
updateNotifier({
|
|
18
|
+
pkg,
|
|
19
|
+
updateCheckInterval: 6 * 60 * 60 * 1000
|
|
20
|
+
}).notify({
|
|
21
|
+
isGlobal: true,
|
|
22
|
+
defer: false
|
|
23
|
+
});
|
|
24
|
+
} catch {
|
|
25
|
+
// Non-fatal: CLI should keep working even if update checks fail.
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function getConfigDir() {
|
|
30
|
+
const envDir = String(process.env.BAPPY_CONFIG_DIR || '').trim();
|
|
31
|
+
if (envDir) return envDir;
|
|
32
|
+
return path.join(os.homedir(), '.bappy-cli');
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function getConfigPath() {
|
|
36
|
+
return path.join(getConfigDir(), 'config.json');
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
async function readConfig() {
|
|
40
|
+
const file = getConfigPath();
|
|
41
|
+
try {
|
|
42
|
+
const raw = await fs.readFile(file, 'utf8');
|
|
43
|
+
const parsed = JSON.parse(raw);
|
|
44
|
+
return parsed && typeof parsed === 'object' ? parsed : {};
|
|
45
|
+
} catch {
|
|
46
|
+
return {};
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async function writeConfig(config) {
|
|
51
|
+
const dir = getConfigDir();
|
|
52
|
+
const file = getConfigPath();
|
|
53
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
54
|
+
const payload = JSON.stringify(config, null, 2);
|
|
55
|
+
await fs.writeFile(file, payload, { mode: 0o600 });
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sanitizeBaseUrl(value) {
|
|
59
|
+
const raw = String(value || '').trim();
|
|
60
|
+
if (!raw) return DEFAULT_BASE_URL;
|
|
61
|
+
const normalized = raw.replace(/\/+$/, '');
|
|
62
|
+
let url;
|
|
63
|
+
try {
|
|
64
|
+
url = new URL(normalized);
|
|
65
|
+
} catch {
|
|
66
|
+
throw new Error(`Invalid URL: ${raw}`);
|
|
67
|
+
}
|
|
68
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
69
|
+
throw new Error('Base URL must use http or https');
|
|
70
|
+
}
|
|
71
|
+
return `${url.protocol}//${url.host}`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function printWelcome(baseUrl) {
|
|
75
|
+
stdout.write("Welcome to bappy's competitive intelligence dashboard! Thanks for trying it out.\n\n");
|
|
76
|
+
stdout.write(`You can find your token in your settings at ${baseUrl}/ after you log in.\n\n`);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printHelp() {
|
|
80
|
+
stdout.write(`bappy - Competitive intelligence CLI\n\n`);
|
|
81
|
+
stdout.write(`Usage:\n`);
|
|
82
|
+
stdout.write(` bappy "<question>"\n`);
|
|
83
|
+
stdout.write(` bappy auth\n`);
|
|
84
|
+
stdout.write(` bappy logout\n`);
|
|
85
|
+
stdout.write(` bappy update\n`);
|
|
86
|
+
stdout.write(` bappy --url <baseUrl> "<question>"\n`);
|
|
87
|
+
stdout.write(` bappy --token <cta_...> "<question>"\n`);
|
|
88
|
+
stdout.write(` bappy --json "<question>"\n\n`);
|
|
89
|
+
stdout.write(`Env vars:\n`);
|
|
90
|
+
stdout.write(` BAPPY_API_URL Override API base URL\n`);
|
|
91
|
+
stdout.write(` BAPPY_CONFIG_DIR Override config directory\n`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function parseArgs(argv) {
|
|
95
|
+
const flags = {
|
|
96
|
+
help: false,
|
|
97
|
+
json: false,
|
|
98
|
+
url: null,
|
|
99
|
+
token: null
|
|
100
|
+
};
|
|
101
|
+
const positional = [];
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
104
|
+
const arg = argv[i];
|
|
105
|
+
if (arg === '--help' || arg === '-h') {
|
|
106
|
+
flags.help = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (arg === '--json') {
|
|
110
|
+
flags.json = true;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (arg === '--url') {
|
|
114
|
+
i += 1;
|
|
115
|
+
flags.url = argv[i] || null;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
if (arg === '--token') {
|
|
119
|
+
i += 1;
|
|
120
|
+
flags.token = argv[i] || null;
|
|
121
|
+
continue;
|
|
122
|
+
}
|
|
123
|
+
positional.push(arg);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const command = positional[0] === 'auth' || positional[0] === 'logout' || positional[0] === 'update'
|
|
127
|
+
? positional[0]
|
|
128
|
+
: null;
|
|
129
|
+
const question = command ? positional.slice(1).join(' ').trim() : positional.join(' ').trim();
|
|
130
|
+
|
|
131
|
+
return { flags, command, question };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function runNpmGlobalInstall(packageName) {
|
|
135
|
+
return new Promise((resolve, reject) => {
|
|
136
|
+
const child = spawn('npm', ['install', '-g', `${packageName}@latest`], {
|
|
137
|
+
stdio: 'inherit',
|
|
138
|
+
shell: process.platform === 'win32'
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
child.on('error', reject);
|
|
142
|
+
child.on('close', (code) => {
|
|
143
|
+
if (code === 0) {
|
|
144
|
+
resolve();
|
|
145
|
+
} else {
|
|
146
|
+
reject(new Error(`npm exited with code ${code}`));
|
|
147
|
+
}
|
|
148
|
+
});
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async function updateCli() {
|
|
153
|
+
const pkg = require('../package.json');
|
|
154
|
+
const packageName = pkg.name;
|
|
155
|
+
stdout.write(`Updating ${packageName}...\n`);
|
|
156
|
+
await runNpmGlobalInstall(packageName);
|
|
157
|
+
stdout.write(`Update complete.\n`);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function promptLine(label) {
|
|
161
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
162
|
+
try {
|
|
163
|
+
const answer = await rl.question(label);
|
|
164
|
+
return String(answer || '').trim();
|
|
165
|
+
} finally {
|
|
166
|
+
rl.close();
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
async function validateToken(baseUrl, token) {
|
|
171
|
+
const res = await fetch(`${baseUrl}/api/subagent/actions`, {
|
|
172
|
+
method: 'GET',
|
|
173
|
+
headers: {
|
|
174
|
+
Authorization: `Bearer ${token}`
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
if (!res.ok) {
|
|
179
|
+
const bodyText = await res.text().catch(() => '');
|
|
180
|
+
const message = bodyText && bodyText.length < 400 ? bodyText : `HTTP ${res.status}`;
|
|
181
|
+
throw new Error(`Auth failed: ${message}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function renderResult(result, asJson = false) {
|
|
188
|
+
if (asJson) {
|
|
189
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const answer = result?.result?.answer;
|
|
194
|
+
if (answer && typeof answer.blogPostCount === 'number') {
|
|
195
|
+
stdout.write(`${answer.company}: ${answer.blogPostCount} blog posts\n`);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
if (result?.status && result?.result) {
|
|
200
|
+
stdout.write(`${JSON.stringify(result.result, null, 2)}\n`);
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
stdout.write(`${JSON.stringify(result, null, 2)}\n`);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function invokeQuestion(baseUrl, token, question) {
|
|
208
|
+
const res = await fetch(`${baseUrl}/api/subagent/invoke`, {
|
|
209
|
+
method: 'POST',
|
|
210
|
+
headers: {
|
|
211
|
+
'Content-Type': 'application/json',
|
|
212
|
+
Authorization: `Bearer ${token}`
|
|
213
|
+
},
|
|
214
|
+
body: JSON.stringify({ question })
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const body = await res.json().catch(() => null);
|
|
218
|
+
if (!res.ok) {
|
|
219
|
+
const message = body?.error?.message || body?.error || `HTTP ${res.status}`;
|
|
220
|
+
throw new Error(String(message));
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return body;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
async function ensureToken({ baseUrl, config, forcedToken = null, forcePrompt = false }) {
|
|
227
|
+
let candidate = forcedToken || config.token || null;
|
|
228
|
+
|
|
229
|
+
if (!candidate || forcePrompt) {
|
|
230
|
+
if (!stdin.isTTY) {
|
|
231
|
+
throw new Error('No token configured. Run in an interactive terminal and provide your token.');
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
printWelcome(baseUrl);
|
|
235
|
+
candidate = await promptLine('PLEASE ENTER TOKEN ID: ');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
239
|
+
const token = String(candidate || '').trim();
|
|
240
|
+
if (!TOKEN_PATTERN.test(token)) {
|
|
241
|
+
if (!stdin.isTTY || attempt >= 2) {
|
|
242
|
+
throw new Error('Invalid token format. Token must start with cta_.');
|
|
243
|
+
}
|
|
244
|
+
stderr.write('Invalid token format. Token must start with cta_.\n');
|
|
245
|
+
candidate = await promptLine('PLEASE ENTER TOKEN ID: ');
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
try {
|
|
250
|
+
await validateToken(baseUrl, token);
|
|
251
|
+
return token;
|
|
252
|
+
} catch (error) {
|
|
253
|
+
if (!stdin.isTTY || attempt >= 2) {
|
|
254
|
+
throw new Error(`Token authentication failed. ${error.message}`);
|
|
255
|
+
}
|
|
256
|
+
stderr.write(`Token authentication failed. ${error.message}\n`);
|
|
257
|
+
candidate = await promptLine('PLEASE ENTER TOKEN ID: ');
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
throw new Error('Unable to validate token.');
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async function run() {
|
|
265
|
+
notifyIfUpdateAvailable();
|
|
266
|
+
|
|
267
|
+
const { flags, command, question } = parseArgs(process.argv.slice(2));
|
|
268
|
+
|
|
269
|
+
if (flags.help) {
|
|
270
|
+
printHelp();
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const config = await readConfig();
|
|
275
|
+
const baseUrl = sanitizeBaseUrl(flags.url || process.env.BAPPY_API_URL || config.baseUrl || DEFAULT_BASE_URL);
|
|
276
|
+
|
|
277
|
+
if (command === 'logout') {
|
|
278
|
+
const next = { ...config, baseUrl };
|
|
279
|
+
delete next.token;
|
|
280
|
+
await writeConfig(next);
|
|
281
|
+
stdout.write('Token removed.\n');
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (command === 'auth') {
|
|
286
|
+
const token = await ensureToken({
|
|
287
|
+
baseUrl,
|
|
288
|
+
config,
|
|
289
|
+
forcedToken: flags.token,
|
|
290
|
+
forcePrompt: !flags.token
|
|
291
|
+
});
|
|
292
|
+
await writeConfig({ ...config, baseUrl, token });
|
|
293
|
+
stdout.write('Token saved.\n');
|
|
294
|
+
return;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (command === 'update') {
|
|
298
|
+
await updateCli();
|
|
299
|
+
return;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
if (!question) {
|
|
303
|
+
printHelp();
|
|
304
|
+
throw new Error('Please provide a question.');
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
const token = await ensureToken({
|
|
308
|
+
baseUrl,
|
|
309
|
+
config,
|
|
310
|
+
forcedToken: flags.token,
|
|
311
|
+
forcePrompt: !config.token && !flags.token
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
await writeConfig({ ...config, baseUrl, token });
|
|
315
|
+
|
|
316
|
+
const result = await invokeQuestion(baseUrl, token, question);
|
|
317
|
+
renderResult(result, flags.json);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
run().catch((error) => {
|
|
321
|
+
stderr.write(`Error: ${error.message}\n`);
|
|
322
|
+
exit(1);
|
|
323
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@wildbappy/bappy-cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "CLI for Bappy's competitive intelligence dashboard",
|
|
5
|
+
"bin": {
|
|
6
|
+
"bappy": "bin/bappy.js"
|
|
7
|
+
},
|
|
8
|
+
"files": [
|
|
9
|
+
"bin",
|
|
10
|
+
"README.md"
|
|
11
|
+
],
|
|
12
|
+
"engines": {
|
|
13
|
+
"node": ">=20"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"bappy",
|
|
17
|
+
"cli",
|
|
18
|
+
"competitive-intelligence",
|
|
19
|
+
"comptracker"
|
|
20
|
+
],
|
|
21
|
+
"license": "MIT",
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/onthegonow/comptracker.git",
|
|
25
|
+
"directory": "packages/bappy-cli"
|
|
26
|
+
},
|
|
27
|
+
"bugs": {
|
|
28
|
+
"url": "https://github.com/onthegonow/comptracker/issues"
|
|
29
|
+
},
|
|
30
|
+
"homepage": "https://github.com/onthegonow/comptracker/tree/master/packages/bappy-cli",
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public"
|
|
33
|
+
},
|
|
34
|
+
"dependencies": {
|
|
35
|
+
"update-notifier": "^7.3.1"
|
|
36
|
+
}
|
|
37
|
+
}
|