onkol 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.
@@ -0,0 +1,11 @@
1
+ export interface DiscoveredService {
2
+ name: string;
3
+ type: 'docker' | 'pm2' | 'systemd' | 'process';
4
+ port?: string;
5
+ image?: string;
6
+ status?: string;
7
+ }
8
+ export declare function parseDockerPs(output: string): DiscoveredService[];
9
+ export declare function parseSsOutput(output: string): DiscoveredService[];
10
+ export declare function discoverServices(): DiscoveredService[];
11
+ export declare function formatServicesMarkdown(services: DiscoveredService[]): string;
@@ -0,0 +1,60 @@
1
+ import { execSync } from 'child_process';
2
+ export function parseDockerPs(output) {
3
+ const lines = output.trim().split('\n').slice(1);
4
+ return lines
5
+ .filter((line) => line.trim().length > 0)
6
+ .map((line) => {
7
+ const parts = line.split(/\s{2,}/);
8
+ const name = parts[parts.length - 1]?.trim();
9
+ const image = parts[1]?.trim();
10
+ const portsField = parts.find((p) => p.includes('->')) || '';
11
+ const portMatch = portsField.match(/:(\d+)->/);
12
+ return { name, type: 'docker', port: portMatch?.[1], image };
13
+ })
14
+ .filter((s) => s.name);
15
+ }
16
+ export function parseSsOutput(output) {
17
+ const lines = output.trim().split('\n').slice(1);
18
+ return lines
19
+ .filter((line) => line.includes('LISTEN'))
20
+ .map((line) => {
21
+ const portMatch = line.match(/\*:(\d+)/);
22
+ const processMatch = line.match(/\("([^"]+)"/);
23
+ return {
24
+ name: processMatch?.[1] || 'unknown',
25
+ type: 'process',
26
+ port: portMatch?.[1],
27
+ };
28
+ })
29
+ .filter((s) => s.port);
30
+ }
31
+ export function discoverServices() {
32
+ const services = [];
33
+ try {
34
+ const dockerOutput = execSync('docker ps --format "table {{.ID}}\\t{{.Image}}\\t{{.Ports}}\\t{{.Names}}"', { encoding: 'utf-8', timeout: 5000 });
35
+ services.push(...parseDockerPs(dockerOutput));
36
+ }
37
+ catch { /* docker not available */ }
38
+ try {
39
+ const ssOutput = execSync('ss -tlnp 2>/dev/null', { encoding: 'utf-8', timeout: 5000 });
40
+ const processServices = parseSsOutput(ssOutput);
41
+ const dockerPorts = new Set(services.map((s) => s.port));
42
+ services.push(...processServices.filter((s) => !dockerPorts.has(s.port)));
43
+ }
44
+ catch { /* ss not available */ }
45
+ return services;
46
+ }
47
+ export function formatServicesMarkdown(services) {
48
+ if (services.length === 0)
49
+ return 'No services discovered.\n';
50
+ let md = '## Discovered Services\n\n';
51
+ for (const s of services) {
52
+ md += `- **${s.name}** (${s.type})`;
53
+ if (s.port)
54
+ md += ` on port ${s.port}`;
55
+ if (s.image)
56
+ md += ` — image: ${s.image}`;
57
+ md += '\n';
58
+ }
59
+ return md;
60
+ }
@@ -0,0 +1,19 @@
1
+ export declare function buildCreateCategoryPayload(name: string): {
2
+ name: string;
3
+ type: number;
4
+ };
5
+ export declare function buildCreateChannelPayload(name: string, parentId: string): {
6
+ name: string;
7
+ type: number;
8
+ parent_id: string;
9
+ };
10
+ export declare function createCategory(token: string, guildId: string, name: string): Promise<{
11
+ id: string;
12
+ name: string;
13
+ }>;
14
+ export declare function createChannel(token: string, guildId: string, name: string, parentId: string): Promise<{
15
+ id: string;
16
+ name: string;
17
+ }>;
18
+ export declare function deleteChannel(token: string, channelId: string): Promise<void>;
19
+ export declare function sendMessage(token: string, channelId: string, content: string): Promise<void>;
@@ -0,0 +1,53 @@
1
+ const DISCORD_API = 'https://discord.com/api/v10';
2
+ function sanitizeChannelName(name) {
3
+ return name
4
+ .toLowerCase()
5
+ .replace(/[^a-z0-9-]/g, '-')
6
+ .replace(/-+/g, '-')
7
+ .replace(/^-|-$/g, '')
8
+ .slice(0, 100);
9
+ }
10
+ export function buildCreateCategoryPayload(name) {
11
+ return { name, type: 4 };
12
+ }
13
+ export function buildCreateChannelPayload(name, parentId) {
14
+ return { name: sanitizeChannelName(name), type: 0, parent_id: parentId };
15
+ }
16
+ export async function createCategory(token, guildId, name) {
17
+ const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, {
18
+ method: 'POST',
19
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
20
+ body: JSON.stringify(buildCreateCategoryPayload(name)),
21
+ });
22
+ if (!res.ok)
23
+ throw new Error(`Failed to create category: ${res.status} ${await res.text()}`);
24
+ return res.json();
25
+ }
26
+ export async function createChannel(token, guildId, name, parentId) {
27
+ const res = await fetch(`${DISCORD_API}/guilds/${guildId}/channels`, {
28
+ method: 'POST',
29
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
30
+ body: JSON.stringify(buildCreateChannelPayload(name, parentId)),
31
+ });
32
+ if (!res.ok)
33
+ throw new Error(`Failed to create channel: ${res.status} ${await res.text()}`);
34
+ return res.json();
35
+ }
36
+ export async function deleteChannel(token, channelId) {
37
+ const res = await fetch(`${DISCORD_API}/channels/${channelId}`, {
38
+ method: 'DELETE',
39
+ headers: { Authorization: `Bot ${token}` },
40
+ });
41
+ if (!res.ok && res.status !== 404) {
42
+ throw new Error(`Failed to delete channel: ${res.status} ${await res.text()}`);
43
+ }
44
+ }
45
+ export async function sendMessage(token, channelId, content) {
46
+ const res = await fetch(`${DISCORD_API}/channels/${channelId}/messages`, {
47
+ method: 'POST',
48
+ headers: { Authorization: `Bot ${token}`, 'Content-Type': 'application/json' },
49
+ body: JSON.stringify({ content }),
50
+ });
51
+ if (!res.ok)
52
+ throw new Error(`Failed to send message: ${res.status} ${await res.text()}`);
53
+ }
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
@@ -0,0 +1,313 @@
1
+ #!/usr/bin/env node
2
+ import { dirname } from 'path';
3
+ import { fileURLToPath } from 'url';
4
+ const __dirname = dirname(fileURLToPath(import.meta.url));
5
+ import { program } from 'commander';
6
+ import chalk from 'chalk';
7
+ import { mkdirSync, writeFileSync, readFileSync, copyFileSync, existsSync } from 'fs';
8
+ import { resolve } from 'path';
9
+ import { execSync } from 'child_process';
10
+ import { runSetupPrompts } from './prompts.js';
11
+ import { createCategory, createChannel } from './discord-api.js';
12
+ import { discoverServices, formatServicesMarkdown } from './auto-discover.js';
13
+ import { renderOrchestratorClaude, renderSettings } from './templates.js';
14
+ import { generateSystemdUnit, generateCrontab } from './systemd.js';
15
+ program
16
+ .name('onkol')
17
+ .description('Decentralized on-call agent system')
18
+ .version('0.1.0');
19
+ program
20
+ .command('setup')
21
+ .description('Set up an Onkol node on this VM')
22
+ .action(async () => {
23
+ console.log(chalk.bold('\nWelcome to Onkol Setup\n'));
24
+ const homeDir = process.env.HOME || '/root';
25
+ const answers = await runSetupPrompts(homeDir);
26
+ const dir = resolve(answers.installDir);
27
+ // Create directory structure
28
+ console.log(chalk.gray('Creating directories...'));
29
+ for (const sub of ['knowledge', 'workers', 'workers/.archive', 'scripts', 'plugins/discord-filtered', '.claude']) {
30
+ mkdirSync(resolve(dir, sub), { recursive: true });
31
+ }
32
+ // Build allowed users list from Discord user ID prompt
33
+ const user = process.env.USER || 'root';
34
+ const allowedUsers = [];
35
+ if (answers.discordUserId.trim()) {
36
+ allowedUsers.push(answers.discordUserId.trim());
37
+ }
38
+ // --- CRITICAL: Create Discord category and orchestrator channel ---
39
+ console.log(chalk.gray('Creating Discord category and channel...'));
40
+ let category;
41
+ let orchChannel;
42
+ try {
43
+ category = await createCategory(answers.botToken, answers.guildId, answers.nodeName);
44
+ orchChannel = await createChannel(answers.botToken, answers.guildId, 'orchestrator', category.id);
45
+ }
46
+ catch (err) {
47
+ console.error(chalk.red(`\nFATAL: Could not create Discord category/channel.`));
48
+ console.error(chalk.red(`${err instanceof Error ? err.message : err}`));
49
+ console.error(chalk.red('\nCheck that:'));
50
+ console.error(chalk.red(' 1. Your bot token is correct'));
51
+ console.error(chalk.red(' 2. Your server (guild) ID is correct'));
52
+ console.error(chalk.red(' 3. The bot has been invited to the server with "Manage Channels" permission'));
53
+ process.exit(1);
54
+ }
55
+ console.log(chalk.green('✓ Discord category and #orchestrator channel created'));
56
+ // Write config.json
57
+ const config = {
58
+ nodeName: answers.nodeName,
59
+ botToken: answers.botToken,
60
+ guildId: answers.guildId,
61
+ categoryId: category.id,
62
+ orchestratorChannelId: orchChannel.id,
63
+ allowedUsers,
64
+ maxWorkers: 3,
65
+ installDir: dir,
66
+ plugins: answers.plugins,
67
+ };
68
+ writeFileSync(resolve(dir, 'config.json'), JSON.stringify(config, null, 2), { mode: 0o600 });
69
+ // Handle registry
70
+ if (answers.registryMode === 'import' && answers.registryPath) {
71
+ copyFileSync(answers.registryPath, resolve(dir, 'registry.json'));
72
+ }
73
+ else if (answers.registryMode !== 'prompt') {
74
+ writeFileSync(resolve(dir, 'registry.json'), '{}');
75
+ }
76
+ // Handle services
77
+ let servicesMd = '# Services\n\nNo services configured yet.\n';
78
+ if (answers.serviceMode === 'auto') {
79
+ console.log(chalk.gray('Discovering services...'));
80
+ const services = discoverServices();
81
+ servicesMd = formatServicesMarkdown(services);
82
+ console.log(chalk.green(`Found ${services.length} services.`));
83
+ }
84
+ else if (answers.serviceMode === 'import' && answers.serviceSummaryPath) {
85
+ servicesMd = readFileSync(answers.serviceSummaryPath, 'utf-8');
86
+ }
87
+ if (answers.serviceMode !== 'prompt') {
88
+ writeFileSync(resolve(dir, 'services.md'), servicesMd);
89
+ }
90
+ // Generate CLAUDE.md
91
+ const claudeMd = renderOrchestratorClaude({ nodeName: answers.nodeName, maxWorkers: 3 });
92
+ writeFileSync(resolve(dir, 'CLAUDE.md'), claudeMd);
93
+ // Generate .claude/settings.json
94
+ const settings = renderSettings({ bashLogPath: resolve(dir, 'bash-log.txt') });
95
+ writeFileSync(resolve(dir, '.claude/settings.json'), settings);
96
+ // Write orchestrator .mcp.json
97
+ const pluginPath = resolve(dir, 'plugins/discord-filtered/index.ts');
98
+ const mcpJson = {
99
+ mcpServers: {
100
+ 'discord-filtered': {
101
+ command: 'bun',
102
+ args: [pluginPath],
103
+ env: {
104
+ DISCORD_BOT_TOKEN: answers.botToken,
105
+ DISCORD_CHANNEL_ID: orchChannel.id,
106
+ DISCORD_ALLOWED_USERS: JSON.stringify(allowedUsers),
107
+ },
108
+ },
109
+ },
110
+ };
111
+ writeFileSync(resolve(dir, '.mcp.json'), JSON.stringify(mcpJson, null, 2));
112
+ // Initialize tracking and knowledge index
113
+ writeFileSync(resolve(dir, 'workers/tracking.json'), '[]');
114
+ writeFileSync(resolve(dir, 'knowledge/index.json'), '[]');
115
+ writeFileSync(resolve(dir, 'state.md'), '');
116
+ // Pre-accept Claude Code trust dialog for the onkol directory
117
+ console.log(chalk.gray('Configuring Claude Code trust...'));
118
+ const claudeJsonPath = resolve(homeDir, '.claude/.claude.json');
119
+ try {
120
+ const claudeJson = existsSync(claudeJsonPath) ? JSON.parse(readFileSync(claudeJsonPath, 'utf-8')) : {};
121
+ if (!claudeJson.projects)
122
+ claudeJson.projects = {};
123
+ claudeJson.projects[dir] = {
124
+ ...(claudeJson.projects[dir] || {}),
125
+ allowedTools: [],
126
+ hasTrustDialogAccepted: true,
127
+ };
128
+ writeFileSync(claudeJsonPath, JSON.stringify(claudeJson, null, 2));
129
+ console.log(chalk.green('✓ Claude Code trust pre-accepted for ' + dir));
130
+ }
131
+ catch {
132
+ console.log(chalk.yellow('⚠ Could not pre-accept trust dialog. You may need to accept it manually on first run.'));
133
+ }
134
+ // Handle setup prompts
135
+ const pendingPrompts = [];
136
+ if (answers.registryPrompt) {
137
+ pendingPrompts.push({ target: 'registry.json', prompt: answers.registryPrompt, status: 'pending' });
138
+ }
139
+ if (answers.servicesPrompt) {
140
+ pendingPrompts.push({ target: 'services.md', prompt: answers.servicesPrompt, status: 'pending' });
141
+ }
142
+ if (answers.claudeMdPrompt) {
143
+ pendingPrompts.push({ target: 'CLAUDE.md', prompt: answers.claudeMdPrompt, status: 'pending' });
144
+ }
145
+ if (pendingPrompts.length > 0) {
146
+ writeFileSync(resolve(dir, 'setup-prompts.json'), JSON.stringify({ pending: pendingPrompts }, null, 2));
147
+ }
148
+ // --- CRITICAL: Copy scripts ---
149
+ const requiredScripts = ['spawn-worker.sh', 'dissolve-worker.sh', 'list-workers.sh', 'check-worker.sh', 'healthcheck.sh', 'start-orchestrator.sh'];
150
+ const scriptsSource = resolve(__dirname, '../../scripts');
151
+ console.log(chalk.gray('Copying scripts...'));
152
+ if (!existsSync(scriptsSource)) {
153
+ console.error(chalk.red(`\nFATAL: Scripts directory not found at ${scriptsSource}`));
154
+ console.error(chalk.red('The onkol package appears to be corrupted. Reinstall with: npm install -g onkol'));
155
+ process.exit(1);
156
+ }
157
+ for (const script of requiredScripts) {
158
+ const src = resolve(scriptsSource, script);
159
+ const dst = resolve(dir, 'scripts', script);
160
+ if (!existsSync(src)) {
161
+ console.error(chalk.red(`\nFATAL: Required script not found: ${src}`));
162
+ process.exit(1);
163
+ }
164
+ copyFileSync(src, dst);
165
+ execSync(`chmod +x "${dst}"`);
166
+ }
167
+ console.log(chalk.green(`✓ ${requiredScripts.length} scripts installed`));
168
+ // --- CRITICAL: Copy plugin source ---
169
+ // Look for .ts source files first (for bun), fall back to .js compiled files
170
+ const pluginFiles = ['index', 'mcp-server', 'discord-client', 'message-batcher'];
171
+ const pluginSourceDir = resolve(__dirname, '../plugin');
172
+ const projectSrcDir = resolve(__dirname, '../../src/plugin');
173
+ console.log(chalk.gray('Installing discord-filtered plugin...'));
174
+ let pluginCopied = 0;
175
+ for (const base of pluginFiles) {
176
+ const dst = resolve(dir, 'plugins/discord-filtered', `${base}.ts`);
177
+ // Try .ts from project src first, then .ts from dist, then .js from dist
178
+ const candidates = [
179
+ resolve(projectSrcDir, `${base}.ts`),
180
+ resolve(pluginSourceDir, `${base}.ts`),
181
+ resolve(pluginSourceDir, `${base}.js`),
182
+ ];
183
+ const found = candidates.find(c => existsSync(c));
184
+ if (found) {
185
+ copyFileSync(found, found.endsWith('.js') ? resolve(dir, 'plugins/discord-filtered', `${base}.js`) : dst);
186
+ pluginCopied++;
187
+ }
188
+ }
189
+ if (pluginCopied < pluginFiles.length) {
190
+ console.error(chalk.red(`\nFATAL: Only ${pluginCopied}/${pluginFiles.length} plugin files found.`));
191
+ console.error(chalk.red(`Searched in:\n ${projectSrcDir}\n ${pluginSourceDir}`));
192
+ console.error(chalk.red('The onkol package appears to be corrupted. Reinstall with: npm install -g onkol'));
193
+ process.exit(1);
194
+ }
195
+ // Create plugin package.json and install deps
196
+ const pluginPkgJson = {
197
+ name: 'discord-filtered',
198
+ version: '0.1.0',
199
+ private: true,
200
+ dependencies: {
201
+ '@modelcontextprotocol/sdk': '^1.0.0',
202
+ 'discord.js': '^14.0.0',
203
+ },
204
+ };
205
+ writeFileSync(resolve(dir, 'plugins/discord-filtered/package.json'), JSON.stringify(pluginPkgJson, null, 2));
206
+ console.log(chalk.gray('Installing plugin dependencies (bun install)...'));
207
+ try {
208
+ execSync('bun install', { cwd: resolve(dir, 'plugins/discord-filtered'), stdio: 'pipe' });
209
+ console.log(chalk.green(`✓ Plugin installed with ${pluginCopied} files + dependencies`));
210
+ }
211
+ catch {
212
+ console.error(chalk.red('\nFATAL: Failed to install plugin dependencies.'));
213
+ console.error(chalk.red('Is bun installed? Install with: curl -fsSL https://bun.sh/install | bash'));
214
+ process.exit(1);
215
+ }
216
+ // Install systemd service
217
+ const systemdUnit = generateSystemdUnit(answers.nodeName, user, dir);
218
+ const unitPath = `/etc/systemd/system/onkol-${answers.nodeName}.service`;
219
+ console.log(chalk.gray('\nInstalling systemd service...'));
220
+ try {
221
+ writeFileSync(resolve(dir, `onkol-${answers.nodeName}.service`), systemdUnit);
222
+ execSync(`sudo cp "${resolve(dir, `onkol-${answers.nodeName}.service`)}" "${unitPath}"`, { stdio: 'pipe' });
223
+ execSync('sudo systemctl daemon-reload', { stdio: 'pipe' });
224
+ execSync(`sudo systemctl enable onkol-${answers.nodeName}`, { stdio: 'pipe' });
225
+ console.log(chalk.green(`✓ Systemd service installed and enabled`));
226
+ }
227
+ catch {
228
+ console.log(chalk.yellow(`⚠ Could not install systemd service automatically (need sudo).`));
229
+ console.log(chalk.yellow(` To install manually:`));
230
+ console.log(chalk.gray(` sudo tee ${unitPath} << 'EOF'\n${systemdUnit}EOF`));
231
+ console.log(chalk.gray(` sudo systemctl daemon-reload`));
232
+ console.log(chalk.gray(` sudo systemctl enable onkol-${answers.nodeName}`));
233
+ }
234
+ // Install health check timers — try cron first, then systemd user timers
235
+ console.log(chalk.gray('Installing health check timers...'));
236
+ let timersInstalled = false;
237
+ // Try crontab
238
+ try {
239
+ execSync('which crontab', { stdio: 'pipe' });
240
+ const cron = generateCrontab(dir);
241
+ const existingCron = (() => { try {
242
+ return execSync('crontab -l 2>/dev/null', { encoding: 'utf-8' });
243
+ }
244
+ catch {
245
+ return '';
246
+ } })();
247
+ if (!existingCron.includes(resolve(dir, 'scripts/healthcheck.sh'))) {
248
+ const newCron = existingCron.trimEnd() + '\n' + cron;
249
+ execSync(`echo ${JSON.stringify(newCron)} | crontab -`, { stdio: 'pipe' });
250
+ }
251
+ console.log(chalk.green(`✓ Cron jobs installed (healthcheck every 5min, archive cleanup daily)`));
252
+ timersInstalled = true;
253
+ }
254
+ catch { /* crontab not available */ }
255
+ // Fallback: systemd user timers (works on Arch, Fedora, etc. without cronie)
256
+ if (!timersInstalled) {
257
+ try {
258
+ const installTimersScript = resolve(dir, 'scripts/install-timers.sh');
259
+ if (existsSync(installTimersScript)) {
260
+ execSync(`bash "${installTimersScript}"`, { stdio: 'pipe' });
261
+ }
262
+ else {
263
+ // Create and run inline
264
+ const timerDir = resolve(homeDir, '.config/systemd/user');
265
+ mkdirSync(timerDir, { recursive: true });
266
+ const healthcheckPath = resolve(dir, 'scripts/healthcheck.sh');
267
+ writeFileSync(resolve(timerDir, 'onkol-healthcheck.service'), `[Unit]\nDescription=Onkol healthcheck\n[Service]\nType=oneshot\nExecStart=${healthcheckPath}\n`);
268
+ writeFileSync(resolve(timerDir, 'onkol-healthcheck.timer'), `[Unit]\nDescription=Onkol healthcheck every 5min\n[Timer]\nOnBootSec=2min\nOnUnitActiveSec=5min\n[Install]\nWantedBy=timers.target\n`);
269
+ writeFileSync(resolve(timerDir, 'onkol-cleanup.service'), `[Unit]\nDescription=Onkol archive cleanup\n[Service]\nType=oneshot\nExecStart=/usr/bin/find ${resolve(dir, 'workers/.archive')} -maxdepth 1 -mtime +30 -exec rm -rf {} \\;\n`);
270
+ writeFileSync(resolve(timerDir, 'onkol-cleanup.timer'), `[Unit]\nDescription=Onkol archive cleanup daily\n[Timer]\nOnCalendar=*-*-* 04:00:00\n[Install]\nWantedBy=timers.target\n`);
271
+ execSync('systemctl --user daemon-reload', { stdio: 'pipe' });
272
+ execSync('systemctl --user enable --now onkol-healthcheck.timer', { stdio: 'pipe' });
273
+ execSync('systemctl --user enable --now onkol-cleanup.timer', { stdio: 'pipe' });
274
+ }
275
+ console.log(chalk.green(`✓ Systemd user timers installed (healthcheck every 5min, cleanup daily)`));
276
+ timersInstalled = true;
277
+ }
278
+ catch { /* systemd timers failed too */ }
279
+ }
280
+ if (!timersInstalled) {
281
+ console.log(chalk.yellow(`⚠ Could not install health check timers (no crontab or systemd --user).`));
282
+ console.log(chalk.yellow(` You'll need to set up periodic health checks manually.`));
283
+ }
284
+ // Report pending setup prompts
285
+ if (pendingPrompts.length > 0) {
286
+ console.log(chalk.cyan('\nPending setup prompts saved. On first boot, the orchestrator will:'));
287
+ for (const p of pendingPrompts) {
288
+ console.log(chalk.cyan(` - Generate ${p.target} from your ${p.target === 'CLAUDE.md' ? 'description' : 'prompt'}`));
289
+ }
290
+ }
291
+ // Start orchestrator
292
+ console.log(chalk.gray('\nStarting orchestrator...'));
293
+ try {
294
+ execSync(`bash "${resolve(dir, 'scripts/start-orchestrator.sh')}"`, { stdio: 'pipe' });
295
+ console.log(chalk.green(`✓ Orchestrator started in tmux session "onkol-${answers.nodeName}"`));
296
+ }
297
+ catch (err) {
298
+ console.log(chalk.yellow(`⚠ Could not start orchestrator automatically.`));
299
+ console.log(chalk.yellow(` Start manually: ${dir}/scripts/start-orchestrator.sh`));
300
+ }
301
+ // Done
302
+ console.log(chalk.green.bold(`\n✓ Onkol node "${answers.nodeName}" is live!`));
303
+ console.log(chalk.green(`✓ Discord category "${answers.nodeName}" created with #orchestrator channel`));
304
+ if (allowedUsers.length > 0) {
305
+ console.log(chalk.green(`✓ Allowed Discord users: ${allowedUsers.join(', ')}`));
306
+ }
307
+ else {
308
+ console.log(chalk.yellow(`⚠ No Discord user ID configured. Add user IDs to config.json allowedUsers array.`));
309
+ }
310
+ console.log(chalk.gray(`\n To attach to the session: tmux attach -t onkol-${answers.nodeName}`));
311
+ console.log(chalk.gray(` To check status: systemctl status onkol-${answers.nodeName}`));
312
+ });
313
+ program.parse();
@@ -0,0 +1,17 @@
1
+ export interface SetupAnswers {
2
+ installDir: string;
3
+ nodeName: string;
4
+ botToken: string;
5
+ guildId: string;
6
+ discordUserId: string;
7
+ registryPath: string | null;
8
+ registryMode: 'import' | 'prompt' | 'skip';
9
+ registryPrompt: string | null;
10
+ serviceMode: 'import' | 'auto' | 'prompt' | 'skip';
11
+ serviceSummaryPath: string | null;
12
+ servicesPrompt: string | null;
13
+ claudeMdMode: 'prompt' | 'skip';
14
+ claudeMdPrompt: string | null;
15
+ plugins: string[];
16
+ }
17
+ export declare function runSetupPrompts(homeDir: string): Promise<SetupAnswers>;
@@ -0,0 +1,178 @@
1
+ import inquirer from 'inquirer';
2
+ import chalk from 'chalk';
3
+ function printDiscordBotGuide() {
4
+ const separator = '━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━';
5
+ console.log(`
6
+ ${separator}
7
+ ${chalk.bold('How to create a Discord bot for Onkol')}
8
+ ${separator}
9
+
10
+ ${chalk.bold('Step 1: Create a Discord Application')}
11
+ → Go to ${chalk.cyan('https://discord.com/developers/applications')}
12
+ → Click "New Application"
13
+ → Name it (e.g., "onkol-bot" or your node name)
14
+ → Click "Create"
15
+
16
+ ${chalk.bold('Step 2: Create the Bot & Get Token')}
17
+ → In your application, click "Bot" in the left sidebar
18
+ → Click "Reset Token"
19
+ → Copy the token — you'll need it in a moment
20
+ → ${chalk.yellow('IMPORTANT: You can only see the token once. Save it.')}
21
+
22
+ ${chalk.bold('Step 3: Enable Required Intents')}
23
+ → Still on the Bot page, scroll down to "Privileged Gateway Intents"
24
+ → Enable: "Message Content Intent"
25
+ → Click "Save Changes"
26
+
27
+ ${chalk.bold('Step 4: Invite the Bot to Your Server')}
28
+ → Click "OAuth2" in the left sidebar
29
+ → Click "URL Generator"
30
+ → Under "Scopes", check: bot
31
+ → Under "Bot Permissions", check:
32
+ ${chalk.gray('✓ View Channels')}
33
+ ${chalk.gray('✓ Send Messages')}
34
+ ${chalk.gray('✓ Send Messages in Threads')}
35
+ ${chalk.gray('✓ Read Message History')}
36
+ ${chalk.gray('✓ Attach Files')}
37
+ ${chalk.gray('✓ Add Reactions')}
38
+ ${chalk.gray('✓ Manage Channels (needed to create/delete worker channels)')}
39
+ → Copy the generated URL at the bottom
40
+ → Open it in your browser
41
+ → Select your Discord server and click "Authorize"
42
+
43
+ ${chalk.bold('Step 5: Get Your Server (Guild) ID')}
44
+ → In Discord, go to Settings → Advanced → Enable "Developer Mode"
45
+ → Right-click your server name → "Copy Server ID"
46
+ → You'll need this in the next question
47
+
48
+ ${chalk.bold('Step 6: Get Your Discord User ID')}
49
+ → In Discord, right-click your username → "Copy User ID"
50
+ → You'll need this to whitelist yourself
51
+ ${separator}
52
+ `);
53
+ }
54
+ export async function runSetupPrompts(homeDir) {
55
+ const preDiscordAnswers = await inquirer.prompt([
56
+ {
57
+ type: 'input',
58
+ name: 'installDir',
59
+ message: 'Where should Onkol live?',
60
+ default: `${homeDir}/onkol`,
61
+ },
62
+ {
63
+ type: 'input',
64
+ name: 'nodeName',
65
+ message: 'What should this node be called? (shows up on Discord)',
66
+ },
67
+ {
68
+ type: 'list',
69
+ name: 'botTokenHelp',
70
+ message: 'Do you have a Discord bot token ready?',
71
+ choices: [
72
+ { name: 'Yes, I have my token', value: 'ready' },
73
+ { name: 'No, show me how to create one', value: 'help' },
74
+ ],
75
+ },
76
+ ]);
77
+ if (preDiscordAnswers.botTokenHelp === 'help') {
78
+ printDiscordBotGuide();
79
+ }
80
+ const discordAndRestAnswers = await inquirer.prompt([
81
+ {
82
+ type: 'password',
83
+ name: 'botToken',
84
+ message: 'Discord bot token:',
85
+ mask: '*',
86
+ },
87
+ {
88
+ type: 'input',
89
+ name: 'guildId',
90
+ message: 'Discord server (guild) ID:',
91
+ },
92
+ {
93
+ type: 'input',
94
+ name: 'discordUserId',
95
+ message: 'Your Discord user ID (right-click your name > Copy User ID):',
96
+ },
97
+ {
98
+ type: 'list',
99
+ name: 'registryMode',
100
+ message: 'Do you have a registry file for this VM? (secrets, endpoints, ports)',
101
+ choices: [
102
+ { name: 'Yes, import from file', value: 'import' },
103
+ { name: 'Write a prompt — tell Claude what to find', value: 'prompt' },
104
+ { name: 'Skip for now', value: 'skip' },
105
+ ],
106
+ },
107
+ {
108
+ type: 'input',
109
+ name: 'registryPath',
110
+ message: 'Path to registry file:',
111
+ when: (a) => a.registryMode === 'import',
112
+ },
113
+ {
114
+ type: 'input',
115
+ name: 'registryPrompt',
116
+ message: 'Describe what Claude should find for the registry (secrets, endpoints, ports):',
117
+ when: (a) => a.registryMode === 'prompt',
118
+ },
119
+ {
120
+ type: 'list',
121
+ name: 'serviceMode',
122
+ message: 'Service summary for this VM?',
123
+ choices: [
124
+ { name: 'Auto-discover (scan for running services)', value: 'auto' },
125
+ { name: 'Import from file', value: 'import' },
126
+ { name: 'Write a prompt — tell Claude what to discover', value: 'prompt' },
127
+ { name: 'Skip for now', value: 'skip' },
128
+ ],
129
+ },
130
+ {
131
+ type: 'input',
132
+ name: 'serviceSummaryPath',
133
+ message: 'Path to service summary file:',
134
+ when: (a) => a.serviceMode === 'import',
135
+ },
136
+ {
137
+ type: 'input',
138
+ name: 'servicesPrompt',
139
+ message: 'Describe what Claude should discover about services on this VM:',
140
+ when: (a) => a.serviceMode === 'prompt',
141
+ },
142
+ {
143
+ type: 'list',
144
+ name: 'claudeMdMode',
145
+ message: 'Want to describe this project in plain language? Claude will convert it to a structured CLAUDE.md.',
146
+ choices: [
147
+ { name: 'Yes, write a description', value: 'prompt' },
148
+ { name: 'Skip (use default template)', value: 'skip' },
149
+ ],
150
+ },
151
+ {
152
+ type: 'input',
153
+ name: 'claudeMdPrompt',
154
+ message: 'Describe this project in plain language:',
155
+ when: (a) => a.claudeMdMode === 'prompt',
156
+ },
157
+ {
158
+ type: 'checkbox',
159
+ name: 'plugins',
160
+ message: 'Which Claude Code plugins should workers have?',
161
+ choices: [
162
+ { name: 'context7', value: 'context7', checked: true },
163
+ { name: 'superpowers', value: 'superpowers', checked: true },
164
+ { name: 'code-simplifier', value: 'code-simplifier', checked: true },
165
+ { name: 'frontend-design', value: 'frontend-design', checked: false },
166
+ ],
167
+ },
168
+ ]);
169
+ const answers = { ...preDiscordAnswers, ...discordAndRestAnswers };
170
+ return {
171
+ ...answers,
172
+ registryPath: answers.registryPath || null,
173
+ registryPrompt: answers.registryPrompt || null,
174
+ serviceSummaryPath: answers.serviceSummaryPath || null,
175
+ servicesPrompt: answers.servicesPrompt || null,
176
+ claudeMdPrompt: answers.claudeMdPrompt || null,
177
+ };
178
+ }
@@ -0,0 +1,2 @@
1
+ export declare function generateSystemdUnit(nodeName: string, user: string, onkolDir: string): string;
2
+ export declare function generateCrontab(onkolDir: string): string;