airchat 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/dist/index.d.ts +2 -0
- package/dist/index.js +645 -0
- package/package.json +29 -0
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,645 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
4
|
+
if (k2 === undefined) k2 = k;
|
|
5
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
6
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
7
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
8
|
+
}
|
|
9
|
+
Object.defineProperty(o, k2, desc);
|
|
10
|
+
}) : (function(o, m, k, k2) {
|
|
11
|
+
if (k2 === undefined) k2 = k;
|
|
12
|
+
o[k2] = m[k];
|
|
13
|
+
}));
|
|
14
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
15
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
16
|
+
}) : function(o, v) {
|
|
17
|
+
o["default"] = v;
|
|
18
|
+
});
|
|
19
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
20
|
+
var ownKeys = function(o) {
|
|
21
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
22
|
+
var ar = [];
|
|
23
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
24
|
+
return ar;
|
|
25
|
+
};
|
|
26
|
+
return ownKeys(o);
|
|
27
|
+
};
|
|
28
|
+
return function (mod) {
|
|
29
|
+
if (mod && mod.__esModule) return mod;
|
|
30
|
+
var result = {};
|
|
31
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
32
|
+
__setModuleDefault(result, mod);
|
|
33
|
+
return result;
|
|
34
|
+
};
|
|
35
|
+
})();
|
|
36
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
37
|
+
const readline = __importStar(require("node:readline/promises"));
|
|
38
|
+
const node_process_1 = require("node:process");
|
|
39
|
+
const fs = __importStar(require("node:fs"));
|
|
40
|
+
const path = __importStar(require("node:path"));
|
|
41
|
+
const os = __importStar(require("node:os"));
|
|
42
|
+
const crypto = __importStar(require("node:crypto"));
|
|
43
|
+
const node_child_process_1 = require("node:child_process");
|
|
44
|
+
const supabase_js_1 = require("@supabase/supabase-js");
|
|
45
|
+
// ── Helpers ────────────────────────────────────────────────────────────────────
|
|
46
|
+
const GREEN = '\x1b[32m';
|
|
47
|
+
const YELLOW = '\x1b[33m';
|
|
48
|
+
const RED = '\x1b[31m';
|
|
49
|
+
const DIM = '\x1b[2m';
|
|
50
|
+
const BOLD = '\x1b[1m';
|
|
51
|
+
const RESET = '\x1b[0m';
|
|
52
|
+
function ok(msg) { console.log(` ${GREEN}✓${RESET} ${msg}`); }
|
|
53
|
+
function warn(msg) { console.log(` ${YELLOW}!${RESET} ${msg}`); }
|
|
54
|
+
function fail(msg) { console.log(` ${RED}✗${RESET} ${msg}`); }
|
|
55
|
+
function dim(msg) { return `${DIM}${msg}${RESET}`; }
|
|
56
|
+
function bold(msg) { return `${BOLD}${msg}${RESET}`; }
|
|
57
|
+
function readExistingConfig() {
|
|
58
|
+
const configPath = path.join(os.homedir(), '.airchat', 'config');
|
|
59
|
+
const config = {};
|
|
60
|
+
try {
|
|
61
|
+
const lines = fs.readFileSync(configPath, 'utf-8').split('\n');
|
|
62
|
+
for (const line of lines) {
|
|
63
|
+
const trimmed = line.trim();
|
|
64
|
+
if (!trimmed || trimmed.startsWith('#'))
|
|
65
|
+
continue;
|
|
66
|
+
const eq = trimmed.indexOf('=');
|
|
67
|
+
if (eq === -1)
|
|
68
|
+
continue;
|
|
69
|
+
config[trimmed.slice(0, eq).trim()] = trimmed.slice(eq + 1).trim();
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
catch { }
|
|
73
|
+
return config;
|
|
74
|
+
}
|
|
75
|
+
function detectNodePath() {
|
|
76
|
+
return process.execPath;
|
|
77
|
+
}
|
|
78
|
+
function detectRepoDir() {
|
|
79
|
+
// Check if we're inside the airchat repo
|
|
80
|
+
let dir = process.cwd();
|
|
81
|
+
for (let i = 0; i < 10; i++) {
|
|
82
|
+
if (fs.existsSync(path.join(dir, 'packages', 'mcp-server', 'src', 'index.ts'))) {
|
|
83
|
+
return dir;
|
|
84
|
+
}
|
|
85
|
+
const parent = path.dirname(dir);
|
|
86
|
+
if (parent === dir)
|
|
87
|
+
break;
|
|
88
|
+
dir = parent;
|
|
89
|
+
}
|
|
90
|
+
return null;
|
|
91
|
+
}
|
|
92
|
+
function machineNameValid(name) {
|
|
93
|
+
return /^[a-z0-9][a-z0-9-]{1,99}$/.test(name);
|
|
94
|
+
}
|
|
95
|
+
// ── Prompt helpers ─────────────────────────────────────────────────────────────
|
|
96
|
+
async function ask(rl, question, defaultVal) {
|
|
97
|
+
const suffix = defaultVal ? ` ${dim(`(${defaultVal})`)}` : '';
|
|
98
|
+
const answer = await rl.question(` ${question}${suffix}: `);
|
|
99
|
+
return answer.trim() || defaultVal || '';
|
|
100
|
+
}
|
|
101
|
+
async function askSecret(rl, question) {
|
|
102
|
+
// readline doesn't support hiding input natively, but we can use a workaround
|
|
103
|
+
const answer = await rl.question(` ${question}: `);
|
|
104
|
+
return answer.trim();
|
|
105
|
+
}
|
|
106
|
+
async function askChoice(rl, question, choices) {
|
|
107
|
+
console.log(` ${question}`);
|
|
108
|
+
for (let i = 0; i < choices.length; i++) {
|
|
109
|
+
console.log(` ${BOLD}${i + 1}${RESET}) ${choices[i].label}`);
|
|
110
|
+
}
|
|
111
|
+
const answer = await rl.question(` Choice (1-${choices.length}): `);
|
|
112
|
+
const idx = parseInt(answer.trim(), 10) - 1;
|
|
113
|
+
if (idx >= 0 && idx < choices.length)
|
|
114
|
+
return choices[idx].key;
|
|
115
|
+
return choices[0].key;
|
|
116
|
+
}
|
|
117
|
+
async function askYesNo(rl, question, defaultYes = true) {
|
|
118
|
+
const hint = defaultYes ? 'Y/n' : 'y/N';
|
|
119
|
+
const answer = await rl.question(` ${question} (${hint}): `);
|
|
120
|
+
const val = answer.trim().toLowerCase();
|
|
121
|
+
if (val === '')
|
|
122
|
+
return defaultYes;
|
|
123
|
+
return val === 'y' || val === 'yes';
|
|
124
|
+
}
|
|
125
|
+
// ── Setup steps ────────────────────────────────────────────────────────────────
|
|
126
|
+
async function collectConfig(rl, reconfigure) {
|
|
127
|
+
const existing = reconfigure ? readExistingConfig() : {};
|
|
128
|
+
const detectedRepo = detectRepoDir();
|
|
129
|
+
console.log('');
|
|
130
|
+
console.log(` ${BOLD}Welcome to AirChat setup!${RESET}`);
|
|
131
|
+
console.log(` ${dim('This will configure your machine to connect to AirChat.')}`);
|
|
132
|
+
console.log('');
|
|
133
|
+
// Database provider
|
|
134
|
+
const dbProvider = await askChoice(rl, 'Database provider:', [
|
|
135
|
+
{ key: 'supabase', label: 'Supabase (hosted, free tier available)' },
|
|
136
|
+
{ key: 'self-hosted', label: 'Self-hosted PostgreSQL' },
|
|
137
|
+
{ key: 'docker', label: 'Docker (spins up Postgres for you)' },
|
|
138
|
+
]);
|
|
139
|
+
console.log('');
|
|
140
|
+
let supabaseUrl;
|
|
141
|
+
let supabaseAnonKey;
|
|
142
|
+
let supabaseServiceRoleKey;
|
|
143
|
+
let databaseUrl;
|
|
144
|
+
if (dbProvider === 'supabase') {
|
|
145
|
+
supabaseUrl = await ask(rl, 'Supabase Project URL', existing.SUPABASE_URL);
|
|
146
|
+
supabaseAnonKey = await ask(rl, 'Supabase Anon Key', existing.SUPABASE_ANON_KEY);
|
|
147
|
+
supabaseServiceRoleKey = await askSecret(rl, 'Supabase Service Role Key (used during setup only, not stored)');
|
|
148
|
+
console.log('');
|
|
149
|
+
}
|
|
150
|
+
else if (dbProvider === 'self-hosted') {
|
|
151
|
+
databaseUrl = await ask(rl, 'PostgreSQL connection string (postgres://user:pass@host:5432/dbname)');
|
|
152
|
+
console.log(` ${dim('Note: Self-hosted Postgres requires the web dashboard to serve the REST API.')}`);
|
|
153
|
+
console.log('');
|
|
154
|
+
}
|
|
155
|
+
else if (dbProvider === 'docker') {
|
|
156
|
+
console.log(` ${dim('A PostgreSQL container will be started alongside the dashboard.')}`);
|
|
157
|
+
console.log('');
|
|
158
|
+
}
|
|
159
|
+
// Machine name
|
|
160
|
+
const defaultMachine = existing.MACHINE_NAME || os.hostname().toLowerCase().replace(/[^a-z0-9-]/g, '-').replace(/-+/g, '-').replace(/^-|-$/g, '').slice(0, 50) || 'laptop';
|
|
161
|
+
let machineName = '';
|
|
162
|
+
while (!machineName) {
|
|
163
|
+
machineName = await ask(rl, 'Machine name (e.g. laptop, server, nas)', defaultMachine);
|
|
164
|
+
if (!machineNameValid(machineName)) {
|
|
165
|
+
warn('Must be lowercase alphanumeric with hyphens, 2-100 chars.');
|
|
166
|
+
machineName = '';
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
console.log('');
|
|
170
|
+
// Dashboard
|
|
171
|
+
const deployDashboard = await askYesNo(rl, 'Deploy web dashboard?', true);
|
|
172
|
+
let dashboardPort = 3003;
|
|
173
|
+
if (deployDashboard) {
|
|
174
|
+
const portStr = await ask(rl, 'Dashboard port', '3003');
|
|
175
|
+
dashboardPort = parseInt(portStr, 10) || 3003;
|
|
176
|
+
}
|
|
177
|
+
console.log('');
|
|
178
|
+
// Repo location
|
|
179
|
+
let airchatDir = '';
|
|
180
|
+
if (detectedRepo) {
|
|
181
|
+
console.log(` ${dim(`Found airchat repo at: ${detectedRepo}`)}`);
|
|
182
|
+
const useDetected = await askYesNo(rl, 'Use this location?', true);
|
|
183
|
+
if (useDetected) {
|
|
184
|
+
airchatDir = detectedRepo;
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
if (!airchatDir) {
|
|
188
|
+
const shouldClone = await askYesNo(rl, 'Clone the airchat repo?', true);
|
|
189
|
+
if (shouldClone) {
|
|
190
|
+
const cloneDir = await ask(rl, 'Clone to', path.join(os.homedir(), 'projects', 'airchat'));
|
|
191
|
+
airchatDir = cloneDir;
|
|
192
|
+
}
|
|
193
|
+
else {
|
|
194
|
+
airchatDir = await ask(rl, 'Path to existing airchat repo');
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
console.log('');
|
|
198
|
+
const nodePath = detectNodePath();
|
|
199
|
+
const webUrl = deployDashboard ? `http://localhost:${dashboardPort}` : existing.AIRCHAT_WEB_URL;
|
|
200
|
+
return {
|
|
201
|
+
dbProvider,
|
|
202
|
+
supabaseUrl,
|
|
203
|
+
supabaseAnonKey,
|
|
204
|
+
supabaseServiceRoleKey,
|
|
205
|
+
databaseUrl,
|
|
206
|
+
machineName,
|
|
207
|
+
dashboardPort,
|
|
208
|
+
deployDashboard,
|
|
209
|
+
airchatDir,
|
|
210
|
+
nodePath,
|
|
211
|
+
webUrl,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
async function cloneAndInstall(config) {
|
|
215
|
+
const name = 'Clone & install';
|
|
216
|
+
if (fs.existsSync(path.join(config.airchatDir, 'packages', 'mcp-server'))) {
|
|
217
|
+
return { name, ok: true, message: 'Repo already exists' };
|
|
218
|
+
}
|
|
219
|
+
try {
|
|
220
|
+
console.log(` Cloning airchat...`);
|
|
221
|
+
(0, node_child_process_1.execSync)(`git clone https://github.com/prone/airchat.git "${config.airchatDir}"`, { stdio: 'pipe' });
|
|
222
|
+
console.log(` Installing dependencies...`);
|
|
223
|
+
(0, node_child_process_1.execSync)('npm install', { cwd: config.airchatDir, stdio: 'pipe' });
|
|
224
|
+
return { name, ok: true, message: 'Cloned and installed' };
|
|
225
|
+
}
|
|
226
|
+
catch (e) {
|
|
227
|
+
return { name, ok: false, message: `Failed: ${e.message}` };
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
async function runMigrations(config) {
|
|
231
|
+
const name = 'Run migrations';
|
|
232
|
+
if (config.dbProvider === 'supabase') {
|
|
233
|
+
if (!config.supabaseUrl || !config.supabaseServiceRoleKey) {
|
|
234
|
+
return { name, ok: false, message: 'Missing Supabase credentials' };
|
|
235
|
+
}
|
|
236
|
+
const supabase = (0, supabase_js_1.createClient)(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
237
|
+
auth: { persistSession: false },
|
|
238
|
+
});
|
|
239
|
+
// Check if tables already exist
|
|
240
|
+
const { data: agents } = await supabase.from('agents').select('id').limit(1);
|
|
241
|
+
if (agents !== null) {
|
|
242
|
+
return { name, ok: true, message: 'Tables already exist — skipping migrations' };
|
|
243
|
+
}
|
|
244
|
+
// Try supabase CLI
|
|
245
|
+
try {
|
|
246
|
+
(0, node_child_process_1.execSync)('which supabase', { stdio: 'pipe' });
|
|
247
|
+
(0, node_child_process_1.execSync)(`supabase db push --db-url "${config.supabaseUrl}"`, {
|
|
248
|
+
cwd: config.airchatDir,
|
|
249
|
+
stdio: 'pipe',
|
|
250
|
+
});
|
|
251
|
+
return { name, ok: true, message: 'Migrations applied via Supabase CLI' };
|
|
252
|
+
}
|
|
253
|
+
catch { }
|
|
254
|
+
// Read and execute migration files sequentially
|
|
255
|
+
const migrationsDir = path.join(config.airchatDir, 'supabase', 'migrations');
|
|
256
|
+
if (!fs.existsSync(migrationsDir)) {
|
|
257
|
+
return { name, ok: false, message: `Migrations directory not found at ${migrationsDir}` };
|
|
258
|
+
}
|
|
259
|
+
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
|
260
|
+
let applied = 0;
|
|
261
|
+
for (const file of files) {
|
|
262
|
+
const sql = fs.readFileSync(path.join(migrationsDir, file), 'utf-8');
|
|
263
|
+
const { error } = await supabase.rpc('', {}).then(() => ({ error: null }), () => ({ error: null }));
|
|
264
|
+
// Supabase JS client can't run raw DDL — instruct user
|
|
265
|
+
console.log(` ${dim(`Migration ${file} needs to be applied via Supabase SQL Editor`)}`);
|
|
266
|
+
applied++;
|
|
267
|
+
}
|
|
268
|
+
if (applied > 0) {
|
|
269
|
+
return {
|
|
270
|
+
name,
|
|
271
|
+
ok: false,
|
|
272
|
+
message: `${files.length} migration files need to be applied manually in the Supabase SQL Editor (Settings → SQL Editor). Files are in: ${migrationsDir}`,
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
return { name, ok: true, message: 'Migrations applied' };
|
|
276
|
+
}
|
|
277
|
+
// Self-hosted or Docker: migrations run via psql or dashboard
|
|
278
|
+
if (config.dbProvider === 'self-hosted' && config.databaseUrl) {
|
|
279
|
+
const migrationsDir = path.join(config.airchatDir, 'supabase', 'migrations');
|
|
280
|
+
const files = fs.readdirSync(migrationsDir).filter(f => f.endsWith('.sql')).sort();
|
|
281
|
+
try {
|
|
282
|
+
for (const file of files) {
|
|
283
|
+
(0, node_child_process_1.execSync)(`psql "${config.databaseUrl}" -f "${path.join(migrationsDir, file)}"`, { stdio: 'pipe' });
|
|
284
|
+
}
|
|
285
|
+
return { name, ok: true, message: `Applied ${files.length} migrations` };
|
|
286
|
+
}
|
|
287
|
+
catch (e) {
|
|
288
|
+
return { name, ok: false, message: `Migration failed: ${e.message}. Run manually with psql.` };
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { name, ok: true, message: 'Migrations will run with Docker startup' };
|
|
292
|
+
}
|
|
293
|
+
async function seedChannels(config) {
|
|
294
|
+
const name = 'Seed channels';
|
|
295
|
+
if (config.dbProvider !== 'supabase' || !config.supabaseUrl || !config.supabaseServiceRoleKey) {
|
|
296
|
+
return { name, ok: true, message: 'Skipped (non-Supabase provider)' };
|
|
297
|
+
}
|
|
298
|
+
const supabase = (0, supabase_js_1.createClient)(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
299
|
+
auth: { persistSession: false },
|
|
300
|
+
});
|
|
301
|
+
const channels = [
|
|
302
|
+
{ name: 'global', type: 'global', description: 'Broadcast channel visible to all agents' },
|
|
303
|
+
{ name: 'general', type: 'global', description: 'General discussion for all agents' },
|
|
304
|
+
{ name: 'project-airchat', type: 'project', description: 'AirChat project coordination' },
|
|
305
|
+
{ name: 'tech-typescript', type: 'technology', description: 'TypeScript tips, issues, and discussion' },
|
|
306
|
+
];
|
|
307
|
+
let created = 0;
|
|
308
|
+
for (const channel of channels) {
|
|
309
|
+
const { error } = await supabase.from('channels').upsert(channel, { onConflict: 'name' });
|
|
310
|
+
if (!error)
|
|
311
|
+
created++;
|
|
312
|
+
}
|
|
313
|
+
return { name, ok: true, message: `${created}/${channels.length} channels ready` };
|
|
314
|
+
}
|
|
315
|
+
async function generateMachineKey(config) {
|
|
316
|
+
const name = 'Generate machine key';
|
|
317
|
+
if (config.dbProvider !== 'supabase' || !config.supabaseUrl || !config.supabaseServiceRoleKey) {
|
|
318
|
+
return { name, ok: false, message: 'Machine key generation requires Supabase credentials' };
|
|
319
|
+
}
|
|
320
|
+
const supabase = (0, supabase_js_1.createClient)(config.supabaseUrl, config.supabaseServiceRoleKey, {
|
|
321
|
+
auth: { persistSession: false },
|
|
322
|
+
});
|
|
323
|
+
const rawKey = `ack_${crypto.randomBytes(32).toString('hex')}`;
|
|
324
|
+
const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
|
|
325
|
+
const { data, error } = await supabase
|
|
326
|
+
.from('machine_keys')
|
|
327
|
+
.insert({
|
|
328
|
+
machine_name: config.machineName,
|
|
329
|
+
key_hash: keyHash,
|
|
330
|
+
active: true,
|
|
331
|
+
})
|
|
332
|
+
.select()
|
|
333
|
+
.single();
|
|
334
|
+
if (error) {
|
|
335
|
+
return { name, ok: false, message: `Failed: ${error.message}` };
|
|
336
|
+
}
|
|
337
|
+
config.apiKey = rawKey;
|
|
338
|
+
return { name, ok: true, message: `Key generated for "${config.machineName}"` };
|
|
339
|
+
}
|
|
340
|
+
function writeAirchatConfig(config) {
|
|
341
|
+
const name = 'Write ~/.airchat/config';
|
|
342
|
+
try {
|
|
343
|
+
const configDir = path.join(os.homedir(), '.airchat');
|
|
344
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
345
|
+
const lines = [
|
|
346
|
+
`MACHINE_NAME=${config.machineName}`,
|
|
347
|
+
];
|
|
348
|
+
if (config.apiKey)
|
|
349
|
+
lines.push(`AIRCHAT_API_KEY=${config.apiKey}`);
|
|
350
|
+
if (config.webUrl)
|
|
351
|
+
lines.push(`AIRCHAT_WEB_URL=${config.webUrl}`);
|
|
352
|
+
// Supabase-specific (needed by check-mentions hook)
|
|
353
|
+
if (config.supabaseUrl)
|
|
354
|
+
lines.push(`SUPABASE_URL=${config.supabaseUrl}`);
|
|
355
|
+
if (config.supabaseAnonKey)
|
|
356
|
+
lines.push(`SUPABASE_ANON_KEY=${config.supabaseAnonKey}`);
|
|
357
|
+
fs.writeFileSync(path.join(configDir, 'config'), lines.join('\n') + '\n');
|
|
358
|
+
return { name, ok: true, message: configDir };
|
|
359
|
+
}
|
|
360
|
+
catch (e) {
|
|
361
|
+
return { name, ok: false, message: e.message };
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function writeWebEnv(config) {
|
|
365
|
+
const name = 'Write dashboard .env';
|
|
366
|
+
if (!config.deployDashboard) {
|
|
367
|
+
return { name, ok: true, message: 'Skipped' };
|
|
368
|
+
}
|
|
369
|
+
try {
|
|
370
|
+
const envPath = path.join(config.airchatDir, 'apps', 'web', '.env.local');
|
|
371
|
+
const lines = [];
|
|
372
|
+
if (config.supabaseUrl) {
|
|
373
|
+
lines.push(`NEXT_PUBLIC_SUPABASE_URL=${config.supabaseUrl}`);
|
|
374
|
+
}
|
|
375
|
+
if (config.supabaseAnonKey) {
|
|
376
|
+
lines.push(`NEXT_PUBLIC_SUPABASE_ANON_KEY=${config.supabaseAnonKey}`);
|
|
377
|
+
}
|
|
378
|
+
if (config.supabaseServiceRoleKey) {
|
|
379
|
+
lines.push(`SUPABASE_SERVICE_ROLE_KEY=${config.supabaseServiceRoleKey}`);
|
|
380
|
+
}
|
|
381
|
+
if (config.databaseUrl) {
|
|
382
|
+
lines.push(`DATABASE_URL=${config.databaseUrl}`);
|
|
383
|
+
}
|
|
384
|
+
if (config.apiKey) {
|
|
385
|
+
lines.push(`AIRCHAT_API_KEY=${config.apiKey}`);
|
|
386
|
+
}
|
|
387
|
+
fs.writeFileSync(envPath, lines.join('\n') + '\n');
|
|
388
|
+
return { name, ok: true, message: envPath };
|
|
389
|
+
}
|
|
390
|
+
catch (e) {
|
|
391
|
+
return { name, ok: false, message: e.message };
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
function registerMcpServer(config) {
|
|
395
|
+
const name = 'Register MCP server';
|
|
396
|
+
const tsxPath = path.join(config.airchatDir, 'node_modules', '.bin', 'tsx');
|
|
397
|
+
const serverPath = path.join(config.airchatDir, 'packages', 'mcp-server', 'src', 'index.ts');
|
|
398
|
+
if (!fs.existsSync(serverPath)) {
|
|
399
|
+
return { name, ok: false, message: `MCP server not found at ${serverPath}` };
|
|
400
|
+
}
|
|
401
|
+
// Build env args
|
|
402
|
+
const envArgs = [];
|
|
403
|
+
if (config.supabaseUrl)
|
|
404
|
+
envArgs.push(`-e SUPABASE_URL=${config.supabaseUrl}`);
|
|
405
|
+
if (config.supabaseAnonKey)
|
|
406
|
+
envArgs.push(`-e SUPABASE_ANON_KEY=${config.supabaseAnonKey}`);
|
|
407
|
+
if (config.apiKey)
|
|
408
|
+
envArgs.push(`-e AIRCHAT_API_KEY=${config.apiKey}`);
|
|
409
|
+
if (config.webUrl)
|
|
410
|
+
envArgs.push(`-e AIRCHAT_WEB_URL=${config.webUrl}`);
|
|
411
|
+
const cmd = `claude mcp add airchat -s user ${envArgs.join(' ')} -- "${config.nodePath}" "${tsxPath}" "${serverPath}"`;
|
|
412
|
+
try {
|
|
413
|
+
(0, node_child_process_1.execSync)(cmd, { stdio: 'pipe' });
|
|
414
|
+
return { name, ok: true, message: 'Registered at user level' };
|
|
415
|
+
}
|
|
416
|
+
catch (e) {
|
|
417
|
+
// claude CLI might not be installed
|
|
418
|
+
return {
|
|
419
|
+
name,
|
|
420
|
+
ok: false,
|
|
421
|
+
message: `Could not run "claude mcp add". Run manually:\n ${cmd}`,
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function installClaudeMd(config) {
|
|
426
|
+
const name = 'Install agent instructions';
|
|
427
|
+
try {
|
|
428
|
+
const srcPath = path.join(config.airchatDir, 'setup', 'global-CLAUDE.md');
|
|
429
|
+
if (!fs.existsSync(srcPath)) {
|
|
430
|
+
return { name, ok: false, message: 'setup/global-CLAUDE.md not found' };
|
|
431
|
+
}
|
|
432
|
+
const content = fs.readFileSync(srcPath, 'utf-8');
|
|
433
|
+
const claudeMdPath = path.join(os.homedir(), '.claude', 'CLAUDE.md');
|
|
434
|
+
// Check if already present
|
|
435
|
+
if (fs.existsSync(claudeMdPath)) {
|
|
436
|
+
const existing = fs.readFileSync(claudeMdPath, 'utf-8');
|
|
437
|
+
if (existing.includes('# AirChat')) {
|
|
438
|
+
return { name, ok: true, message: 'Already present in ~/.claude/CLAUDE.md' };
|
|
439
|
+
}
|
|
440
|
+
fs.appendFileSync(claudeMdPath, '\n' + content);
|
|
441
|
+
}
|
|
442
|
+
else {
|
|
443
|
+
fs.mkdirSync(path.dirname(claudeMdPath), { recursive: true });
|
|
444
|
+
fs.writeFileSync(claudeMdPath, content);
|
|
445
|
+
}
|
|
446
|
+
return { name, ok: true, message: '~/.claude/CLAUDE.md' };
|
|
447
|
+
}
|
|
448
|
+
catch (e) {
|
|
449
|
+
return { name, ok: false, message: e.message };
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function installMentionHook(config) {
|
|
453
|
+
const name = 'Install mention hook';
|
|
454
|
+
try {
|
|
455
|
+
const settingsPath = path.join(os.homedir(), '.claude', 'settings.json');
|
|
456
|
+
let settings = {};
|
|
457
|
+
if (fs.existsSync(settingsPath)) {
|
|
458
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
459
|
+
}
|
|
460
|
+
const hookCommand = `${config.nodePath} ${path.join(config.airchatDir, 'scripts', 'check-mentions.mjs')}`;
|
|
461
|
+
// Check if hook already exists
|
|
462
|
+
if (settings.hooks?.UserPromptSubmit) {
|
|
463
|
+
const existing = settings.hooks.UserPromptSubmit;
|
|
464
|
+
const alreadyHasHook = Array.isArray(existing) && existing.some((entry) => entry.hooks?.some((h) => h.command?.includes('check-mentions')));
|
|
465
|
+
if (alreadyHasHook) {
|
|
466
|
+
return { name, ok: true, message: 'Hook already configured' };
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// Add hook
|
|
470
|
+
if (!settings.hooks)
|
|
471
|
+
settings.hooks = {};
|
|
472
|
+
if (!settings.hooks.UserPromptSubmit)
|
|
473
|
+
settings.hooks.UserPromptSubmit = [];
|
|
474
|
+
settings.hooks.UserPromptSubmit.push({
|
|
475
|
+
matcher: '',
|
|
476
|
+
hooks: [{
|
|
477
|
+
type: 'command',
|
|
478
|
+
command: hookCommand,
|
|
479
|
+
}],
|
|
480
|
+
});
|
|
481
|
+
fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
|
|
482
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
483
|
+
return { name, ok: true, message: '~/.claude/settings.json' };
|
|
484
|
+
}
|
|
485
|
+
catch (e) {
|
|
486
|
+
return { name, ok: false, message: e.message };
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
function copySlashCommands(config) {
|
|
490
|
+
const name = 'Copy slash commands';
|
|
491
|
+
try {
|
|
492
|
+
const setupDir = path.join(config.airchatDir, 'setup');
|
|
493
|
+
const commandsDir = path.join(os.homedir(), '.claude', 'commands');
|
|
494
|
+
fs.mkdirSync(commandsDir, { recursive: true });
|
|
495
|
+
const files = fs.readdirSync(setupDir).filter(f => f.startsWith('airchat-') && f.endsWith('.md'));
|
|
496
|
+
for (const file of files) {
|
|
497
|
+
fs.copyFileSync(path.join(setupDir, file), path.join(commandsDir, file));
|
|
498
|
+
}
|
|
499
|
+
return { name, ok: true, message: `${files.length} commands → ~/.claude/commands/` };
|
|
500
|
+
}
|
|
501
|
+
catch (e) {
|
|
502
|
+
return { name, ok: false, message: e.message };
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
// ── Main ───────────────────────────────────────────────────────────────────────
|
|
506
|
+
async function main() {
|
|
507
|
+
const reconfigure = process.argv.includes('--reconfigure');
|
|
508
|
+
console.log('');
|
|
509
|
+
console.log(` ${GREEN}>${RESET} ${BOLD}AirChat${RESET} ${dim('v0.1.0')}`);
|
|
510
|
+
console.log(` ${dim('Your AI agents can talk to each other')}`);
|
|
511
|
+
const rl = readline.createInterface({ input: node_process_1.stdin, output: node_process_1.stdout });
|
|
512
|
+
let config;
|
|
513
|
+
try {
|
|
514
|
+
config = await collectConfig(rl, reconfigure);
|
|
515
|
+
}
|
|
516
|
+
finally {
|
|
517
|
+
rl.close();
|
|
518
|
+
}
|
|
519
|
+
// Show summary before proceeding
|
|
520
|
+
console.log(` ${bold('Summary:')}`);
|
|
521
|
+
console.log(` Database: ${config.dbProvider}`);
|
|
522
|
+
console.log(` Machine: ${config.machineName}`);
|
|
523
|
+
console.log(` Repo: ${config.airchatDir}`);
|
|
524
|
+
console.log(` Node: ${config.nodePath}`);
|
|
525
|
+
if (config.deployDashboard) {
|
|
526
|
+
console.log(` Dashboard: port ${config.dashboardPort}`);
|
|
527
|
+
}
|
|
528
|
+
console.log('');
|
|
529
|
+
// Execute steps
|
|
530
|
+
const results = [];
|
|
531
|
+
// 1. Clone & install
|
|
532
|
+
process.stdout.write(` Cloning & installing...`);
|
|
533
|
+
const cloneResult = await cloneAndInstall(config);
|
|
534
|
+
results.push(cloneResult);
|
|
535
|
+
process.stdout.write(`\r`);
|
|
536
|
+
if (cloneResult.ok)
|
|
537
|
+
ok(cloneResult.message);
|
|
538
|
+
else
|
|
539
|
+
fail(cloneResult.message);
|
|
540
|
+
// 2. Run migrations
|
|
541
|
+
if (!reconfigure) {
|
|
542
|
+
process.stdout.write(` Running migrations...`);
|
|
543
|
+
const migrationResult = await runMigrations(config);
|
|
544
|
+
results.push(migrationResult);
|
|
545
|
+
process.stdout.write(`\r`);
|
|
546
|
+
if (migrationResult.ok)
|
|
547
|
+
ok(migrationResult.message);
|
|
548
|
+
else
|
|
549
|
+
fail(migrationResult.message);
|
|
550
|
+
}
|
|
551
|
+
// 3. Seed channels
|
|
552
|
+
if (!reconfigure) {
|
|
553
|
+
process.stdout.write(` Seeding channels...`);
|
|
554
|
+
const seedResult = await seedChannels(config);
|
|
555
|
+
results.push(seedResult);
|
|
556
|
+
process.stdout.write(`\r`);
|
|
557
|
+
if (seedResult.ok)
|
|
558
|
+
ok(seedResult.message);
|
|
559
|
+
else
|
|
560
|
+
fail(seedResult.message);
|
|
561
|
+
}
|
|
562
|
+
// 4. Generate machine key
|
|
563
|
+
if (!reconfigure) {
|
|
564
|
+
process.stdout.write(` Generating machine key...`);
|
|
565
|
+
const keyResult = await generateMachineKey(config);
|
|
566
|
+
results.push(keyResult);
|
|
567
|
+
process.stdout.write(`\r`);
|
|
568
|
+
if (keyResult.ok)
|
|
569
|
+
ok(keyResult.message);
|
|
570
|
+
else
|
|
571
|
+
fail(keyResult.message);
|
|
572
|
+
}
|
|
573
|
+
// 5. Write config
|
|
574
|
+
const configResult = writeAirchatConfig(config);
|
|
575
|
+
results.push(configResult);
|
|
576
|
+
if (configResult.ok)
|
|
577
|
+
ok(`Config → ${configResult.message}`);
|
|
578
|
+
else
|
|
579
|
+
fail(configResult.message);
|
|
580
|
+
// 6. Write dashboard .env
|
|
581
|
+
const envResult = writeWebEnv(config);
|
|
582
|
+
results.push(envResult);
|
|
583
|
+
if (envResult.ok && config.deployDashboard)
|
|
584
|
+
ok(`Dashboard env → ${envResult.message}`);
|
|
585
|
+
else if (!config.deployDashboard) { } // silent skip
|
|
586
|
+
else
|
|
587
|
+
fail(envResult.message);
|
|
588
|
+
// 7. Register MCP server
|
|
589
|
+
const mcpResult = registerMcpServer(config);
|
|
590
|
+
results.push(mcpResult);
|
|
591
|
+
if (mcpResult.ok)
|
|
592
|
+
ok(`MCP server → ${mcpResult.message}`);
|
|
593
|
+
else
|
|
594
|
+
warn(mcpResult.message);
|
|
595
|
+
// 8. Install agent instructions
|
|
596
|
+
const claudeResult = installClaudeMd(config);
|
|
597
|
+
results.push(claudeResult);
|
|
598
|
+
if (claudeResult.ok)
|
|
599
|
+
ok(`Instructions → ${claudeResult.message}`);
|
|
600
|
+
else
|
|
601
|
+
fail(claudeResult.message);
|
|
602
|
+
// 9. Install mention hook
|
|
603
|
+
const hookResult = installMentionHook(config);
|
|
604
|
+
results.push(hookResult);
|
|
605
|
+
if (hookResult.ok)
|
|
606
|
+
ok(`Mention hook → ${hookResult.message}`);
|
|
607
|
+
else
|
|
608
|
+
fail(hookResult.message);
|
|
609
|
+
// 10. Copy slash commands
|
|
610
|
+
const cmdResult = copySlashCommands(config);
|
|
611
|
+
results.push(cmdResult);
|
|
612
|
+
if (cmdResult.ok)
|
|
613
|
+
ok(`Slash commands → ${cmdResult.message}`);
|
|
614
|
+
else
|
|
615
|
+
fail(cmdResult.message);
|
|
616
|
+
// Summary
|
|
617
|
+
console.log('');
|
|
618
|
+
const failures = results.filter(r => !r.ok);
|
|
619
|
+
if (failures.length === 0) {
|
|
620
|
+
console.log(` ${GREEN}${BOLD}Setup complete!${RESET}`);
|
|
621
|
+
}
|
|
622
|
+
else {
|
|
623
|
+
console.log(` ${YELLOW}${BOLD}Setup completed with ${failures.length} issue(s):${RESET}`);
|
|
624
|
+
for (const f of failures) {
|
|
625
|
+
console.log(` ${RED}•${RESET} ${f.name}: ${f.message}`);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
if (config.apiKey) {
|
|
629
|
+
console.log('');
|
|
630
|
+
console.log(` ${BOLD}Your API key:${RESET} ${config.apiKey}`);
|
|
631
|
+
console.log(` ${YELLOW}Save this key — it cannot be retrieved later.${RESET}`);
|
|
632
|
+
}
|
|
633
|
+
console.log('');
|
|
634
|
+
console.log(` ${dim('Next steps:')}`);
|
|
635
|
+
console.log(` 1. Restart Claude Code`);
|
|
636
|
+
if (config.deployDashboard) {
|
|
637
|
+
console.log(` 2. Start the dashboard: cd ${config.airchatDir} && docker compose up -d --build`);
|
|
638
|
+
}
|
|
639
|
+
console.log(` ${dim('Run with --reconfigure to update settings later.')}`);
|
|
640
|
+
console.log('');
|
|
641
|
+
}
|
|
642
|
+
main().catch((e) => {
|
|
643
|
+
console.error(`\n ${RED}Setup failed:${RESET} ${e.message}\n`);
|
|
644
|
+
process.exit(1);
|
|
645
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "airchat",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Interactive setup CLI for AirChat — one command to get your AI agents talking to each other",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"author": "Salmonrun.ai",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "https://github.com/prone/airchat",
|
|
10
|
+
"directory": "packages/create-airchat"
|
|
11
|
+
},
|
|
12
|
+
"bin": {
|
|
13
|
+
"create-airchat": "./dist/index.js"
|
|
14
|
+
},
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc",
|
|
20
|
+
"dev": "tsx src/index.ts"
|
|
21
|
+
},
|
|
22
|
+
"dependencies": {
|
|
23
|
+
"@supabase/supabase-js": "^2.49.0"
|
|
24
|
+
},
|
|
25
|
+
"devDependencies": {
|
|
26
|
+
"tsx": "^4.19.0",
|
|
27
|
+
"typescript": "^5.7.0"
|
|
28
|
+
}
|
|
29
|
+
}
|