omnikey-cli 1.5.8 → 1.6.1

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,247 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.aiProviderRouter = aiProviderRouter;
7
+ const express_1 = __importDefault(require("express"));
8
+ const zod_1 = __importDefault(require("zod"));
9
+ const fs_1 = __importDefault(require("fs"));
10
+ const path_1 = __importDefault(require("path"));
11
+ const os_1 = __importDefault(require("os"));
12
+ const authMiddleware_1 = require("./authMiddleware");
13
+ const config_1 = require("./config");
14
+ const logger_1 = require("./logger");
15
+ const providerEnum = zod_1.default.enum(['openai', 'anthropic', 'gemini', 'nemotron']);
16
+ const putSchema = zod_1.default.object({
17
+ apiKey: zod_1.default.string().min(1).max(4096),
18
+ baseUrl: zod_1.default
19
+ .string()
20
+ .max(1000)
21
+ .url({ message: 'baseUrl must be a valid URL.' })
22
+ .nullable()
23
+ .optional(),
24
+ });
25
+ /** Mapping from provider → env var that holds its API key. */
26
+ const PROVIDER_ENV_KEY = {
27
+ openai: 'OPENAI_API_KEY',
28
+ anthropic: 'ANTHROPIC_API_KEY',
29
+ gemini: 'GEMINI_API_KEY',
30
+ nemotron: 'NEMOTRON_API_KEY',
31
+ };
32
+ /** Legacy aliases that should be cleared whenever the canonical env is rewritten. */
33
+ const PROVIDER_LEGACY_ALIASES = {
34
+ nemotron: ['NVIDIA_API_KEY'],
35
+ };
36
+ function getConfigPath() {
37
+ const home = process.env.HOME || process.env.USERPROFILE || os_1.default.homedir();
38
+ return path_1.default.join(home, '.omnikey', 'config.json');
39
+ }
40
+ function readConfigFile() {
41
+ const configPath = getConfigPath();
42
+ try {
43
+ if (fs_1.default.existsSync(configPath)) {
44
+ const raw = fs_1.default.readFileSync(configPath, 'utf-8');
45
+ const parsed = JSON.parse(raw);
46
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
47
+ return parsed;
48
+ }
49
+ }
50
+ }
51
+ catch (err) {
52
+ logger_1.logger.warn('Could not read ~/.omnikey/config.json — treating as empty.', { error: err });
53
+ }
54
+ return {};
55
+ }
56
+ function writeConfigFile(data) {
57
+ const configPath = getConfigPath();
58
+ fs_1.default.mkdirSync(path_1.default.dirname(configPath), { recursive: true });
59
+ fs_1.default.writeFileSync(configPath, JSON.stringify(data, null, 2), 'utf-8');
60
+ }
61
+ /** Mask an API key so a small prefix/suffix is visible (e.g. `sk-…AB12`). */
62
+ function maskApiKey(key) {
63
+ if (!key)
64
+ return '';
65
+ if (key.length <= 8)
66
+ return '••••••••';
67
+ return `${key.slice(0, 3)}••••••••${key.slice(-4)}`;
68
+ }
69
+ /** Reads any provider key (canonical or legacy alias) out of the config object. */
70
+ function readProviderKey(cfg, provider) {
71
+ const canonical = cfg[PROVIDER_ENV_KEY[provider]];
72
+ if (canonical && typeof canonical === 'string' && canonical.length > 0)
73
+ return canonical;
74
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
75
+ const v = cfg[alias];
76
+ if (v && typeof v === 'string' && v.length > 0)
77
+ return v;
78
+ }
79
+ return undefined;
80
+ }
81
+ function describeProvider(provider, cfg) {
82
+ const key = readProviderKey(cfg, provider);
83
+ return {
84
+ provider,
85
+ isConfigured: Boolean(key),
86
+ apiKeyMasked: maskApiKey(key),
87
+ baseUrl: provider === 'nemotron'
88
+ ? (typeof cfg.NEMOTRON_BASE_URL === 'string' ? cfg.NEMOTRON_BASE_URL : null)
89
+ : null,
90
+ };
91
+ }
92
+ function readActiveProvider(cfg) {
93
+ const raw = cfg.AI_PROVIDER;
94
+ if (raw === 'openai' || raw === 'anthropic' || raw === 'gemini' || raw === 'nemotron') {
95
+ return raw;
96
+ }
97
+ // Fall back to the provider currently bound to the running process (config.ts
98
+ // already auto-detects from the first configured key in env).
99
+ return config_1.config.aiProvider;
100
+ }
101
+ /**
102
+ * Triggers a graceful daemon restart. The process is supervised by launchd
103
+ * (macOS) / NSSM (Windows) with auto-restart enabled, so exiting will cause
104
+ * the supervisor to relaunch the API with the freshly written config.json.
105
+ */
106
+ function scheduleDaemonRestart(reason) {
107
+ setTimeout(() => {
108
+ logger_1.logger.info(`Exiting process to trigger supervised restart: ${reason}`);
109
+ process.exit(0);
110
+ }, 500);
111
+ }
112
+ function aiProviderRouter() {
113
+ const router = express_1.default.Router();
114
+ /** GET /api/providers — list which providers have a key in config.json. */
115
+ router.get('/', authMiddleware_1.authMiddleware, async (_req, res) => {
116
+ const { logger: reqLogger } = res.locals;
117
+ try {
118
+ const cfg = readConfigFile();
119
+ const providers = ['openai', 'anthropic', 'gemini', 'nemotron'];
120
+ const active = readActiveProvider(cfg);
121
+ res.json({
122
+ providers: providers.map((p) => describeProvider(p, cfg)),
123
+ activeProvider: active,
124
+ // The in-process provider may differ from cfg.AI_PROVIDER if the user
125
+ // changed config.json out of band — surface both so the UI can warn.
126
+ runtimeProvider: config_1.config.aiProvider,
127
+ });
128
+ }
129
+ catch (err) {
130
+ reqLogger.error('Error reading provider config.', { error: err });
131
+ res.status(500).json({ error: 'Failed to read provider config.' });
132
+ }
133
+ });
134
+ /** PUT /api/providers/:provider — store/update API key for that provider. */
135
+ router.put('/:provider', authMiddleware_1.authMiddleware, async (req, res) => {
136
+ const { logger: reqLogger } = res.locals;
137
+ const providerParam = providerEnum.safeParse(req.params.provider);
138
+ if (!providerParam.success) {
139
+ return res.status(400).json({ error: 'Unknown provider.' });
140
+ }
141
+ const provider = providerParam.data;
142
+ try {
143
+ const parsed = putSchema.parse(req.body);
144
+ const cfg = readConfigFile();
145
+ // Always clear legacy aliases so the canonical env wins on next boot.
146
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
147
+ delete cfg[alias];
148
+ }
149
+ cfg[PROVIDER_ENV_KEY[provider]] = parsed.apiKey;
150
+ if (provider === 'nemotron') {
151
+ if (parsed.baseUrl) {
152
+ cfg.NEMOTRON_BASE_URL = parsed.baseUrl;
153
+ }
154
+ else if (parsed.baseUrl === null) {
155
+ delete cfg.NEMOTRON_BASE_URL;
156
+ }
157
+ }
158
+ writeConfigFile(cfg);
159
+ // If the user updated the currently-active provider's key, the running
160
+ // process is now using a stale key — schedule a restart so it picks
161
+ // the new one up. We only restart when AI_PROVIDER is *explicitly*
162
+ // pinned in config.json, because the auto-detected fallback may differ
163
+ // from what the user is configuring and we don't want to bounce the
164
+ // server on first-time key save.
165
+ const explicitActive = typeof cfg.AI_PROVIDER === 'string' ? cfg.AI_PROVIDER : null;
166
+ let restartScheduled = false;
167
+ if (explicitActive === provider) {
168
+ scheduleDaemonRestart(`updated active provider key (${provider})`);
169
+ restartScheduled = true;
170
+ }
171
+ res.json({
172
+ ...describeProvider(provider, cfg),
173
+ restartScheduled,
174
+ });
175
+ }
176
+ catch (err) {
177
+ reqLogger.error('Error storing provider key.', { error: err });
178
+ if (err instanceof zod_1.default.ZodError) {
179
+ return res.status(400).json({ error: 'Invalid provider data.' });
180
+ }
181
+ res.status(500).json({ error: 'Failed to store provider key.' });
182
+ }
183
+ });
184
+ /** DELETE /api/providers/:provider — remove the saved key. */
185
+ router.delete('/:provider', authMiddleware_1.authMiddleware, async (req, res) => {
186
+ const { logger: reqLogger } = res.locals;
187
+ const providerParam = providerEnum.safeParse(req.params.provider);
188
+ if (!providerParam.success) {
189
+ return res.status(400).json({ error: 'Unknown provider.' });
190
+ }
191
+ const provider = providerParam.data;
192
+ try {
193
+ const cfg = readConfigFile();
194
+ const active = readActiveProvider(cfg);
195
+ if (active === provider) {
196
+ return res.status(409).json({
197
+ error: 'Cannot remove the active provider. Activate a different provider first, then remove this one.',
198
+ });
199
+ }
200
+ delete cfg[PROVIDER_ENV_KEY[provider]];
201
+ for (const alias of PROVIDER_LEGACY_ALIASES[provider] ?? []) {
202
+ delete cfg[alias];
203
+ }
204
+ if (provider === 'nemotron') {
205
+ delete cfg.NEMOTRON_BASE_URL;
206
+ }
207
+ writeConfigFile(cfg);
208
+ res.status(204).send();
209
+ }
210
+ catch (err) {
211
+ reqLogger.error('Error removing provider key.', { error: err });
212
+ res.status(500).json({ error: 'Failed to remove provider key.' });
213
+ }
214
+ });
215
+ /** POST /api/providers/:provider/activate — switch AI_PROVIDER + restart. */
216
+ router.post('/:provider/activate', authMiddleware_1.authMiddleware, async (req, res) => {
217
+ const { logger: reqLogger } = res.locals;
218
+ const providerParam = providerEnum.safeParse(req.params.provider);
219
+ if (!providerParam.success) {
220
+ return res.status(400).json({ error: 'Unknown provider.' });
221
+ }
222
+ const provider = providerParam.data;
223
+ try {
224
+ const cfg = readConfigFile();
225
+ const key = readProviderKey(cfg, provider);
226
+ if (!key) {
227
+ return res.status(400).json({
228
+ error: `No API key saved for provider "${provider}". Save a key first, then activate.`,
229
+ });
230
+ }
231
+ cfg.AI_PROVIDER = provider;
232
+ writeConfigFile(cfg);
233
+ res.json({
234
+ provider,
235
+ activeProvider: provider,
236
+ restartScheduled: true,
237
+ message: 'Provider activated. Server will restart shortly to apply the change.',
238
+ });
239
+ scheduleDaemonRestart(`activated provider ${provider}`);
240
+ }
241
+ catch (err) {
242
+ reqLogger.error('Error activating provider.', { error: err });
243
+ res.status(500).json({ error: 'Failed to activate provider.' });
244
+ }
245
+ });
246
+ return router;
247
+ }
@@ -45,13 +45,16 @@ function getSqlitePath() {
45
45
  }
46
46
  function getAIProvider() {
47
47
  const value = getEnv('AI_PROVIDER', false);
48
- if (value === 'gemini' || value === 'anthropic' || value === 'openai')
48
+ if (value === 'gemini' || value === 'anthropic' || value === 'openai' || value === 'nemotron') {
49
49
  return value;
50
+ }
50
51
  // Auto-detect from available keys
51
52
  if (getEnv('ANTHROPIC_API_KEY', false))
52
53
  return 'anthropic';
53
54
  if (getEnv('GEMINI_API_KEY', false))
54
55
  return 'gemini';
56
+ if (getEnv('NVIDIA_API_KEY', false) || getEnv('NEMOTRON_API_KEY', false))
57
+ return 'nemotron';
55
58
  return 'openai';
56
59
  }
57
60
  function getActiveApiKey(provider) {
@@ -61,6 +64,14 @@ function getActiveApiKey(provider) {
61
64
  return getEnv('ANTHROPIC_API_KEY', true);
62
65
  if (provider === 'gemini')
63
66
  return getEnv('GEMINI_API_KEY', true);
67
+ if (provider === 'nemotron') {
68
+ // Accept either NVIDIA_API_KEY (default name on build.nvidia.com) or
69
+ // NEMOTRON_API_KEY (more explicit). The latter wins if both are set.
70
+ const explicit = getEnv('NEMOTRON_API_KEY', false);
71
+ if (explicit)
72
+ return explicit;
73
+ return getEnv('NVIDIA_API_KEY', true);
74
+ }
64
75
  throw new Error(`Unknown AI provider: ${provider}`);
65
76
  }
66
77
  const _provider = getAIProvider();
@@ -73,6 +84,10 @@ exports.config = {
73
84
  aiApiKey: getActiveApiKey(_provider),
74
85
  // Legacy — kept for backwards compatibility; may be undefined when using another provider
75
86
  openaiApiKey: getEnv('OPENAI_API_KEY', false),
87
+ // Optional override for the NVIDIA NIM endpoint. Defaults to the public
88
+ // `https://integrate.api.nvidia.com/v1` gateway when unset. Point this at a
89
+ // self-hosted NIM (e.g. `http://my-nim-host:8000/v1`) to use private weights.
90
+ nemotronBaseUrl: getEnv('NEMOTRON_BASE_URL', false),
76
91
  // Database
77
92
  databaseUrl: getEnv('DATABASE_URL', getBooleanEnv('IS_SELF_HOSTED', false) ? false : true),
78
93
  dbLogging: getBooleanEnv('DB_LOGGING', false),
@@ -30,7 +30,11 @@ else if (config_1.config.databaseUrl) {
30
30
  }
31
31
  const COLUMN_MIGRATIONS = [
32
32
  // Added: context-window tracking (prompt token count of last API call)
33
- { table: 'agent_sessions', column: 'last_prompt_tokens', definition: 'INTEGER NOT NULL DEFAULT 0' },
33
+ {
34
+ table: 'agent_sessions',
35
+ column: 'last_prompt_tokens',
36
+ definition: 'INTEGER NOT NULL DEFAULT 0',
37
+ },
34
38
  // Added: project grouping
35
39
  { table: 'agent_sessions', column: 'group_name', definition: 'VARCHAR(255)' },
36
40
  { table: 'agent_sessions', column: 'group_description', definition: 'TEXT' },
@@ -15,6 +15,7 @@ const logger_1 = require("./logger");
15
15
  const taskInstructionRoutes_1 = require("./taskInstructionRoutes");
16
16
  const scheduledJobRoutes_1 = require("./scheduledJobRoutes");
17
17
  const mcpServerRoutes_1 = require("./mcpServerRoutes");
18
+ const aiProviderRoutes_1 = require("./aiProviderRoutes");
18
19
  const spawn_1 = require("./workers/spawn");
19
20
  const scheduledJobWorkerClient_1 = require("./workers/scheduledJobWorkerClient");
20
21
  const config_1 = require("./config");
@@ -36,6 +37,7 @@ app.use('/api/feature', (0, featureRoutes_1.createFeatureRouter)());
36
37
  app.use('/api/instructions', (0, taskInstructionRoutes_1.taskInstructionRouter)());
37
38
  app.use('/api/scheduled-jobs', (0, scheduledJobRoutes_1.scheduledJobRouter)());
38
39
  app.use('/api/mcp-servers', (0, mcpServerRoutes_1.mcpServerRouter)());
40
+ app.use('/api/providers', (0, aiProviderRoutes_1.aiProviderRouter)());
39
41
  app.use('/api/agent', (0, agentServer_1.createAgentRouter)());
40
42
  app.get('/macos/download', (_req, res) => {
41
43
  const dmgPath = path_1.default.join(process.cwd(), 'macOS', 'OmniKeyAI.dmg');
@@ -43,21 +45,25 @@ app.get('/macos/download', (_req, res) => {
43
45
  res.status(404).send('File not found.');
44
46
  return;
45
47
  }
48
+ let fileSize = 0;
49
+ try {
50
+ fileSize = fs_1.default.statSync(dmgPath).size;
51
+ }
52
+ catch (_) { }
46
53
  res.set({
47
54
  'Content-Type': 'application/octet-stream',
48
55
  'Content-Disposition': 'attachment; filename="OmniKeyAI.dmg"',
49
- 'Content-Encoding': 'gzip',
56
+ ...(fileSize ? { 'Content-Length': String(fileSize) } : {}),
50
57
  });
51
58
  (0, bucket_adapter_1.incrementDownloadCount)('macos').catch(() => { });
52
59
  const fileStream = fs_1.default.createReadStream(dmgPath);
53
- const gzip = zlib_1.default.createGzip();
54
60
  fileStream.on('error', (err) => {
55
61
  logger_1.logger.error('Failed to send OmniKeyAI.dmg for download.', { error: err });
56
62
  if (!res.headersSent) {
57
63
  res.status(500).send('Unable to download file.');
58
64
  }
59
65
  });
60
- fileStream.pipe(gzip).pipe(res);
66
+ fileStream.pipe(res);
61
67
  });
62
68
  // Sparkle appcast feed for macOS updates.
63
69
  // This feed uses the existing /macos/download endpoint as the
@@ -181,6 +187,24 @@ app.get('/downloads/stats', async (_req, res) => {
181
187
  app.get('/health', (_req, res) => {
182
188
  res.json({ status: 'ok' });
183
189
  });
190
+ app.get('/install.sh', (_req, res) => {
191
+ const scriptPath = path_1.default.join(process.cwd(), 'install.sh');
192
+ if (!fs_1.default.existsSync(scriptPath)) {
193
+ res.status(404).send('Not found.');
194
+ return;
195
+ }
196
+ res.set('Content-Type', 'text/plain; charset=utf-8');
197
+ res.sendFile(scriptPath);
198
+ });
199
+ app.get('/install.ps1', (_req, res) => {
200
+ const scriptPath = path_1.default.join(process.cwd(), 'install.ps1');
201
+ if (!fs_1.default.existsSync(scriptPath)) {
202
+ res.status(404).send('Not found.');
203
+ return;
204
+ }
205
+ res.set('Content-Type', 'text/plain; charset=utf-8');
206
+ res.sendFile(scriptPath);
207
+ });
184
208
  app.get('*', (_req, res) => {
185
209
  res.sendFile(path_1.default.join(process.cwd(), 'public', 'index.html'));
186
210
  });
@@ -165,11 +165,23 @@ function mcpServerRouter() {
165
165
  // When transport changes, clear fields incompatible with the new transport so
166
166
  // stale credentials/config never persist across a transport switch.
167
167
  const command = transportChanged
168
- ? (transport === 'stdio' ? (parsed.command !== undefined ? parsed.command : server.command) : null)
169
- : (parsed.command !== undefined ? parsed.command : server.command);
168
+ ? transport === 'stdio'
169
+ ? parsed.command !== undefined
170
+ ? parsed.command
171
+ : server.command
172
+ : null
173
+ : parsed.command !== undefined
174
+ ? parsed.command
175
+ : server.command;
170
176
  const url = transportChanged
171
- ? (transport !== 'stdio' ? (parsed.url !== undefined ? parsed.url : server.url) : null)
172
- : (parsed.url !== undefined ? parsed.url : server.url);
177
+ ? transport !== 'stdio'
178
+ ? parsed.url !== undefined
179
+ ? parsed.url
180
+ : server.url
181
+ : null
182
+ : parsed.url !== undefined
183
+ ? parsed.url
184
+ : server.url;
173
185
  const args = transportChanged && transport !== 'stdio' ? [] : (parsed.args ?? server.args);
174
186
  const env = transportChanged && transport !== 'stdio' ? {} : (parsed.env ?? server.env);
175
187
  const headers = transportChanged && transport === 'stdio' ? {} : (parsed.headers ?? server.headers);
@@ -14,7 +14,10 @@ const CRON_REGEX = /^(\S+\s){4}\S+$/;
14
14
  const jobSchema = zod_1.default.object({
15
15
  label: zod_1.default.string().min(1).max(200),
16
16
  prompt: zod_1.default.string().min(1),
17
- cronExpression: zod_1.default.string().regex(CRON_REGEX, 'Invalid cron expression (must be 5 fields)').optional(),
17
+ cronExpression: zod_1.default
18
+ .string()
19
+ .regex(CRON_REGEX, 'Invalid cron expression (must be 5 fields)')
20
+ .optional(),
18
21
  runAt: zod_1.default.string().optional(),
19
22
  isActive: zod_1.default.boolean().optional(),
20
23
  sessionId: zod_1.default.string().nullable().optional(),
@@ -108,7 +111,7 @@ function scheduledJobRouter() {
108
111
  if (!job) {
109
112
  return res.status(404).json({ error: 'Scheduled job not found.' });
110
113
  }
111
- const cronExpression = parsed.cronExpression !== undefined ? parsed.cronExpression ?? null : job.cronExpression;
114
+ const cronExpression = parsed.cronExpression !== undefined ? (parsed.cronExpression ?? null) : job.cronExpression;
112
115
  let runAt = job.runAt;
113
116
  if (parsed.runAt !== undefined) {
114
117
  if (parsed.runAt) {
package/dist/index.js CHANGED
@@ -18,7 +18,7 @@ const program = new commander_1.Command();
18
18
  program
19
19
  .name('omnikey')
20
20
  .description('Omnikey CLI for onboarding and configuration')
21
- .version('1.5.4');
21
+ .version('1.6.0');
22
22
  program
23
23
  .command('onboard')
24
24
  .description('Onboard and configure your AI provider')
package/dist/onboard.js CHANGED
@@ -12,6 +12,10 @@ const AI_PROVIDERS = [
12
12
  { name: 'OpenAI (gpt-4o-mini / gpt-5.5)', value: 'openai' },
13
13
  { name: 'Anthropic — Claude (claude-haiku / claude-opus)', value: 'anthropic' },
14
14
  { name: 'Google Gemini (gemini-2.5-flash / gemini-2.5-pro)', value: 'gemini' },
15
+ {
16
+ name: 'NVIDIA Nemotron (nemotron-3-nano / nemotron-3-ultra) — open weights',
17
+ value: 'nemotron',
18
+ },
15
19
  ];
16
20
  const SEARCH_PROVIDERS = [
17
21
  { name: 'Skip', value: 'skip' },
@@ -25,11 +29,13 @@ const AI_PROVIDER_KEY_ENV = {
25
29
  openai: 'OPENAI_API_KEY',
26
30
  anthropic: 'ANTHROPIC_API_KEY',
27
31
  gemini: 'GEMINI_API_KEY',
32
+ nemotron: 'NVIDIA_API_KEY',
28
33
  };
29
34
  const AI_PROVIDER_KEY_LABEL = {
30
35
  openai: 'OpenAI API key (from platform.openai.com)',
31
36
  anthropic: 'Anthropic API key (from console.anthropic.com)',
32
37
  gemini: 'Google Gemini API key (from ai.google.dev)',
38
+ nemotron: 'NVIDIA API key (from build.nvidia.com — used by NIM/Nemotron)',
33
39
  };
34
40
  /**
35
41
  * Onboard the user by configuring their AI provider API key and generating config for self-hosted use.
@@ -55,6 +61,37 @@ async function onboard() {
55
61
  validate: (input) => input.trim() !== '' || 'API key cannot be empty',
56
62
  },
57
63
  ]);
64
+ // Provider-specific extras
65
+ const providerExtras = {};
66
+ if (aiProvider === 'nemotron') {
67
+ // Nemotron is served either via the public NVIDIA NIM gateway or a
68
+ // self-hosted NIM microservice. Always ask the user for the base URL so
69
+ // they can point at either. The default value matches the public gateway
70
+ // so pressing Enter "just works" for build.nvidia.com keys.
71
+ const DEFAULT_NEMOTRON_URL = 'https://integrate.api.nvidia.com/v1';
72
+ const { nemotronBaseUrl } = await inquirer_1.default.prompt([
73
+ {
74
+ type: 'input',
75
+ name: 'nemotronBaseUrl',
76
+ message: 'Enter the Nemotron / NVIDIA NIM base URL (press Enter for the public gateway):',
77
+ default: DEFAULT_NEMOTRON_URL,
78
+ validate: (input) => {
79
+ const trimmed = input.trim();
80
+ if (trimmed === '')
81
+ return 'URL cannot be empty';
82
+ try {
83
+ // eslint-disable-next-line no-new
84
+ new URL(trimmed);
85
+ return true;
86
+ }
87
+ catch {
88
+ return 'Please enter a valid URL (including the scheme, e.g. https://...)';
89
+ }
90
+ },
91
+ },
92
+ ]);
93
+ providerExtras['NEMOTRON_BASE_URL'] = nemotronBaseUrl.trim();
94
+ }
58
95
  // Web search provider (optional)
59
96
  const { provider } = await inquirer_1.default.prompt([
60
97
  {
@@ -119,6 +156,7 @@ async function onboard() {
119
156
  [AI_PROVIDER_KEY_ENV[aiProvider]]: apiKey,
120
157
  IS_SELF_HOSTED: true,
121
158
  SQLITE_PATH: sqlitePath,
159
+ ...providerExtras,
122
160
  ...searchConfig,
123
161
  };
124
162
  fs_1.default.writeFileSync(configPath, JSON.stringify(configVars, null, 2));
@@ -23,7 +23,7 @@ function resolveBundleRoot() {
23
23
  return path_1.default.resolve(__dirname, '..', 'telegram-client-dist');
24
24
  }
25
25
  function resolveBundledEntry() {
26
- return path_1.default.join(resolveBundleRoot(), 'dist', 'index.js');
26
+ return path_1.default.join(resolveBundleRoot(), 'index.js');
27
27
  }
28
28
  function persistConfig(values) {
29
29
  const configDir = (0, utils_1.getConfigDir)();
@@ -20,12 +20,12 @@ const LABEL = `com.${os_1.default.userInfo().username}.telegram`;
20
20
  const PLIST_NAME = `${LABEL}.plist`;
21
21
  const WINDOWS_SERVICE_NAME = 'OmnikeyTelegram';
22
22
  // At runtime __dirname is cli/dist/. The bundled telegram app is copied into
23
- // cli/telegram-client-dist/ by the build:telegram-client script, so one level
24
- // up from dist/ lands at the package root, then into the bundle directory.
23
+ // cli/telegram-client-dist/ by the build:telegram-client script (flat layout,
24
+ // matching backend-dist/), so one level up from dist/ lands at the package
25
25
  // This matches resolveBundleRoot() in telegramClient.ts and works correctly
26
26
  // both in the monorepo and after `npm install -g omnikey-cli`.
27
27
  const TELEGRAM_BOT_ROOT = path_1.default.resolve(__dirname, '..', 'telegram-client-dist');
28
- const ENTRY_POINT = path_1.default.join(TELEGRAM_BOT_ROOT, 'dist', 'index.js');
28
+ const ENTRY_POINT = path_1.default.join(TELEGRAM_BOT_ROOT, 'index.js');
29
29
  const HOME = (0, utils_1.getHomeDir)();
30
30
  // macOS — launchd LaunchAgent paths
31
31
  const LAUNCH_AGENTS_DIR = path_1.default.join(HOME, 'Library', 'LaunchAgents');
@@ -289,7 +289,9 @@ async function startWindows() {
289
289
  (0, child_process_1.execFileSync)(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'Start', 'SERVICE_AUTO_START'], {
290
290
  stdio: 'pipe',
291
291
  });
292
- (0, child_process_1.execFileSync)(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'DisplayName', 'Omnikey Telegram'], { stdio: 'pipe' });
292
+ (0, child_process_1.execFileSync)(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'DisplayName', 'Omnikey Telegram'], {
293
+ stdio: 'pipe',
294
+ });
293
295
  (0, child_process_1.execFileSync)(nssmPath, ['set', WINDOWS_SERVICE_NAME, 'Description', 'Omnikey Telegram Daemon'], { stdio: 'pipe' });
294
296
  (0, child_process_1.execFileSync)(nssmPath, ['start', WINDOWS_SERVICE_NAME], { stdio: 'pipe' });
295
297
  console.log(`NSSM service installed and started: ${WINDOWS_SERVICE_NAME}`);
package/package.json CHANGED
@@ -4,7 +4,7 @@
4
4
  "access": "public",
5
5
  "registry": "https://registry.npmjs.org/"
6
6
  },
7
- "version": "1.5.8",
7
+ "version": "1.6.1",
8
8
  "description": "CLI for onboarding users to Omnikey AI and configuring OPENAI_API_KEY. Use Yarn for install/build.",
9
9
  "engines": {
10
10
  "node": ">=14.0.0",
@@ -14,10 +14,14 @@
14
14
  "omnikey": "dist/index.js"
15
15
  },
16
16
  "scripts": {
17
- "build": "tsc && npm run copy-backend && npm run build:telegram-client",
17
+ "build": "tsc && yarn run copy-backend && yarn run build:telegram-client",
18
18
  "start": "node dist/index.js",
19
- "copy-backend": "rm -rf backend-dist && mkdir -p backend-dist && cp -R ../dist/* backend-dist/",
20
- "build:telegram-client": "rm -rf telegram-client-dist && mkdir -p telegram-client-dist/dist && cp -R ../telegram/dist/* telegram-client-dist/dist/"
19
+ "copy-backend": "rm -rf backend-dist && mkdir -p backend-dist && cp -R ../api/dist/* backend-dist/",
20
+ "build:telegram-client": "rm -rf telegram-client-dist && mkdir -p telegram-client-dist && cp -R ../telegram/dist/* telegram-client-dist/",
21
+ "clean": "rm -rf dist backend-dist telegram-client-dist",
22
+ "format": "prettier --write \"src/**/*.ts\"",
23
+ "test": "echo \"(no tests in this workspace)\" && exit 0",
24
+ "lint": "echo \"(no lint in this workspace)\" && exit 0"
21
25
  },
22
26
  "keywords": [
23
27
  "cli",
@@ -33,7 +37,6 @@
33
37
  "@google/genai": "^1.46.0",
34
38
  "@modelcontextprotocol/sdk": "^1.29.0",
35
39
  "axios": "^1.13.5",
36
- "better-sqlite3": "^12.10.0",
37
40
  "commander": "^11.0.0",
38
41
  "cors": "^2.8.5",
39
42
  "cron-parser": "^4.9.0",
@@ -54,7 +57,6 @@
54
57
  "zod": "^4.3.6"
55
58
  },
56
59
  "devDependencies": {
57
- "@types/better-sqlite3": "^7.6.13",
58
60
  "@types/inquirer": "^9.0.9",
59
61
  "@types/node-telegram-bot-api": "^0.64.0",
60
62
  "typescript": "^5.0.0"
package/src/index.ts CHANGED
@@ -26,7 +26,7 @@ const program = new Command();
26
26
  program
27
27
  .name('omnikey')
28
28
  .description('Omnikey CLI for onboarding and configuration')
29
- .version('1.5.4');
29
+ .version('1.6.0');
30
30
 
31
31
  program
32
32
  .command('onboard')
package/src/onboard.ts CHANGED
@@ -7,6 +7,10 @@ const AI_PROVIDERS = [
7
7
  { name: 'OpenAI (gpt-4o-mini / gpt-5.5)', value: 'openai' },
8
8
  { name: 'Anthropic — Claude (claude-haiku / claude-opus)', value: 'anthropic' },
9
9
  { name: 'Google Gemini (gemini-2.5-flash / gemini-2.5-pro)', value: 'gemini' },
10
+ {
11
+ name: 'NVIDIA Nemotron (nemotron-3-nano / nemotron-3-ultra) — open weights',
12
+ value: 'nemotron',
13
+ },
10
14
  ];
11
15
 
12
16
  const SEARCH_PROVIDERS = [
@@ -22,12 +26,14 @@ const AI_PROVIDER_KEY_ENV: Record<string, string> = {
22
26
  openai: 'OPENAI_API_KEY',
23
27
  anthropic: 'ANTHROPIC_API_KEY',
24
28
  gemini: 'GEMINI_API_KEY',
29
+ nemotron: 'NVIDIA_API_KEY',
25
30
  };
26
31
 
27
32
  const AI_PROVIDER_KEY_LABEL: Record<string, string> = {
28
33
  openai: 'OpenAI API key (from platform.openai.com)',
29
34
  anthropic: 'Anthropic API key (from console.anthropic.com)',
30
35
  gemini: 'Google Gemini API key (from ai.google.dev)',
36
+ nemotron: 'NVIDIA API key (from build.nvidia.com — used by NIM/Nemotron)',
31
37
  };
32
38
 
33
39
  /**
@@ -57,6 +63,37 @@ export async function onboard() {
57
63
  },
58
64
  ]);
59
65
 
66
+ // Provider-specific extras
67
+ const providerExtras: Record<string, string> = {};
68
+
69
+ if (aiProvider === 'nemotron') {
70
+ // Nemotron is served either via the public NVIDIA NIM gateway or a
71
+ // self-hosted NIM microservice. Always ask the user for the base URL so
72
+ // they can point at either. The default value matches the public gateway
73
+ // so pressing Enter "just works" for build.nvidia.com keys.
74
+ const DEFAULT_NEMOTRON_URL = 'https://integrate.api.nvidia.com/v1';
75
+ const { nemotronBaseUrl } = await inquirer.prompt([
76
+ {
77
+ type: 'input',
78
+ name: 'nemotronBaseUrl',
79
+ message: 'Enter the Nemotron / NVIDIA NIM base URL (press Enter for the public gateway):',
80
+ default: DEFAULT_NEMOTRON_URL,
81
+ validate: (input: string) => {
82
+ const trimmed = input.trim();
83
+ if (trimmed === '') return 'URL cannot be empty';
84
+ try {
85
+ // eslint-disable-next-line no-new
86
+ new URL(trimmed);
87
+ return true;
88
+ } catch {
89
+ return 'Please enter a valid URL (including the scheme, e.g. https://...)';
90
+ }
91
+ },
92
+ },
93
+ ]);
94
+ providerExtras['NEMOTRON_BASE_URL'] = nemotronBaseUrl.trim();
95
+ }
96
+
60
97
  // Web search provider (optional)
61
98
  const { provider } = await inquirer.prompt([
62
99
  {
@@ -122,6 +159,7 @@ export async function onboard() {
122
159
  [AI_PROVIDER_KEY_ENV[aiProvider]]: apiKey,
123
160
  IS_SELF_HOSTED: true,
124
161
  SQLITE_PATH: sqlitePath,
162
+ ...providerExtras,
125
163
  ...searchConfig,
126
164
  };
127
165
  fs.writeFileSync(configPath, JSON.stringify(configVars, null, 2));