verbalcoding 0.2.8 → 0.2.10

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.
@@ -30,7 +30,9 @@ test('CLI includes npm-friendly setup and start commands', () => {
30
30
  const cli = fs.readFileSync(path.join(ROOT, 'scripts', 'cli.mjs'), 'utf8');
31
31
 
32
32
  assert.match(cli, /vc setup \[--yes\]/);
33
+ assert.match(cli, /vc setup token \[bot-token\]/);
33
34
  assert.match(cli, /command === 'setup'/);
35
+ assert.match(cli, /install\.mjs'\), \.\.\.argv\.slice\(1\)/);
34
36
  assert.match(cli, /VERBALCODING_SKIP_CLI_LINK/);
35
37
  assert.match(cli, /command === 'start'/);
36
38
  assert.match(cli, /run\.sh/);
@@ -53,6 +55,9 @@ test('npm setup supports non-interactive --yes mode', () => {
53
55
  const config = fs.readFileSync(path.join(ROOT, 'app-node', 'install_config.mjs'), 'utf8');
54
56
 
55
57
  assert.match(installer, /args\.includes\('--yes'\)/);
58
+ assert.match(installer, /configureDiscordToken/);
59
+ assert.match(installer, /DISCORD_BOT_TOKEN: token/);
60
+ assert.match(installer, /vc setup token/);
56
61
  assert.match(installer, /normalizeInstallAnswers\(process\.env\)/);
57
62
  assert.match(config, /vc start/);
58
63
  assert.doesNotMatch(config, /npm install -g \.\s+#/);
@@ -82,9 +87,28 @@ test('doctor auto-bootstraps fixable prerequisites by default', () => {
82
87
  assert.match(doctor, /--no-fix/);
83
88
  assert.match(doctor, /WHISPER_CPP_BIN/);
84
89
  assert.match(doctor, /EDGE_TTS_COMMAND/);
90
+ assert.match(doctor, /installHermesCliIfNeeded/);
91
+ assert.match(doctor, /NousResearch\/hermes-agent\/main\/scripts\/install\.sh/);
92
+ assert.match(doctor, /VERBALCODING_DOCTOR_INSTALL_HERMES/);
93
+ assert.match(doctor, /Discord bot setup:/);
94
+ assert.match(doctor, /vc setup token/);
95
+ assert.match(doctor, /discord\.com\/developers\/applications/);
85
96
  assert.match(cli, /doctor\.mjs'\), \.\.\.argv\.slice\(1\)/);
86
97
  });
87
98
 
99
+ test('setup summary guides Discord app creation and records client id', () => {
100
+ const installer = fs.readFileSync(path.join(ROOT, 'scripts', 'install.mjs'), 'utf8');
101
+ const config = fs.readFileSync(path.join(ROOT, 'app-node', 'install_config.mjs'), 'utf8');
102
+
103
+ assert.match(installer, /Discord application\/client ID for invite URL/);
104
+ assert.match(config, /DISCORD_CLIENT_ID/);
105
+ assert.match(config, /Discord app setup:/);
106
+ assert.match(config, /https:\/\/discord\.com\/developers\/applications/);
107
+ assert.match(config, /vc bot invite <client-id>/);
108
+ assert.match(config, /vc setup token/);
109
+ assert.match(config, /buildDiscordBotInviteUrl\(\{ clientId: values\.DISCORD_CLIENT_ID \}\)/);
110
+ });
111
+
88
112
  test('Ubuntu Docker smoke script validates clean install without secrets', () => {
89
113
  const script = fs.readFileSync(path.join(ROOT, 'scripts', 'docker_ubuntu_smoke.sh'), 'utf8');
90
114
 
@@ -26,6 +26,7 @@ export function normalizeInstallAnswers(input = {}) {
26
26
  const out = {
27
27
  AGENT_BACKEND: normalizedHarness,
28
28
  DISCORD_BOT_TOKEN: clean(input.discordBotToken || input.DISCORD_BOT_TOKEN),
29
+ DISCORD_CLIENT_ID: clean(input.discordClientId || input.DISCORD_CLIENT_ID || input.applicationId || input.APPLICATION_ID),
29
30
  DISCORD_ALLOWED_USERS: clean(input.allowedUsers || input.DISCORD_ALLOWED_USERS),
30
31
  AUTO_JOIN_VOICE_CHANNELS: clean(input.autoJoinVoiceChannels || input.AUTO_JOIN_VOICE_CHANNELS, '일반,General,general'),
31
32
  TRANSCRIPT_CHANNEL_ID: clean(input.transcriptChannelId || input.TRANSCRIPT_CHANNEL_ID),
@@ -101,6 +102,7 @@ export function slugifyInstanceName(name) {
101
102
  export function buildEnvFile(values = {}) {
102
103
  const ordered = [
103
104
  'DISCORD_BOT_TOKEN',
105
+ 'DISCORD_CLIENT_ID',
104
106
  'DISCORD_ALLOWED_USERS',
105
107
  'AUTO_JOIN_VOICE_CHANNELS',
106
108
  'TRANSCRIPT_CHANNEL_ID',
@@ -243,9 +245,18 @@ export function parseKeyValueEnv(text) {
243
245
 
244
246
  export function renderInstallSummary(values = {}) {
245
247
  const backend = values.AGENT_BACKEND || 'hermes';
248
+ const inviteUrl = values.DISCORD_CLIENT_ID ? buildDiscordBotInviteUrl({ clientId: values.DISCORD_CLIENT_ID }) : '';
246
249
  return [
247
250
  `Configured Discord voice bridge for harness: ${backend}`,
248
251
  '',
252
+ 'Discord app setup:',
253
+ ' 1. Create an app: https://discord.com/developers/applications',
254
+ ' 2. Bot tab: Add Bot, enable Message Content Intent, copy/reset the token.',
255
+ ' 3. Register the token with `vc setup token` (or `vc setup token <token>`).',
256
+ inviteUrl ? ` 4. Invite URL: ${inviteUrl}` : ' 4. Invite URL: vc bot invite <client-id>',
257
+ ' 5. Make sure the bot can read/send text and connect/speak in voice.',
258
+ 'You may skip the token now and run `vc setup token` anytime later.',
259
+ '',
249
260
  'Next commands:',
250
261
  ' vc doctor',
251
262
  ' vc start',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "verbalcoding",
3
- "version": "0.2.8",
3
+ "version": "0.2.10",
4
4
  "description": "Discord voice bridge for CLI coding agents.",
5
5
  "license": "MIT",
6
6
  "repository": {
package/scripts/cli.mjs CHANGED
@@ -31,6 +31,7 @@ function usage() {
31
31
 
32
32
  Usage:
33
33
  vc setup [--yes] [--no-wizard] [--skip-system] [--skip-model] [--skip-edge-tts]
34
+ vc setup token [bot-token] [--client-id <client-id>]
34
35
  vc start
35
36
  vc status
36
37
  vc language <ko|en|auto>
@@ -48,6 +49,7 @@ Usage:
48
49
  Examples:
49
50
  npx verbalcoding setup --yes
50
51
  vc setup --yes
52
+ vc setup token
51
53
  vc start
52
54
  vc language en
53
55
  vc language ko
@@ -275,6 +277,11 @@ async function main(argv = process.argv.slice(2)) {
275
277
  }
276
278
  if (command === 'setup' || command === 'install') {
277
279
  const { spawnSync } = await import('node:child_process');
280
+ if (argv[1] === 'token' || argv[1] === 'discord' || argv[1] === 'bot-token') {
281
+ const result = spawnSync(process.execPath, [path.join(ROOT, 'scripts', 'install.mjs'), ...argv.slice(1)], { stdio: 'inherit', cwd: ROOT });
282
+ process.exitCode = result.status ?? 1;
283
+ return;
284
+ }
278
285
  const script = path.join(ROOT, 'scripts', 'install.sh');
279
286
  const result = spawnSync('bash', [script, ...argv.slice(1)], {
280
287
  stdio: 'inherit',
@@ -61,10 +61,14 @@ function isExecutable(file) {
61
61
  }
62
62
 
63
63
  function commandExists(command) {
64
+ const extraPath = [
65
+ path.join(ROOT, '.local', 'bin'),
66
+ path.join(process.env.HOME || '', '.local', 'bin'),
67
+ ].filter(Boolean).join(':');
64
68
  const result = spawnSync('bash', ['-lc', `command -v ${JSON.stringify(command)}`], {
65
69
  cwd: ROOT,
66
70
  encoding: 'utf8',
67
- env: { ...process.env, PATH: `${path.join(ROOT, '.local', 'bin')}:${process.env.PATH || ''}` },
71
+ env: { ...process.env, PATH: `${extraPath}:${process.env.PATH || ''}` },
68
72
  });
69
73
  return result.status === 0 ? result.stdout.trim() : '';
70
74
  }
@@ -90,6 +94,7 @@ function note(label, detail = '') {
90
94
 
91
95
  function fixablePrerequisites(env) {
92
96
  const ttsBackend = (env.TTS_BACKEND || 'edge').toLowerCase();
97
+ const backend = (env.AGENT_BACKEND || 'hermes').toLowerCase();
93
98
  const missing = [];
94
99
  if (!commandExists('ffmpeg')) missing.push('ffmpeg');
95
100
  if (!resolveCommand(env.WHISPER_CPP_BIN || 'whisper-cli', [path.join(ROOT, '.local', 'bin', 'whisper-cli')])) missing.push('whisper-cli');
@@ -99,9 +104,53 @@ function fixablePrerequisites(env) {
99
104
  const edgeCommand = env.EDGE_TTS_COMMAND || env.TTS_EDGE_COMMAND || 'edge-tts';
100
105
  if (!resolveCommand(edgeCommand, [path.join(ROOT, '.venv-tts', 'bin', 'edge-tts')])) missing.push('edge-tts');
101
106
  }
107
+ if (backend === 'hermes' && !commandExists('hermes')) missing.push('hermes CLI');
102
108
  return missing;
103
109
  }
104
110
 
111
+ function installHermesCliIfNeeded(env) {
112
+ const backend = (env.AGENT_BACKEND || 'hermes').toLowerCase();
113
+ if (backend !== 'hermes' || commandExists('hermes')) return false;
114
+ if (process.platform === 'win32') {
115
+ console.log('Skipping Hermes CLI auto-install: Windows is not supported by VerbalCoding yet.');
116
+ return false;
117
+ }
118
+ if (['0', 'false', 'no', 'off'].includes(String(process.env.VERBALCODING_DOCTOR_INSTALL_HERMES || '1').toLowerCase())) {
119
+ console.log('Skipping Hermes CLI auto-install because VERBALCODING_DOCTOR_INSTALL_HERMES is off.');
120
+ return false;
121
+ }
122
+ if (!commandExists('curl')) {
123
+ console.log('Skipping Hermes CLI auto-install: curl is missing.');
124
+ return false;
125
+ }
126
+ console.log('VerbalCoding doctor: missing hermes CLI; installing Hermes Agent...');
127
+ const result = spawnSync('bash', ['-lc', 'curl -fsSL https://raw.githubusercontent.com/NousResearch/hermes-agent/main/scripts/install.sh | bash'], {
128
+ cwd: ROOT,
129
+ stdio: 'inherit',
130
+ env: process.env,
131
+ });
132
+ if (result.status !== 0) {
133
+ console.log(`Hermes installer exited with status ${result.status}. Continuing with checks.`);
134
+ }
135
+ return true;
136
+ }
137
+
138
+ function discordSetupGuidance(env) {
139
+ const clientId = env.DISCORD_CLIENT_ID || env.APPLICATION_ID || '';
140
+ const lines = [
141
+ 'Discord bot setup:',
142
+ ' 1. Open https://discord.com/developers/applications and create an application.',
143
+ ' 2. Bot tab: Add Bot, enable Message Content Intent, then Reset/Copy Token.',
144
+ ` 3. Register it with \`vc setup token\` (or \`vc setup token <token>\`).`,
145
+ ' 4. OAuth2 tab: copy the Application/Client ID.',
146
+ clientId
147
+ ? ` 5. Invite the bot: vc bot invite ${clientId}`
148
+ : ' 5. Invite the bot: vc bot invite <client-id>',
149
+ ' 6. Give it text channel send/read plus voice connect/speak permissions, then rerun vc doctor.',
150
+ ];
151
+ return lines.join('\n');
152
+ }
153
+
105
154
  function persistDiscoveredLocalHelpers(env) {
106
155
  const updates = {};
107
156
  const localWhisper = path.join(ROOT, '.local', 'bin', 'whisper-cli');
@@ -141,6 +190,13 @@ if (autoFixEnabled && missingBeforeFix.length > 0) {
141
190
  console.log('');
142
191
  env = mergeEnv();
143
192
  }
193
+ if (autoFixEnabled) {
194
+ const hermesAttempted = installHermesCliIfNeeded(env);
195
+ if (hermesAttempted) {
196
+ console.log('');
197
+ env = mergeEnv();
198
+ }
199
+ }
144
200
 
145
201
  const backend = (env.AGENT_BACKEND || 'hermes').toLowerCase();
146
202
  const ttsBackend = (env.TTS_BACKEND || 'edge').toLowerCase();
@@ -166,6 +222,9 @@ ok = check('whisper-cli', whisperCommand, whisperCommand || 'missing') && ok;
166
222
  const modelPath = path.resolve(ROOT, env.WHISPER_CPP_MODEL || 'models/ggml-small-q5_1.bin');
167
223
  ok = check('whisper.cpp model', fs.existsSync(modelPath), path.relative(ROOT, modelPath)) && ok;
168
224
  ok = check('Discord bot token configured', Boolean(env.DISCORD_BOT_TOKEN || env.DISCORD_TOKEN), (env.DISCORD_BOT_TOKEN || env.DISCORD_TOKEN) ? '[REDACTED]' : 'missing DISCORD_BOT_TOKEN') && ok;
225
+ if (!(env.DISCORD_BOT_TOKEN || env.DISCORD_TOKEN)) {
226
+ console.log(discordSetupGuidance(env));
227
+ }
169
228
  note('Allowed users configured', env.DISCORD_ALLOWED_USERS ? '[REDACTED]' : 'not set; bot may accept all users depending on config');
170
229
  note('Auto-join channels', env.AUTO_JOIN_VOICE_CHANNELS || 'default: 일반,General,general');
171
230
  note('Verbose progress default', ['1', 'true', 'yes', 'on'].includes(String(env.AGENT_VERBOSE_PROGRESS || env.VERBALCODING_VERBOSE_PROGRESS || '0').toLowerCase()) ? 'on' : 'off');
@@ -15,8 +15,73 @@ async function ask(question, fallback = '', options = {}) {
15
15
  return answer || fallback;
16
16
  }
17
17
 
18
+ function quoteEnv(value) {
19
+ return JSON.stringify(String(value ?? ''));
20
+ }
21
+
22
+ function upsertEnvFile(file, updates) {
23
+ const existing = fs.existsSync(file) ? fs.readFileSync(file, 'utf8') : '';
24
+ const seen = new Set();
25
+ const lines = existing.split(/\r?\n/).map(raw => {
26
+ const line = raw.trim();
27
+ if (!line || line.startsWith('#') || !line.includes('=')) return raw;
28
+ const idx = line.indexOf('=');
29
+ const key = line.slice(0, idx).trim().replace(/^export\s+/, '');
30
+ if (!(key in updates)) return raw;
31
+ seen.add(key);
32
+ return `${key}=${quoteEnv(updates[key])}`;
33
+ });
34
+ for (const [key, value] of Object.entries(updates)) {
35
+ if (!seen.has(key)) lines.push(`${key}=${quoteEnv(value)}`);
36
+ }
37
+ const text = `${lines.filter((line, index, arr) => line !== '' || index < arr.length - 1).join('\n')}\n`;
38
+ fs.writeFileSync(file, text, { mode: 0o600 });
39
+ }
40
+
41
+ function argValue(args, name) {
42
+ const idx = args.indexOf(name);
43
+ if (idx < 0) return '';
44
+ const value = args[idx + 1] || '';
45
+ return value.startsWith('--') ? '' : value;
46
+ }
47
+
48
+ async function configureDiscordToken(args) {
49
+ const envPath = path.join(ROOT, '.env');
50
+ const tokenArg = args.find((arg, idx) => idx > 0 && !arg.startsWith('--')) || argValue(args, '--token');
51
+ const clientIdArg = argValue(args, '--client-id') || argValue(args, '--application-id');
52
+ let token = tokenArg || process.env.DISCORD_BOT_TOKEN || '';
53
+ let clientId = clientIdArg || process.env.DISCORD_CLIENT_ID || '';
54
+ if (!token) {
55
+ globalThis.__rl = readline.createInterface({ input, output });
56
+ try {
57
+ console.log('Discord bot token setup');
58
+ console.log('Create/copy the token at https://discord.com/developers/applications → your app → Bot.');
59
+ token = await ask('Discord bot token (DISCORD_BOT_TOKEN)', '', { fallbackLabel: '' });
60
+ clientId = await ask('Discord application/client ID for invite URL (optional)', clientId, { fallbackLabel: clientId ? 'keep existing' : 'skip' });
61
+ } finally {
62
+ globalThis.__rl.close();
63
+ globalThis.__rl = null;
64
+ }
65
+ }
66
+ if (!token) {
67
+ console.error('No Discord bot token provided. Nothing changed.');
68
+ process.exitCode = 2;
69
+ return;
70
+ }
71
+ const updates = { DISCORD_BOT_TOKEN: token };
72
+ if (clientId) updates.DISCORD_CLIENT_ID = clientId;
73
+ upsertEnvFile(envPath, updates);
74
+ console.log(`Updated ${envPath}`);
75
+ console.log('Discord bot token saved. Run `vc doctor` to verify. You can update it anytime with `vc setup token`.');
76
+ if (clientId) console.log(`Invite URL: vc bot invite ${clientId}`);
77
+ }
78
+
18
79
  async function main() {
19
80
  const args = process.argv.slice(2);
81
+ if (args[0] === 'token' || args[0] === 'discord' || args[0] === 'bot-token') {
82
+ await configureDiscordToken(args);
83
+ return;
84
+ }
20
85
  const yes = args.includes('--yes') || args.includes('-y');
21
86
  if (args[0] === 'instance' || args.includes('--instance')) {
22
87
  const { spawnSync } = await import('node:child_process');
@@ -53,6 +118,7 @@ async function main() {
53
118
  }
54
119
  const existingDiscordBotToken = process.env.DISCORD_BOT_TOKEN || '';
55
120
  const discordBotToken = await ask('Discord bot token (DISCORD_BOT_TOKEN)', existingDiscordBotToken, { fallbackLabel: existingDiscordBotToken ? 'keep existing' : '' });
121
+ const discordClientId = await ask('Discord application/client ID for invite URL', process.env.DISCORD_CLIENT_ID || '');
56
122
  const allowedUsers = await ask('Allowed Discord user IDs, comma-separated', process.env.DISCORD_ALLOWED_USERS || '');
57
123
  const autoJoinVoiceChannels = await ask('Auto-join voice channel names', process.env.AUTO_JOIN_VOICE_CHANNELS || '일반,General,general');
58
124
  const transcriptChannelId = await ask('Transcript text channel/thread ID', process.env.TRANSCRIPT_CHANNEL_ID || '');
@@ -80,6 +146,7 @@ async function main() {
80
146
  agentLabel,
81
147
  agentCommand,
82
148
  discordBotToken,
149
+ discordClientId,
83
150
  allowedUsers,
84
151
  autoJoinVoiceChannels,
85
152
  transcriptChannelId,