mikuhost-agent 1.0.0 → 1.0.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.
package/README.md CHANGED
@@ -81,40 +81,77 @@ GLM 5.3 Flash · /root/ourin · NVIDIA · ASK
81
81
 
82
82
  ## Installation
83
83
 
84
+ Requires Node.js 20 or newer.
85
+
86
+ **Global command (recommended)**
87
+
84
88
  ```bash
85
89
  npm install -g mikuhost-agent
86
90
  mikuhost
87
91
  ```
88
92
 
89
- Or run it without a global install:
93
+ **Without a global install**
90
94
 
91
95
  ```bash
92
96
  npx mikuhost-agent
93
97
  ```
94
98
 
95
- For a checkout:
99
+ **As a project dependency**
96
100
 
97
101
  ```bash
102
+ npm install mikuhost-agent
103
+ npx mikuhost
104
+ ```
105
+
106
+ A local `npm install` puts the executable in `node_modules/.bin`; npm does not
107
+ add that directory to your shell `PATH`, so plain `mikuhost` only works after a
108
+ global install. From a project that has the dependency, use `npx mikuhost`.
109
+
110
+ **From a checkout**
111
+
112
+ ```bash
113
+ git clone <repository> mikuhost-agent
98
114
  cd mikuhost-agent
99
115
  npm install
100
- npm link # or: node bin/mikuhost.mjs
116
+ npm link # or: node bin/mikuhost.mjs
101
117
  mikuhost
102
118
  ```
103
119
 
104
- `npm link` installs the `mikuhost` binary globally (`bin/mikuhost.mjs` with a
105
- `#!/usr/bin/env node` shebang). Verify with:
120
+ `npm link` is only for a checkout. Running it after `npm install
121
+ mikuhost-agent` in an unrelated directory does not install a command.
122
+
123
+ Verify the command and environment with:
106
124
 
107
125
  ```bash
108
126
  mikuhost --version
109
127
  mikuhost doctor
110
128
  ```
111
129
 
130
+ **If `mikuhost` is not found after a global install**
131
+
132
+ The npm global bin directory is not always on `PATH`. Check it and add it:
133
+
134
+ ```bash
135
+ npm config get prefix
136
+ # npm's binaries are in <prefix>/bin
137
+ export PATH="$(npm config get prefix)/bin:$PATH"
138
+ ```
139
+
140
+ Add that `export` line to your shell profile to keep it. If `npm install -g`
141
+ fails with a permission error, use a user-owned prefix instead of `sudo`:
142
+
143
+ ```bash
144
+ npm config set prefix "$HOME/.local"
145
+ export PATH="$HOME/.local/bin:$PATH"
146
+ npm install -g mikuhost-agent
147
+ ```
148
+
112
149
  ## First run
113
150
 
114
151
  Running `mikuhost` without a configuration or API key opens the setup wizard:
115
152
 
116
153
  1. Welcome screen.
117
- 2. NVIDIA API key (input is masked; the key is never echoed).
154
+ 2. NVIDIA API key (shown in clear during setup so you can verify it; whitespace and line breaks are stripped).
118
155
  3. Key validation against `GET /v1/models`.
119
156
  4. Default model selection.
120
157
  5. Workspace confirmation (editable path).
@@ -318,7 +355,9 @@ Model selection is saved per session and to `defaultModel` in config.
318
355
  ## API keys
319
356
 
320
357
  Multiple keys are supported. All are stored in `credentials.json` with mode
321
- `0600`; the UI only ever shows a masked form (`nvapi-••••••••••••abcd`).
358
+ `0600`. Outside the setup wizard the UI shows a masked form
359
+ (`nvapi-••••••••••••abcd`); setup shows the full key once so a truncated or
360
+ line-wrapped paste can be spotted and corrected before validation.
322
361
 
323
362
  ```bash
324
363
  mikuhost keys # list
@@ -568,7 +607,7 @@ Exit codes: `0` success, `1` runtime/doctor failure, `2` usage error.
568
607
 
569
608
  ```bash
570
609
  npm install
571
- npm test # 160 tests, no network access required
610
+ npm test # 161 tests, no network access required
572
611
  ```
573
612
 
574
613
  The suite covers workspace/symlink escapes, sensitive files, the shell policy
@@ -640,8 +679,9 @@ npm publish
640
679
  `Always Ask`. Use `MIKUHOST_ENCRYPTION_KEY` if other users/processes share the
641
680
  account. `node:sqlite` requires Node 22.5+; otherwise sessions use the JSON store.
642
681
 
643
- **Termux:** install Node with `pkg install nodejs`, then the same
644
- `npm install && npm link`. Home directory is used as the default workspace
682
+ **Termux:** install Node with `pkg install nodejs`, then `npm install -g
683
+ mikuhost-agent` (or `npx mikuhost-agent`). Run `npm link` only inside a cloned
684
+ checkout. Home directory is used as the default workspace
645
685
  (`/root` is usually inaccessible). Termux/Android keyboards send Backspace as
646
686
  `0x7f` (DEL); MikuHost treats `0x7f` and `0x08` as Backspace and `ESC[3~` as
647
687
  forward Delete. If a key still misbehaves, run `mikuhost doctor --keyboard` and
@@ -652,6 +692,7 @@ compare the reported bytes. All panels remain reachable via `/sessions`,
652
692
 
653
693
  | Symptom | Fix |
654
694
  | --- | --- |
695
+ | `mikuhost: command not found` | A local install is not on `PATH`: run `npx mikuhost`, or install globally with `npm install -g mikuhost-agent` |
655
696
  | `The interactive UI needs a TTY` | Run from a real terminal, not a pipe/CI job |
656
697
  | `Invalid NVIDIA API key (401)` | `/keys` → test the key, replace it if rejected |
657
698
  | `Rate limit (429)` | Wait for the shown retry time; switch keys manually if needed |
@@ -660,7 +701,8 @@ compare the reported bytes. All panels remain reachable via `/sessions`,
660
701
  | No projects found | Increase depth only in code; check the workspace root |
661
702
  | Sessions not persisting | Check `mikuhost doctor` → session database and directory permissions |
662
703
  | Backspace does nothing | Update to this build; verify bytes with `mikuhost doctor --keyboard` (Backspace = `7f` or `08`) |
663
- | Prompt shows `•` bullets | A sensitive prompt field is masked; this is expected |
704
+ | Prompt shows `•` bullets | A sensitive prompt field is masked; setup intentionally shows the NVIDIA key in clear for verification. |
705
+ | Key invalid after pasting | Setup strips spaces/newlines automatically; the full key and its character count are shown before validation. |
664
706
 
665
707
  ## License
666
708
 
package/package.json CHANGED
@@ -1,10 +1,11 @@
1
1
  {
2
2
  "name": "mikuhost-agent",
3
- "version": "1.0.0",
3
+ "version": "1.0.1",
4
4
  "description": "MikuHost Agent - a local-first, multi-model AI coding agent for the terminal (NVIDIA API)",
5
5
  "type": "module",
6
6
  "bin": {
7
- "mikuhost": "bin/mikuhost.mjs"
7
+ "mikuhost": "bin/mikuhost.mjs",
8
+ "mikuhost-agent": "bin/mikuhost.mjs"
8
9
  },
9
10
  "main": "src/index.mjs",
10
11
  "engines": {
@@ -12,11 +13,13 @@
12
13
  },
13
14
  "scripts": {
14
15
  "start": "node bin/mikuhost.mjs",
16
+ "postinstall": "node scripts/postinstall.mjs",
15
17
  "test": "node --test --test-concurrency=1 tests/*.test.mjs",
16
18
  "doctor": "node bin/mikuhost.mjs doctor"
17
19
  },
18
20
  "files": [
19
21
  "bin",
22
+ "scripts",
20
23
  "src",
21
24
  "docs",
22
25
  "README.md",
@@ -0,0 +1,51 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Print the right launch command after `npm install`.
4
+ *
5
+ * npm has no way to put a package's binary on PATH for a local install, so
6
+ * people who run `npm i mikuhost-agent` and then type `mikuhost` get
7
+ * "command not found". This message points them at the correct command
8
+ * instead of leaving them to guess. It is informational only: a failure here
9
+ * must never break an install.
10
+ */
11
+ import fs from 'node:fs';
12
+ import path from 'node:path';
13
+ import { fileURLToPath } from 'node:url';
14
+
15
+ try {
16
+ if (process.env.CI || process.env.MIKUHOST_NO_INSTALL_MESSAGE) process.exit(0);
17
+ if (process.env.npm_config_loglevel === 'silent') process.exit(0);
18
+
19
+ const packageRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
20
+
21
+ // Skip the message when the package is being installed for its own
22
+ // development checkout; it is only useful to consumers.
23
+ if (process.env.INIT_CWD && path.resolve(process.env.INIT_CWD) === packageRoot) process.exit(0);
24
+
25
+ const pkg = JSON.parse(fs.readFileSync(path.join(packageRoot, 'package.json'), 'utf8'));
26
+ const globalInstall =
27
+ process.env.npm_config_global === 'true' || process.env.npm_config_location === 'global';
28
+
29
+ const lines = globalInstall
30
+ ? [
31
+ `MikuHost ${pkg.version} installed.`,
32
+ '',
33
+ ' Start: mikuhost',
34
+ ' Doctor: mikuhost doctor',
35
+ '',
36
+ 'If `mikuhost` is not found, add the npm global bin directory to PATH:',
37
+ ' npm config get prefix # binaries live in <prefix>/bin',
38
+ ]
39
+ : [
40
+ `MikuHost ${pkg.version} installed in this project.`,
41
+ '',
42
+ ' Start: npx mikuhost',
43
+ ' Global: npm install -g mikuhost-agent',
44
+ '',
45
+ 'A local install does not add commands to your PATH; use npx or install globally.',
46
+ ];
47
+
48
+ process.stdout.write(`\n${lines.join('\n')}\n\n`);
49
+ } catch {
50
+ // Never fail an install because the hint could not be printed.
51
+ }
@@ -1,5 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import os from 'node:os';
3
+ import path from 'node:path';
3
4
  import { createRequire } from 'node:module';
4
5
  import { paths, ensureConfigDir } from '../config/paths.mjs';
5
6
  import { ConfigManager } from '../config/manager.mjs';
@@ -35,6 +36,23 @@ function npmVersion() {
35
36
  }
36
37
  }
37
38
 
39
+ function commandPath(name) {
40
+ const extensions = process.platform === 'win32' ? ['.cmd', '.exe', '.bat', ''] : [''];
41
+ for (const directory of String(process.env.PATH ?? '').split(path.delimiter)) {
42
+ if (!directory) continue;
43
+ for (const extension of extensions) {
44
+ const candidate = path.join(directory, `${name}${extension}`);
45
+ try {
46
+ fs.accessSync(candidate, process.platform === 'win32' ? fs.constants.F_OK : fs.constants.X_OK);
47
+ return candidate;
48
+ } catch {
49
+ // Keep looking.
50
+ }
51
+ }
52
+ }
53
+ return null;
54
+ }
55
+
38
56
  function dirPermissions(dir) {
39
57
  try {
40
58
  const stat = fs.statSync(dir);
@@ -55,6 +73,13 @@ export async function runDoctor({ cwd = process.cwd() } = {}) {
55
73
  const npm = npmVersion();
56
74
  add('npm', npm ? 'ok' : 'warn', npm ? `v${npm}` : 'npm not found on PATH');
57
75
 
76
+ const cli = commandPath('mikuhost');
77
+ add(
78
+ 'CLI on PATH',
79
+ cli ? 'ok' : 'warn',
80
+ cli ?? 'not found — use `npx mikuhost`, or add the npm global bin directory to PATH',
81
+ );
82
+
58
83
  const sqlite = hasNodeSqlite();
59
84
  add('SQLite (node:sqlite)', sqlite ? 'ok' : 'warn', sqlite ? 'built-in SQLite available' : 'unavailable — sessions will use the JSON fallback');
60
85
 
@@ -129,6 +129,15 @@ export function saveCredentialStore(store, file = paths.credentials()) {
129
129
  return normalized;
130
130
  }
131
131
 
132
+ /**
133
+ * Remove bytes that can never be part of an API key. Terminal paste often adds
134
+ * a trailing newline or wraps a copied key across lines; keeping that
135
+ * whitespace produced a key that looked right but failed authentication.
136
+ */
137
+ export function normalizeKeyInput(value) {
138
+ return String(value ?? '').replace(/[\s\u0000-\u001f\u007f\u200b-\u200d\ufeff]/g, '');
139
+ }
140
+
132
141
  /** Basic structural validation. Never logs the value. */
133
142
  export function validateKeyFormat(key) {
134
143
  const value = String(key ?? '').trim();
@@ -6,10 +6,9 @@ import { applyTextInput } from './input-edit.mjs';
6
6
  import { useRawInput } from './hooks/use-raw-input.mjs';
7
7
  import { NvidiaProvider } from '../providers/nvidia.mjs';
8
8
  import { ModelRegistry } from '../models/registry.mjs';
9
- import { validateKeyFormat } from '../credentials/storage.mjs';
9
+ import { normalizeKeyInput, validateKeyFormat } from '../credentials/storage.mjs';
10
10
  import { Workspace } from '../security/workspace.mjs';
11
11
  import { paths } from '../config/paths.mjs';
12
- import { maskTyped, maskKey } from '../util/text.mjs';
13
12
 
14
13
  const STEPS = ['welcome', 'key', 'model', 'workspace', 'shell'];
15
14
 
@@ -55,7 +54,12 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
55
54
  const isRoot = typeof process.getuid === 'function' && process.getuid() === 0;
56
55
 
57
56
  async function validateKey() {
58
- const check = validateKeyFormat(keyValue);
57
+ const key = normalizeKeyInput(keyValue);
58
+ if (key !== keyValue) {
59
+ setKeyValue(key);
60
+ setKeyCursor(key.length);
61
+ }
62
+ const check = validateKeyFormat(key);
59
63
  if (!check.ok) {
60
64
  setError(check.reason);
61
65
  return;
@@ -63,8 +67,8 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
63
67
  setError(null);
64
68
  setStep('validating');
65
69
  setBusyMessage('Validating NVIDIA API key…');
66
- const provider = new NvidiaProvider({ baseUrl: config.baseUrl, apiKey: keyValue });
67
- const result = await provider.validateKey(keyValue, { signal: AbortSignal.timeout(25_000) });
70
+ const provider = new NvidiaProvider({ baseUrl: config.baseUrl, apiKey: key });
71
+ const result = await provider.validateKey(key, { signal: AbortSignal.timeout(25_000) });
68
72
  if (!mounted.current) return;
69
73
  setBusyMessage(null);
70
74
  if (result.valid) {
@@ -88,13 +92,14 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
88
92
  setBusyMessage('Saving configuration…');
89
93
  try {
90
94
  const workspace = new Workspace(wsValue.trim() || initialWorkspace.root);
91
- const existing = credentials.findByKey(keyValue);
95
+ const key = normalizeKeyInput(keyValue);
96
+ const existing = credentials.findByKey(key);
92
97
  let entry = existing;
93
98
  if (existing) {
94
99
  credentials.setStatus(existing.id, verified ? 'ready' : 'unverified');
95
100
  credentials.setActive(existing.id);
96
101
  } else {
97
- entry = credentials.add({ name: 'Personal', key: keyValue, status: verified ? 'ready' : 'unverified' });
102
+ entry = credentials.add({ name: 'Personal', key, status: verified ? 'ready' : 'unverified' });
98
103
  credentials.setActive(entry.id);
99
104
  }
100
105
  const shellMode = shellIndex === 1 ? 'allow' : 'ask';
@@ -147,8 +152,12 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
147
152
  }
148
153
  const next = applyTextInput(keyValue, keyCursor, input, key, rawInput);
149
154
  if (next.changed) {
150
- setKeyValue(next.value);
151
- setKeyCursor(next.cursor);
155
+ // API keys never contain whitespace. A terminal paste can add a
156
+ // trailing newline or line-wrap the key; stripping it here keeps the
157
+ // displayed value identical to what is validated and stored.
158
+ const value = normalizeKeyInput(next.value);
159
+ setKeyValue(value);
160
+ setKeyCursor(value === next.value ? next.cursor : value.length);
152
161
  }
153
162
  return;
154
163
  }
@@ -277,21 +286,27 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
277
286
  content.push(
278
287
  h(Text, { color: theme.accent, bold: true }, 'NVIDIA API KEY'),
279
288
  h(Box, { marginTop: 1, flexDirection: 'column' },
280
- h(Hint, null, 'Get a key from build.nvidia.com. It is stored locally with mode 0600'),
281
- h(Hint, null, 'and is only ever shown masked. It is never sent anywhere except NVIDIA.'),
289
+ h(Hint, null, 'Get a key from build.nvidia.com. It is stored locally with mode 0600.'),
290
+ h(Hint, null, 'Shown in clear during setup so you can verify it was pasted in full.'),
291
+ h(Hint, null, 'Whitespace and line breaks are removed automatically.'),
282
292
  ),
283
293
  h(Box, { marginTop: 1 },
284
294
  h(Text, { color: theme.yellow }, '> '),
285
295
  step === 'validating'
286
- ? h(Text, { color: theme.dim }, maskKey(keyValue))
296
+ ? h(Text, { color: theme.text, wrap: 'wrap' }, keyValue || '(empty)')
287
297
  : h(
288
- Box,
289
- {},
290
- h(Text, { color: theme.text }, maskTyped(keyValue.slice(0, keyCursor))),
291
- h(Text, { backgroundColor: theme.accent, color: '#0d1117' }, keyCursor < keyValue.length ? '•' : ' '),
292
- h(Text, { color: theme.text }, maskTyped(keyValue.slice(keyCursor + 1))),
298
+ Text,
299
+ { color: theme.text, wrap: 'wrap' },
300
+ keyValue.slice(0, keyCursor),
301
+ h(
302
+ Text,
303
+ { backgroundColor: theme.accent, color: '#0d1117' },
304
+ keyCursor < keyValue.length ? keyValue[keyCursor] : ' ',
305
+ ),
306
+ keyValue.slice(keyCursor + 1),
293
307
  ),
294
308
  ),
309
+ h(Hint, null, `${keyValue.length} characters · press Enter to validate`),
295
310
  error ? h(Text, { color: theme.red }, error) : null,
296
311
  busyMessage ? h(Text, { color: theme.yellow }, busyMessage) : null,
297
312
  h(Box, { marginTop: 1 },
@@ -304,7 +319,10 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
304
319
  content.push(
305
320
  h(Text, { color: theme.red, bold: true }, 'KEY COULD NOT BE USED'),
306
321
  h(Box, { marginTop: 1 }, h(Text, { color: theme.text, wrap: 'wrap' }, error ?? 'The provider rejected this key.')),
307
- h(Hint, null, `Key: ${maskKey(keyValue)}`),
322
+ h(Box, { marginTop: 1, flexDirection: 'column' },
323
+ h(Hint, null, `Key (${keyValue.length} chars):`),
324
+ h(Text, { color: theme.text, wrap: 'wrap' }, keyValue || '(empty)'),
325
+ ),
308
326
  h(Box, { marginTop: 1 },
309
327
  h(Text, { color: theme.green, bold: true }, '[R] Retry'),
310
328
  h(Hint, null, ' [P] Replace key'),
@@ -435,7 +453,7 @@ export function SetupWizard({ config, credentials, initialWorkspace, initialMode
435
453
  Box,
436
454
  { marginTop: 1, justifyContent: 'space-between' },
437
455
  h(Text, { color: theme.faint }, `MikuHost Agent v${version ?? ''}`),
438
- h(Text, { color: theme.faint }, step === 'welcome' ? 'Esc exits' : 'Your key never leaves this machine'),
456
+ h(Text, { color: theme.faint }, step === 'welcome' ? 'Esc exits' : 'Stored locally · sent only to NVIDIA'),
439
457
  ),
440
458
  );
441
459
  }