@c4t4/heyamigo 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/.gitignore +35 -0
- package/LICENSE +21 -0
- package/README.md +261 -0
- package/config/access.example.json +88 -0
- package/config/config.example.json +72 -0
- package/config/import-instructions.HOWTO.md +58 -0
- package/config/import-instructions.md +67 -0
- package/config/memory-instructions.md +40 -0
- package/config/personalities/casual.md +24 -0
- package/config/personalities/professional.md +25 -0
- package/config/personalities/sharp.md +45 -0
- package/dist/ai/claude.js +153 -0
- package/dist/ai/sessions.js +63 -0
- package/dist/cli/import.js +17 -0
- package/dist/cli/index.js +70 -0
- package/dist/cli/service.js +105 -0
- package/dist/cli/setup.js +701 -0
- package/dist/cli/start.js +37 -0
- package/dist/cli/supervisor.js +37 -0
- package/dist/config.js +104 -0
- package/dist/gateway/bootstrap.js +56 -0
- package/dist/gateway/commands.js +58 -0
- package/dist/gateway/incoming.js +239 -0
- package/dist/gateway/outgoing.js +168 -0
- package/dist/gateway/triggers.js +75 -0
- package/dist/index.js +30 -0
- package/dist/logger.js +7 -0
- package/dist/memory/digest-flag.js +8 -0
- package/dist/memory/digest.js +211 -0
- package/dist/memory/frontmatter.js +100 -0
- package/dist/memory/importer.js +103 -0
- package/dist/memory/paths.js +26 -0
- package/dist/memory/preamble.js +98 -0
- package/dist/memory/router.js +90 -0
- package/dist/memory/scheduler.js +85 -0
- package/dist/memory/store.js +183 -0
- package/dist/promptlog.js +52 -0
- package/dist/queue/persistence.js +68 -0
- package/dist/queue/queue.js +49 -0
- package/dist/queue/types.js +1 -0
- package/dist/queue/worker.js +51 -0
- package/dist/store/media.js +108 -0
- package/dist/store/messages.js +33 -0
- package/dist/wa/auth.js +9 -0
- package/dist/wa/sender.js +79 -0
- package/dist/wa/socket.js +84 -0
- package/dist/wa/whitelist.js +213 -0
- package/package.json +63 -0
- package/scripts/start-browser.sh +158 -0
|
@@ -0,0 +1,701 @@
|
|
|
1
|
+
import * as p from '@clack/prompts';
|
|
2
|
+
import { execSync, spawnSync } from 'child_process';
|
|
3
|
+
import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync, } from 'fs';
|
|
4
|
+
import { homedir } from 'os';
|
|
5
|
+
import { dirname, resolve } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
const __pkgRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
8
|
+
function run(cmd) {
|
|
9
|
+
try {
|
|
10
|
+
const out = execSync(cmd, { encoding: 'utf-8', stdio: 'pipe' }).trim();
|
|
11
|
+
return { ok: true, output: out };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return { ok: false, output: '' };
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
function which(bin) {
|
|
18
|
+
const r = run(`which ${bin}`);
|
|
19
|
+
return r.ok ? r.output : null;
|
|
20
|
+
}
|
|
21
|
+
function runLive(cmd) {
|
|
22
|
+
const result = spawnSync('sh', ['-c', cmd], { stdio: 'inherit' });
|
|
23
|
+
return result.status === 0;
|
|
24
|
+
}
|
|
25
|
+
function setConfigOwnerNumber(configPath, number) {
|
|
26
|
+
try {
|
|
27
|
+
const cfg = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
28
|
+
cfg.owner = { ...(cfg.owner ?? {}), number };
|
|
29
|
+
writeFileSync(configPath, JSON.stringify(cfg, null, 2) + '\n', 'utf-8');
|
|
30
|
+
}
|
|
31
|
+
catch { }
|
|
32
|
+
}
|
|
33
|
+
function findPackageDir() {
|
|
34
|
+
// __pkgRoot = two levels up from dist/cli/ = package root
|
|
35
|
+
if (existsSync(resolve(__pkgRoot, 'config', 'config.example.json'))) {
|
|
36
|
+
return __pkgRoot;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
function scaffoldProject(targetDir, pkgDir) {
|
|
41
|
+
mkdirSync(targetDir, { recursive: true });
|
|
42
|
+
// Generate package.json for the project
|
|
43
|
+
const projectPkg = {
|
|
44
|
+
name: 'my-heyamigo',
|
|
45
|
+
private: true,
|
|
46
|
+
type: 'module',
|
|
47
|
+
dependencies: {
|
|
48
|
+
heyamigo: '*',
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
writeFileSync(resolve(targetDir, 'package.json'), JSON.stringify(projectPkg, null, 2) + '\n');
|
|
52
|
+
// Copy config templates
|
|
53
|
+
const configDir = resolve(targetDir, 'config');
|
|
54
|
+
mkdirSync(configDir, { recursive: true });
|
|
55
|
+
const configFiles = [
|
|
56
|
+
'config.example.json',
|
|
57
|
+
'access.example.json',
|
|
58
|
+
'memory-instructions.md',
|
|
59
|
+
'import-instructions.md',
|
|
60
|
+
'import-instructions.HOWTO.md',
|
|
61
|
+
];
|
|
62
|
+
for (const f of configFiles) {
|
|
63
|
+
const src = resolve(pkgDir, 'config', f);
|
|
64
|
+
if (existsSync(src))
|
|
65
|
+
copyFileSync(src, resolve(configDir, f));
|
|
66
|
+
}
|
|
67
|
+
// Copy personalities
|
|
68
|
+
const persDir = resolve(configDir, 'personalities');
|
|
69
|
+
mkdirSync(persDir, { recursive: true });
|
|
70
|
+
for (const f of ['sharp.md', 'casual.md', 'professional.md']) {
|
|
71
|
+
const src = resolve(pkgDir, 'config', 'personalities', f);
|
|
72
|
+
if (existsSync(src))
|
|
73
|
+
copyFileSync(src, resolve(persDir, f));
|
|
74
|
+
}
|
|
75
|
+
// Copy scripts
|
|
76
|
+
const scriptsDir = resolve(targetDir, 'scripts');
|
|
77
|
+
mkdirSync(scriptsDir, { recursive: true });
|
|
78
|
+
const browserScript = resolve(pkgDir, 'scripts', 'start-browser.sh');
|
|
79
|
+
if (existsSync(browserScript)) {
|
|
80
|
+
copyFileSync(browserScript, resolve(scriptsDir, 'start-browser.sh'));
|
|
81
|
+
try {
|
|
82
|
+
execSync(`chmod +x "${resolve(scriptsDir, 'start-browser.sh')}"`);
|
|
83
|
+
}
|
|
84
|
+
catch { }
|
|
85
|
+
}
|
|
86
|
+
// Copy .gitignore
|
|
87
|
+
const gi = resolve(pkgDir, '.gitignore');
|
|
88
|
+
if (existsSync(gi))
|
|
89
|
+
copyFileSync(gi, resolve(targetDir, '.gitignore'));
|
|
90
|
+
}
|
|
91
|
+
export async function runSetup() {
|
|
92
|
+
console.clear();
|
|
93
|
+
p.intro('heyamigo');
|
|
94
|
+
// ── Node.js ──────────────────────────────────────────────────
|
|
95
|
+
const nodeVer = run('node -v');
|
|
96
|
+
if (!nodeVer.ok) {
|
|
97
|
+
p.cancel('Node.js not found. Install v18+ from https://nodejs.org');
|
|
98
|
+
process.exit(1);
|
|
99
|
+
}
|
|
100
|
+
const major = parseInt(nodeVer.output.replace('v', ''), 10);
|
|
101
|
+
if (major < 18) {
|
|
102
|
+
p.cancel(`Node.js v18+ required (found ${nodeVer.output})`);
|
|
103
|
+
process.exit(1);
|
|
104
|
+
}
|
|
105
|
+
p.log.success(`Node.js ${nodeVer.output}`);
|
|
106
|
+
// ── Scaffold project if needed ───────────────────────────────
|
|
107
|
+
let cwd = process.cwd();
|
|
108
|
+
const isProject = existsSync(resolve(cwd, 'config', 'config.example.json'));
|
|
109
|
+
if (!isProject) {
|
|
110
|
+
const pkgDir = findPackageDir();
|
|
111
|
+
const dirName = await p.text({
|
|
112
|
+
message: 'Where to create the project?',
|
|
113
|
+
placeholder: './heyamigo',
|
|
114
|
+
initialValue: './heyamigo',
|
|
115
|
+
});
|
|
116
|
+
if (p.isCancel(dirName)) {
|
|
117
|
+
p.cancel('Setup cancelled');
|
|
118
|
+
process.exit(0);
|
|
119
|
+
}
|
|
120
|
+
const targetDir = resolve(cwd, dirName);
|
|
121
|
+
if (existsSync(targetDir) && existsSync(resolve(targetDir, 'config', 'config.example.json'))) {
|
|
122
|
+
p.log.info(`Project already exists at ${targetDir}`);
|
|
123
|
+
cwd = targetDir;
|
|
124
|
+
process.chdir(cwd);
|
|
125
|
+
}
|
|
126
|
+
else if (pkgDir) {
|
|
127
|
+
const s0 = p.spinner();
|
|
128
|
+
s0.start('Scaffolding project');
|
|
129
|
+
scaffoldProject(targetDir, pkgDir);
|
|
130
|
+
cwd = targetDir;
|
|
131
|
+
process.chdir(cwd);
|
|
132
|
+
s0.stop(`Project created at ${targetDir}`);
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
p.cancel('Could not find heyamigo package files. Try:\n' +
|
|
136
|
+
' git clone https://github.com/C4T4/heyamigo.git\n' +
|
|
137
|
+
' cd heyamigo\n' +
|
|
138
|
+
' npm install && npm run setup');
|
|
139
|
+
process.exit(1);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// ── Dependencies ─────────────────────────────────────────────
|
|
143
|
+
p.log.step('Installing dependencies...');
|
|
144
|
+
if (!runLive('npm install --no-fund --no-audit')) {
|
|
145
|
+
p.cancel('npm install failed. Check output above and retry.');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
p.log.success('Dependencies installed');
|
|
149
|
+
// ── Config files ─────────────────────────────────────────────
|
|
150
|
+
const configPath = resolve(cwd, 'config/config.json');
|
|
151
|
+
const configExample = resolve(cwd, 'config/config.example.json');
|
|
152
|
+
const accessPath = resolve(cwd, 'config/access.json');
|
|
153
|
+
const accessExample = resolve(cwd, 'config/access.example.json');
|
|
154
|
+
if (!existsSync(configPath) && existsSync(configExample)) {
|
|
155
|
+
copyFileSync(configExample, configPath);
|
|
156
|
+
p.log.success('config.json created');
|
|
157
|
+
}
|
|
158
|
+
else if (!existsSync(configPath)) {
|
|
159
|
+
p.cancel('config/config.example.json not found. Is this the right directory?');
|
|
160
|
+
process.exit(1);
|
|
161
|
+
}
|
|
162
|
+
let ownerNum = '';
|
|
163
|
+
if (!existsSync(accessPath) && existsSync(accessExample)) {
|
|
164
|
+
copyFileSync(accessExample, accessPath);
|
|
165
|
+
p.log.success('access.json created');
|
|
166
|
+
}
|
|
167
|
+
else {
|
|
168
|
+
p.log.info('access.json already exists');
|
|
169
|
+
}
|
|
170
|
+
// ── Claude CLI (critical — bot cannot work without this) ─────
|
|
171
|
+
const claudePath = which('claude');
|
|
172
|
+
if (!claudePath) {
|
|
173
|
+
p.cancel('Claude CLI is required but was not found.\n' +
|
|
174
|
+
'Install it first, then re-run setup:\n\n' +
|
|
175
|
+
' npm install -g @anthropic-ai/claude-code\n\n' +
|
|
176
|
+
'For other install methods see: https://docs.anthropic.com/en/docs/claude-code');
|
|
177
|
+
process.exit(1);
|
|
178
|
+
}
|
|
179
|
+
p.log.success('Claude CLI found');
|
|
180
|
+
// Auth (critical — bot uses your Claude subscription, not API)
|
|
181
|
+
const authenticated = run('claude auth status').ok;
|
|
182
|
+
if (!authenticated) {
|
|
183
|
+
p.cancel('Claude is not logged in.\n' +
|
|
184
|
+
'Run claude in your terminal and follow the login instructions:\n\n' +
|
|
185
|
+
' claude\n\n' +
|
|
186
|
+
'Once logged in, re-run: npx @c4t4/heyamigo setup');
|
|
187
|
+
process.exit(1);
|
|
188
|
+
}
|
|
189
|
+
p.log.success('Claude authenticated');
|
|
190
|
+
{
|
|
191
|
+
// Tool permissions — write .claude/settings.json in project root.
|
|
192
|
+
p.log.info('Claude needs tool permissions to browse the web, read files, and control the browser. ' +
|
|
193
|
+
'This writes a .claude/settings.json file in the project directory.');
|
|
194
|
+
const grantPermissions = await p.confirm({
|
|
195
|
+
message: 'Grant tool permissions? (WebFetch, WebSearch, Read, Edit, Write, browser)',
|
|
196
|
+
initialValue: true,
|
|
197
|
+
});
|
|
198
|
+
if (p.isCancel(grantPermissions) || !grantPermissions) {
|
|
199
|
+
p.log.info('Skipped. Create .claude/settings.json manually if needed.');
|
|
200
|
+
}
|
|
201
|
+
else {
|
|
202
|
+
const claudeSettingsDir = resolve(cwd, '.claude');
|
|
203
|
+
const claudeSettingsPath = resolve(claudeSettingsDir, 'settings.json');
|
|
204
|
+
try {
|
|
205
|
+
mkdirSync(claudeSettingsDir, { recursive: true });
|
|
206
|
+
let settings = {};
|
|
207
|
+
if (existsSync(claudeSettingsPath)) {
|
|
208
|
+
settings = JSON.parse(readFileSync(claudeSettingsPath, 'utf-8'));
|
|
209
|
+
}
|
|
210
|
+
const permissions = (settings.permissions ?? {});
|
|
211
|
+
const existing = Array.isArray(permissions.allow)
|
|
212
|
+
? permissions.allow
|
|
213
|
+
: [];
|
|
214
|
+
const required = [
|
|
215
|
+
'WebFetch',
|
|
216
|
+
'WebSearch',
|
|
217
|
+
'Read',
|
|
218
|
+
'Edit',
|
|
219
|
+
'Write',
|
|
220
|
+
'mcp__playwright__*',
|
|
221
|
+
];
|
|
222
|
+
const merged = [...new Set([...existing, ...required])];
|
|
223
|
+
permissions.allow = merged;
|
|
224
|
+
settings.permissions = permissions;
|
|
225
|
+
writeFileSync(claudeSettingsPath, JSON.stringify(settings, null, 2) + '\n', 'utf-8');
|
|
226
|
+
p.log.success('Tool permissions configured');
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
p.log.warning(`Could not write ${claudeSettingsPath}: ${err.message}`);
|
|
230
|
+
p.log.info('Create .claude/settings.json manually with permissions.allow array');
|
|
231
|
+
}
|
|
232
|
+
// Trust project directory in ~/.claude.json
|
|
233
|
+
const claudeConfigPath = resolve(homedir(), '.claude.json');
|
|
234
|
+
try {
|
|
235
|
+
if (existsSync(claudeConfigPath)) {
|
|
236
|
+
const claudeCfg = JSON.parse(readFileSync(claudeConfigPath, 'utf-8'));
|
|
237
|
+
const projects = (claudeCfg.projects ?? {});
|
|
238
|
+
if (!projects[cwd])
|
|
239
|
+
projects[cwd] = {};
|
|
240
|
+
projects[cwd].hasTrustDialogAccepted = true;
|
|
241
|
+
claudeCfg.projects = projects;
|
|
242
|
+
writeFileSync(claudeConfigPath, JSON.stringify(claudeCfg, null, 2) + '\n', 'utf-8');
|
|
243
|
+
p.log.success('Project directory trusted');
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
catch {
|
|
247
|
+
// Non-critical, trust prompt will appear on first run
|
|
248
|
+
}
|
|
249
|
+
} // end grant permissions
|
|
250
|
+
} // end claude cli block
|
|
251
|
+
// ── Shared browser (optional) ──────────────────────────────────
|
|
252
|
+
p.log.info('Claude can control a real Chrome browser to browse websites, ' +
|
|
253
|
+
'fill forms, take screenshots, and interact with web apps. ' +
|
|
254
|
+
'Everything runs on localhost only, nothing is exposed publicly. ' +
|
|
255
|
+
'You can connect to watch the browser via a secure SSH tunnel.');
|
|
256
|
+
const wantBrowser = await p.confirm({
|
|
257
|
+
message: 'Enable browser control for Claude?',
|
|
258
|
+
initialValue: false,
|
|
259
|
+
});
|
|
260
|
+
if (!p.isCancel(wantBrowser) && wantBrowser) {
|
|
261
|
+
const isLinux = process.platform === 'linux';
|
|
262
|
+
if (!isLinux) {
|
|
263
|
+
p.log.warning('Automated browser setup is available on Linux only. ' +
|
|
264
|
+
'On macOS/Windows: start Chrome with --remote-debugging-port=9222 manually, ' +
|
|
265
|
+
'then run: claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "http://localhost:9222"');
|
|
266
|
+
}
|
|
267
|
+
else {
|
|
268
|
+
// ── Check if already running ─────────────────────────────
|
|
269
|
+
const cdpUrl = 'http://localhost:9222';
|
|
270
|
+
const alreadyRunning = run(`curl -s '${cdpUrl}/json/version'`);
|
|
271
|
+
const mcpConfigured = run('claude mcp list 2>/dev/null').output.includes('playwright');
|
|
272
|
+
if (alreadyRunning.ok && alreadyRunning.output.includes('Browser') && mcpConfigured) {
|
|
273
|
+
p.log.success('Chrome already running (localhost:9222)');
|
|
274
|
+
p.log.success('Claude already connected to Chrome');
|
|
275
|
+
p.log.info('View browser (SSH tunnel):\n' +
|
|
276
|
+
` ssh -L 6090:127.0.0.1:6090 ${process.env.USER || 'root'}@<server-ip>\n` +
|
|
277
|
+
' Then open: http://localhost:6090/vnc.html');
|
|
278
|
+
}
|
|
279
|
+
else {
|
|
280
|
+
// ── Chrome ───────────────────────────────────────────────
|
|
281
|
+
let chromeFound = false;
|
|
282
|
+
for (const bin of [
|
|
283
|
+
'chromium',
|
|
284
|
+
'chromium-browser',
|
|
285
|
+
'google-chrome',
|
|
286
|
+
'google-chrome-stable',
|
|
287
|
+
]) {
|
|
288
|
+
if (run(`which ${bin}`).ok) {
|
|
289
|
+
p.log.success(`Chrome found: ${bin}`);
|
|
290
|
+
chromeFound = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!chromeFound) {
|
|
295
|
+
const installChrome = await p.confirm({
|
|
296
|
+
message: 'Chrome/Chromium not found. Install Chromium? (apt install chromium)',
|
|
297
|
+
initialValue: true,
|
|
298
|
+
});
|
|
299
|
+
if (!p.isCancel(installChrome) && installChrome) {
|
|
300
|
+
p.log.step('Installing Chromium...');
|
|
301
|
+
if (runLive('apt-get update && apt-get install -y chromium')) {
|
|
302
|
+
p.log.success('Chromium installed');
|
|
303
|
+
chromeFound = true;
|
|
304
|
+
}
|
|
305
|
+
else {
|
|
306
|
+
p.log.warning('Chromium install failed. Run manually: apt install -y chromium');
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
// ── VNC (optional, for human viewing) ────────────────────
|
|
311
|
+
let vncInstalled = false;
|
|
312
|
+
if (chromeFound) {
|
|
313
|
+
p.log.info('noVNC lets you watch and interact with the browser Claude is controlling. ' +
|
|
314
|
+
'It runs on localhost:6090 only, accessible via SSH tunnel. Nothing public.');
|
|
315
|
+
const wantVnc = await p.confirm({
|
|
316
|
+
message: 'Install noVNC? (lets you view the browser via SSH tunnel)',
|
|
317
|
+
initialValue: true,
|
|
318
|
+
});
|
|
319
|
+
if (!p.isCancel(wantVnc) && wantVnc) {
|
|
320
|
+
const vncDeps = ['xvfb', 'x11vnc', 'novnc'];
|
|
321
|
+
const missing = vncDeps.filter((d) => !run(`dpkg -s ${d} 2>/dev/null`).ok);
|
|
322
|
+
if (missing.length > 0) {
|
|
323
|
+
p.log.step(`Installing ${missing.join(', ')}...`);
|
|
324
|
+
if (runLive(`apt-get install -y ${missing.join(' ')}`)) {
|
|
325
|
+
p.log.success('noVNC dependencies installed');
|
|
326
|
+
vncInstalled = true;
|
|
327
|
+
}
|
|
328
|
+
else {
|
|
329
|
+
p.log.warning(`Some packages failed. Run manually: apt install -y ${missing.join(' ')}`);
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
p.log.success('noVNC dependencies already installed');
|
|
334
|
+
vncInstalled = true;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
// ── Start browser ────────────────────────────────────────
|
|
339
|
+
if (chromeFound) {
|
|
340
|
+
p.log.step('Starting Chrome' + (vncInstalled ? ' + noVNC' : '') + '...');
|
|
341
|
+
const scriptPath = resolve(cwd, 'scripts/start-browser.sh');
|
|
342
|
+
if (!runLive(`bash "${scriptPath}"`)) {
|
|
343
|
+
p.log.warning('You can start manually: bash scripts/start-browser.sh');
|
|
344
|
+
}
|
|
345
|
+
// Verify CDP
|
|
346
|
+
const cdpUrl = 'http://localhost:9222';
|
|
347
|
+
const cdpCheck = run(`curl -s '${cdpUrl}/json/version'`);
|
|
348
|
+
if (cdpCheck.ok && cdpCheck.output.includes('Browser')) {
|
|
349
|
+
p.log.success('Chrome running (localhost:9222, not public)');
|
|
350
|
+
// Connect Claude to Chrome via CDP
|
|
351
|
+
const sc = p.spinner();
|
|
352
|
+
sc.start('Connecting Claude to Chrome');
|
|
353
|
+
run('claude mcp remove playwright');
|
|
354
|
+
const addResult = run(`claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "${cdpUrl}"`);
|
|
355
|
+
if (addResult.ok ||
|
|
356
|
+
addResult.output.includes('already exists')) {
|
|
357
|
+
sc.stop('Claude connected to Chrome');
|
|
358
|
+
}
|
|
359
|
+
else {
|
|
360
|
+
sc.stop('Connection failed');
|
|
361
|
+
p.log.warning('Run manually: claude mcp add playwright -- npx @playwright/mcp@latest --cdp-endpoint "http://localhost:9222"');
|
|
362
|
+
}
|
|
363
|
+
if (vncInstalled) {
|
|
364
|
+
p.log.info('Watch the browser (localhost only, via SSH tunnel):\n' +
|
|
365
|
+
` ssh -L 6090:127.0.0.1:6090 ${process.env.USER || 'root'}@<server-ip>\n` +
|
|
366
|
+
' Then open: http://localhost:6090/vnc.html');
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
else {
|
|
370
|
+
p.log.warning('Chrome not reachable. Start manually: bash scripts/start-browser.sh');
|
|
371
|
+
}
|
|
372
|
+
} // end else (not already running)
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
// ── Storage ──────────────────────────────────────────────────
|
|
377
|
+
run('mkdir -p storage/auth storage/messages storage/queue storage/prompts storage/media storage/outbox');
|
|
378
|
+
run('mkdir -p storage/memory/buckets storage/memory/persons storage/memory/chats');
|
|
379
|
+
p.log.success('Storage directories ready');
|
|
380
|
+
// ── Import existing knowledge ────────────────────────────────
|
|
381
|
+
p.log.info('The bot has a long-term memory system organized into buckets (projects, people, topics). ' +
|
|
382
|
+
'If you have an existing knowledge folder (notes, project files, docs), ' +
|
|
383
|
+
'you can import it now. Claude will read the folder and distill it into ' +
|
|
384
|
+
'organized memory buckets the bot can reference during conversations.');
|
|
385
|
+
const wantImport = await p.confirm({
|
|
386
|
+
message: 'Import an existing knowledge folder?',
|
|
387
|
+
initialValue: false,
|
|
388
|
+
});
|
|
389
|
+
if (!p.isCancel(wantImport) && wantImport) {
|
|
390
|
+
const importPath = await p.text({
|
|
391
|
+
message: 'Path to the folder',
|
|
392
|
+
placeholder: '/home/user/my-notes',
|
|
393
|
+
validate: (v) => {
|
|
394
|
+
if (!v || !v.trim())
|
|
395
|
+
return 'Required';
|
|
396
|
+
const { existsSync: ex } = require('fs');
|
|
397
|
+
if (!ex(v.trim()))
|
|
398
|
+
return 'Folder not found';
|
|
399
|
+
return undefined;
|
|
400
|
+
},
|
|
401
|
+
});
|
|
402
|
+
if (!p.isCancel(importPath)) {
|
|
403
|
+
p.log.info('You can customize what gets imported by editing config/import-instructions.md before running. ' +
|
|
404
|
+
'See config/import-instructions.HOWTO.md for details.');
|
|
405
|
+
const importNow = await p.confirm({
|
|
406
|
+
message: 'Run import now? (can take a few minutes)',
|
|
407
|
+
initialValue: true,
|
|
408
|
+
});
|
|
409
|
+
if (!p.isCancel(importNow) && importNow) {
|
|
410
|
+
const si = p.spinner();
|
|
411
|
+
si.start('Importing knowledge (this may take several minutes)...');
|
|
412
|
+
try {
|
|
413
|
+
const { runImport } = await import('../memory/importer.js');
|
|
414
|
+
await runImport(importPath);
|
|
415
|
+
si.stop('Knowledge imported');
|
|
416
|
+
}
|
|
417
|
+
catch (err) {
|
|
418
|
+
si.stop('Import failed');
|
|
419
|
+
p.log.warning(`Import error: ${err.message}\nYou can retry later: npx @c4t4/heyamigo import ${importPath}`);
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else {
|
|
423
|
+
p.log.info(`Run later: npx @c4t4/heyamigo import ${importPath}`);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// ── WhatsApp pairing ──────────────────────────────────────────
|
|
428
|
+
const credsPath = resolve(cwd, 'storage/auth/creds.json');
|
|
429
|
+
let shouldPair = false;
|
|
430
|
+
if (existsSync(credsPath)) {
|
|
431
|
+
const repairChoice = await p.select({
|
|
432
|
+
message: 'Found existing WhatsApp auth data. What would you like to do?',
|
|
433
|
+
options: [
|
|
434
|
+
{ value: 'skip', label: 'Use existing auth', hint: 'if the bot was working before' },
|
|
435
|
+
{ value: 'repaid', label: 'Fresh pairing (new QR scan)', hint: 'if auth is stale or from another machine' },
|
|
436
|
+
],
|
|
437
|
+
initialValue: 'skip',
|
|
438
|
+
});
|
|
439
|
+
if (!p.isCancel(repairChoice) && repairChoice === 'repaid') {
|
|
440
|
+
run(`rm -rf "${resolve(cwd, 'storage/auth')}"/*`);
|
|
441
|
+
shouldPair = true;
|
|
442
|
+
}
|
|
443
|
+
else {
|
|
444
|
+
p.log.success('WhatsApp already paired');
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
else {
|
|
448
|
+
shouldPair = true;
|
|
449
|
+
}
|
|
450
|
+
if (shouldPair) {
|
|
451
|
+
p.log.step('Time to connect your WhatsApp. A QR code and pairing code will appear. ' +
|
|
452
|
+
'Use either one to link your device.');
|
|
453
|
+
const readyToPair = await p.confirm({
|
|
454
|
+
message: 'Ready to pair WhatsApp?',
|
|
455
|
+
initialValue: true,
|
|
456
|
+
});
|
|
457
|
+
if (!p.isCancel(readyToPair) && readyToPair) {
|
|
458
|
+
mkdirSync(resolve(cwd, 'storage/auth'), { recursive: true });
|
|
459
|
+
const { default: makeWASocket, useMultiFileAuthState, fetchLatestWaWebVersion, Browsers } = await import('baileys');
|
|
460
|
+
const QRCode = await import('qrcode');
|
|
461
|
+
const pino = await import('pino');
|
|
462
|
+
// Silence Baileys logs during wizard — we control what the user sees
|
|
463
|
+
const silentLogger = pino.default({ level: 'silent' });
|
|
464
|
+
// ownerNum already set from config earlier
|
|
465
|
+
const authDir = resolve(cwd, 'storage/auth');
|
|
466
|
+
const { version } = await fetchLatestWaWebVersion({});
|
|
467
|
+
let pairingCodeShown = false;
|
|
468
|
+
let pairedNumber = '';
|
|
469
|
+
const pair = async () => {
|
|
470
|
+
for (let attempt = 1; attempt <= 5; attempt++) {
|
|
471
|
+
const { state, saveCreds } = await useMultiFileAuthState(authDir);
|
|
472
|
+
const sock = makeWASocket({
|
|
473
|
+
auth: state,
|
|
474
|
+
version,
|
|
475
|
+
browser: Browsers.macOS('WhatsApp Bot'),
|
|
476
|
+
logger: silentLogger,
|
|
477
|
+
});
|
|
478
|
+
sock.ev.on('creds.update', saveCreds);
|
|
479
|
+
const result = await new Promise((done) => {
|
|
480
|
+
const timeout = setTimeout(() => {
|
|
481
|
+
sock.end(undefined);
|
|
482
|
+
done('retry');
|
|
483
|
+
}, 30000);
|
|
484
|
+
sock.ev.on('connection.update', async (update) => {
|
|
485
|
+
const { connection, qr } = update;
|
|
486
|
+
if (qr) {
|
|
487
|
+
const ascii = await QRCode.toString(qr, {
|
|
488
|
+
type: 'utf8',
|
|
489
|
+
margin: 2,
|
|
490
|
+
});
|
|
491
|
+
console.log('');
|
|
492
|
+
console.log(ascii);
|
|
493
|
+
if (!pairingCodeShown && ownerNum) {
|
|
494
|
+
pairingCodeShown = true;
|
|
495
|
+
try {
|
|
496
|
+
const code = await sock.requestPairingCode(ownerNum);
|
|
497
|
+
console.log(` Or enter pairing code: ${code}`);
|
|
498
|
+
console.log(' WhatsApp > Linked Devices > Link with phone number\n');
|
|
499
|
+
}
|
|
500
|
+
catch { }
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (connection === 'open') {
|
|
504
|
+
clearTimeout(timeout);
|
|
505
|
+
// Extract number from connected account
|
|
506
|
+
const userId = sock.user?.id ?? '';
|
|
507
|
+
const num = userId.split(':')[0]?.split('@')[0];
|
|
508
|
+
if (num)
|
|
509
|
+
pairedNumber = num;
|
|
510
|
+
setTimeout(() => {
|
|
511
|
+
sock.end(undefined);
|
|
512
|
+
done('open');
|
|
513
|
+
}, 5000);
|
|
514
|
+
}
|
|
515
|
+
if (connection === 'close') {
|
|
516
|
+
clearTimeout(timeout);
|
|
517
|
+
sock.end(undefined);
|
|
518
|
+
done('retry');
|
|
519
|
+
}
|
|
520
|
+
});
|
|
521
|
+
});
|
|
522
|
+
if (result === 'open')
|
|
523
|
+
return true;
|
|
524
|
+
await new Promise((r) => setTimeout(r, 2000));
|
|
525
|
+
}
|
|
526
|
+
return false;
|
|
527
|
+
};
|
|
528
|
+
let success = false;
|
|
529
|
+
for (let pairAttempt = 1; pairAttempt <= 3; pairAttempt++) {
|
|
530
|
+
const sp = p.spinner();
|
|
531
|
+
sp.start('Waiting for WhatsApp pairing...');
|
|
532
|
+
success = await pair();
|
|
533
|
+
if (success) {
|
|
534
|
+
sp.stop('WhatsApp paired successfully');
|
|
535
|
+
break;
|
|
536
|
+
}
|
|
537
|
+
if (existsSync(credsPath)) {
|
|
538
|
+
sp.stop('WhatsApp credentials saved. Connection will complete on start.');
|
|
539
|
+
success = true;
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
sp.stop(`Pairing attempt ${pairAttempt}/3 failed`);
|
|
543
|
+
if (pairAttempt < 3) {
|
|
544
|
+
const retry = await p.confirm({
|
|
545
|
+
message: 'Try pairing again?',
|
|
546
|
+
initialValue: true,
|
|
547
|
+
});
|
|
548
|
+
if (p.isCancel(retry) || !retry)
|
|
549
|
+
break;
|
|
550
|
+
pairingCodeShown = false; // allow showing pairing code again
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
if (!success && !existsSync(credsPath)) {
|
|
554
|
+
p.cancel('WhatsApp pairing is required. Re-run: npx @c4t4/heyamigo setup');
|
|
555
|
+
process.exit(1);
|
|
556
|
+
}
|
|
557
|
+
// Auto-set owner number from paired account
|
|
558
|
+
if (pairedNumber) {
|
|
559
|
+
setConfigOwnerNumber(configPath, pairedNumber);
|
|
560
|
+
ownerNum = pairedNumber;
|
|
561
|
+
p.log.success(`Owner number set: ${pairedNumber} (from WhatsApp)`);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
else {
|
|
565
|
+
p.log.info('Skipped. Pair later: heyamigo pair');
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
// ── Name your amigo ───────────────────────────────────────────
|
|
569
|
+
p.log.info('Give your amigo a name. People mention this name in a message to get a reply. ' +
|
|
570
|
+
'You can add multiple names separated by commas.');
|
|
571
|
+
const nameInput = await p.text({
|
|
572
|
+
message: 'What should your amigo be called?',
|
|
573
|
+
placeholder: 'amigo',
|
|
574
|
+
initialValue: 'amigo',
|
|
575
|
+
});
|
|
576
|
+
if (!p.isCancel(nameInput)) {
|
|
577
|
+
const names = nameInput
|
|
578
|
+
.split(',')
|
|
579
|
+
.map((s) => s.trim().toLowerCase())
|
|
580
|
+
.filter(Boolean);
|
|
581
|
+
if (names.length > 0) {
|
|
582
|
+
// Always include "heyamigo" as a hidden alias
|
|
583
|
+
const aliases = [...new Set([...names, 'heyamigo'])];
|
|
584
|
+
const cfgPath = resolve(cwd, 'config/config.json');
|
|
585
|
+
if (existsSync(cfgPath)) {
|
|
586
|
+
let cfg = readFileSync(cfgPath, 'utf-8');
|
|
587
|
+
cfg = cfg.replace(/"aliases":\s*\[.*?\]/, `"aliases": ${JSON.stringify(aliases)}`);
|
|
588
|
+
writeFileSync(cfgPath, cfg);
|
|
589
|
+
p.log.success(`Your amigo responds to: ${names.join(', ')}`);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
// ── Access rules onboarding ───────────────────────────────────
|
|
594
|
+
p.log.info('How groups work:\n\n' +
|
|
595
|
+
' 1. Send a message in any WhatsApp group where the bot is.\n' +
|
|
596
|
+
' The bot auto-discovers the group and adds it to config/access.json.\n\n' +
|
|
597
|
+
' 2. New groups start with mode: "off" (bot stays silent).\n' +
|
|
598
|
+
' To activate: edit config/access.json, change mode to "active".\n\n' +
|
|
599
|
+
' 3. Set allowedSenders to "*" (everyone) or specific numbers.\n\n' +
|
|
600
|
+
' 4. Once active, mention the bot\'s name in a message to get a reply.\n\n' +
|
|
601
|
+
'DMs work the same way — add numbers to dms.allowed in access.json.');
|
|
602
|
+
// Auto-add owner as admin if we have the number
|
|
603
|
+
if (ownerNum) {
|
|
604
|
+
const accessCfgPath = resolve(cwd, 'config/access.json');
|
|
605
|
+
try {
|
|
606
|
+
const access = JSON.parse(readFileSync(accessCfgPath, 'utf-8'));
|
|
607
|
+
const users = access.users ?? {};
|
|
608
|
+
if (!users[ownerNum]) {
|
|
609
|
+
users[ownerNum] = { role: 'admin', name: 'Owner' };
|
|
610
|
+
access.users = users;
|
|
611
|
+
writeFileSync(accessCfgPath, JSON.stringify(access, null, 2) + '\n', 'utf-8');
|
|
612
|
+
p.log.success(`Added ${ownerNum} as admin in access.json`);
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
p.log.info(`${ownerNum} already configured as ${users[ownerNum].role}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
catch { }
|
|
619
|
+
}
|
|
620
|
+
// ── Claude model ─────────────────────────────────────────────
|
|
621
|
+
const model = await p.select({
|
|
622
|
+
message: 'Choose a Claude model',
|
|
623
|
+
options: [
|
|
624
|
+
{
|
|
625
|
+
value: 'claude-opus-4-6',
|
|
626
|
+
label: 'Opus',
|
|
627
|
+
hint: 'highest quality, recommended (default)',
|
|
628
|
+
},
|
|
629
|
+
{
|
|
630
|
+
value: 'claude-sonnet-4-6',
|
|
631
|
+
label: 'Sonnet',
|
|
632
|
+
hint: 'faster, lower cost',
|
|
633
|
+
},
|
|
634
|
+
],
|
|
635
|
+
initialValue: 'claude-opus-4-6',
|
|
636
|
+
});
|
|
637
|
+
if (!p.isCancel(model)) {
|
|
638
|
+
const configPath = resolve(cwd, 'config/config.json');
|
|
639
|
+
if (existsSync(configPath)) {
|
|
640
|
+
let cfg = readFileSync(configPath, 'utf-8');
|
|
641
|
+
cfg = cfg.replace(/"model":\s*"[^"]*"/, `"model": "${model}"`);
|
|
642
|
+
writeFileSync(configPath, cfg);
|
|
643
|
+
const label = model === 'claude-sonnet-4-6'
|
|
644
|
+
? 'Sonnet'
|
|
645
|
+
: model === 'claude-opus-4-6'
|
|
646
|
+
? 'Opus'
|
|
647
|
+
: 'Haiku';
|
|
648
|
+
p.log.success(`Model: ${label}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
// ── Personality ──────────────────────────────────────────────
|
|
652
|
+
const personalities = ['sharp', 'casual', 'professional'];
|
|
653
|
+
const personality = await p.select({
|
|
654
|
+
message: 'Choose a personality',
|
|
655
|
+
options: personalities.map((name) => ({
|
|
656
|
+
value: name,
|
|
657
|
+
label: name.charAt(0).toUpperCase() + name.slice(1),
|
|
658
|
+
hint: name === 'sharp'
|
|
659
|
+
? 'direct, specific, no marketing-speak (default)'
|
|
660
|
+
: name === 'casual'
|
|
661
|
+
? 'warm, relaxed, friend-over-coffee'
|
|
662
|
+
: 'clear, efficient, business-appropriate',
|
|
663
|
+
})),
|
|
664
|
+
initialValue: 'sharp',
|
|
665
|
+
});
|
|
666
|
+
if (!p.isCancel(personality)) {
|
|
667
|
+
const configPath = resolve(cwd, 'config/config.json');
|
|
668
|
+
if (existsSync(configPath)) {
|
|
669
|
+
let cfg = readFileSync(configPath, 'utf-8');
|
|
670
|
+
cfg = cfg.replace(/"personalityFile":\s*"[^"]*"/, `"personalityFile": "./config/personalities/${personality}.md"`);
|
|
671
|
+
writeFileSync(configPath, cfg);
|
|
672
|
+
p.log.success(`Personality: ${personality}`);
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
// ── Done ─────────────────────────────────────────────────────
|
|
676
|
+
p.note([
|
|
677
|
+
'Start the bot:',
|
|
678
|
+
' npx @c4t4/heyamigo start',
|
|
679
|
+
'',
|
|
680
|
+
'Pair with WhatsApp:',
|
|
681
|
+
' Scan the QR code or enter the pairing',
|
|
682
|
+
' code shown in the terminal (npx @c4t4/heyamigo logs).',
|
|
683
|
+
'',
|
|
684
|
+
'Activate a group:',
|
|
685
|
+
' 1. Send a message in any group — bot discovers it',
|
|
686
|
+
' 2. Edit config/access.json — set mode to "active"',
|
|
687
|
+
' 3. Mention the bot\'s name to get a reply',
|
|
688
|
+
'',
|
|
689
|
+
'Check logs:',
|
|
690
|
+
' npx @c4t4/heyamigo logs',
|
|
691
|
+
'',
|
|
692
|
+
'Other commands:',
|
|
693
|
+
' npx @c4t4/heyamigo stop / restart / status',
|
|
694
|
+
' npx @c4t4/heyamigo import <path>',
|
|
695
|
+
'',
|
|
696
|
+
'Configuration:',
|
|
697
|
+
' config/config.json — triggers, model, timeouts',
|
|
698
|
+
' config/access.json — groups, DMs, roles',
|
|
699
|
+
].join('\n'), 'Setup complete!');
|
|
700
|
+
p.outro('Happy chatting!');
|
|
701
|
+
}
|