idlewatch 0.1.2 → 0.1.4

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
@@ -17,7 +17,7 @@ npx idlewatch quickstart
17
17
  npx idlewatch --dry-run
18
18
  ```
19
19
 
20
- `quickstart` runs a first-run setup wizard that writes a local env file and (for production mode) stores a locked-down copy of the service-account key under `~/.idlewatch/`. On hosts with Rust/Cargo available, quickstart launches a ratatui-powered onboarding flow first; otherwise it falls back to the text wizard.
20
+ `quickstart` is the happy path: create an API key on idlewatch.com/api, run the wizard, pick a device name + metrics, and IdleWatch saves local config before sending a first sample. By default, quickstart will try to install Rust/Cargo automatically if needed so it can launch the ratatui onboarding flow. Use `idlewatch quickstart --no-tui` to skip the TUI and stay on the plain text setup path without installing Cargo.
21
21
 
22
22
  ## CLI options
23
23
 
@@ -49,26 +49,30 @@ Use `gpuSource` + `gpuConfidence` in dashboards to decide whether to trust value
49
49
  - `low`: constrained probe path
50
50
  - `none`: no usable sample for that sample window
51
51
 
52
- ## Firebase wiring
52
+ ## Quickstart
53
53
 
54
- ### Recommended: guided enrollment (external users)
54
+ ### Recommended: guided enrollment
55
55
 
56
56
  ```bash
57
57
  npx idlewatch quickstart
58
58
  ```
59
59
 
60
- The wizard supports:
61
- - **Production mode**: prompts for project id + service-account JSON file path, validates it, copies credentials to a local secure path (0600), and writes `FIREBASE_SERVICE_ACCOUNT_FILE=...`.
62
- - **Emulator mode**: writes `FIREBASE_PROJECT_ID` + `FIRESTORE_EMULATOR_HOST` only.
63
- - **Local-only mode**: writes no Firebase credentials.
60
+ The wizard keeps setup small:
61
+ - asks for a **device name**
62
+ - asks for your **API key** from `idlewatch.com/api`
63
+ - lets you choose which **metrics** to collect
64
+ - saves local config to `~/.idlewatch/idlewatch.env`
65
+ - sends a first sample so the device can link right away
64
66
 
65
- Then run a one-shot publish check:
67
+ The saved config is auto-loaded on later runs, so you should not need to manually source the env file in normal use.
68
+
69
+ Then run a one-shot publish check any time with:
66
70
 
67
71
  ```bash
68
72
  idlewatch --once
69
73
  ```
70
74
 
71
- The saved config is auto-loaded from `~/.idlewatch/idlewatch.env`, so you should not need to manually source the env file in normal use.
75
+ ## Advanced Firebase wiring
72
76
 
73
77
  ### Manual wiring
74
78
 
@@ -18,7 +18,7 @@ import { enrichWithOpenClawFleetTelemetry } from '../src/telemetry-mapping.js'
18
18
  import pkg from '../package.json' with { type: 'json' }
19
19
 
20
20
  function printHelp() {
21
- console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup TUI and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --dry-run Collect and print one telemetry sample, then exit without cloud/Firebase writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from dashboard for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n`)
21
+ console.log(`idlewatch\n\nUsage:\n idlewatch [quickstart|configure|dashboard|run] [--dry-run] [--once] [--help]\n\nOptions:\n quickstart Run first-run setup and save local IdleWatch config\n configure Alias for quickstart; reopen setup to change device name, API key, or metrics\n dashboard Launch local dashboard from local IdleWatch logs\n run Start the background collector using saved local config\n --no-tui Skip the Rust TUI and use plain text setup without installing Cargo\n --dry-run Collect and print one telemetry sample, then exit without remote writes\n --once Collect and publish one telemetry sample, then exit\n --help Show this help message\n\nQuickstart:\n 1. Create an API key on idlewatch.com/api\n 2. Run: idlewatch quickstart\n 3. Pick a device name and metrics\n 4. IdleWatch saves your local config and sends a first sample\n\nEnvironment:\n IDLEWATCH_HOST Optional custom host label (default: hostname)\n IDLEWATCH_INTERVAL_MS Sampling interval in ms (default: 10000)\n IDLEWATCH_LOCAL_LOG_PATH Optional NDJSON file path for local sample durability\n IDLEWATCH_DASHBOARD_PORT Local dashboard HTTP port (default: 4373)\n IDLEWATCH_OPENCLAW_USAGE OpenClaw usage lookup mode: auto|off (default: auto)\n IDLEWATCH_OPENCLAW_PROBE_TIMEOUT_MS OpenClaw command timeout per probe in ms (default: 2500)\n IDLEWATCH_OPENCLAW_PROBE_RETRIES Extra OpenClaw probe sweep retries after first pass (default: 1)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES Max per-command OpenClaw probe output capture in bytes before truncation (default: 2097152 / 2MB)\n IDLEWATCH_OPENCLAW_MAX_OUTPUT_BYTES_HARD_CAP Hard cap for auto-retry output capture escalation (default: 16777216 / 16MB)\n IDLEWATCH_USAGE_STALE_MS Mark OpenClaw usage stale beyond this age in ms (default: max(interval*3,60000))\n IDLEWATCH_USAGE_NEAR_STALE_MS Mark OpenClaw usage as aging beyond this age in ms (default: floor((stale+grace)*0.85))\n IDLEWATCH_USAGE_STALE_GRACE_MS Extra grace window before status becomes stale (default: min(interval,10000))\n IDLEWATCH_USAGE_REFRESH_REPROBES Forced uncached reprobes when usage crosses stale threshold (default: 1)\n IDLEWATCH_USAGE_REFRESH_DELAY_MS Delay between forced stale-threshold reprobes in ms (default: 250)\n IDLEWATCH_USAGE_REFRESH_ON_NEAR_STALE Trigger refresh when usage is near-stale: 1|0 (default: 1)\n IDLEWATCH_USAGE_IDLE_AFTER_MS Downgrade stale usage alerts to idle notice beyond this age in ms (default: 21600000)\n IDLEWATCH_OPENCLAW_LAST_GOOD_MAX_AGE_MS Reuse last successful usage snapshot after probe failures up to this age in ms\n IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH Persist/reuse last successful usage snapshot across restarts (default: ~/.idlewatch/cache/<host>-openclaw-last-good.json)\n IDLEWATCH_CLOUD_INGEST_URL Optional cloud ingest endpoint (e.g. https://idlewatch.com/api/ingest)\n IDLEWATCH_CLOUD_API_KEY Cloud API key from idlewatch.com/api for device linking\n IDLEWATCH_REQUIRE_CLOUD_WRITES Require cloud publish path in --once mode: 1|0 (default: 0)\n\nAdvanced Firebase / emulator mode:\n IDLEWATCH_REQUIRE_FIREBASE_WRITES Require Firebase publish path in --once mode: 1|0 (default: 0)\n FIREBASE_PROJECT_ID Firebase project id\n FIREBASE_SERVICE_ACCOUNT_FILE Path to service account JSON file (preferred for production)\n FIREBASE_SERVICE_ACCOUNT_JSON Raw JSON service account (supported, less secure than file path)\n FIREBASE_SERVICE_ACCOUNT_B64 Base64-encoded JSON service account (legacy)\n FIRESTORE_EMULATOR_HOST Optional Firestore emulator host; allows local writes without service-account creds\n`)
22
22
  }
23
23
 
24
24
  const require = createRequire(import.meta.url)
@@ -372,7 +372,7 @@ if (dashboardRequested) {
372
372
 
373
373
  if (quickstartRequested) {
374
374
  try {
375
- const result = await runEnrollmentWizard()
375
+ const result = await runEnrollmentWizard({ noTui: args.has('--no-tui') })
376
376
 
377
377
  const enrolledEnv = parseEnvFileToObject(result.outputEnvFile)
378
378
  const onceRun = spawnSync(process.execPath, [process.argv[1], '--once'], {
@@ -391,7 +391,7 @@ if (quickstartRequested) {
391
391
 
392
392
  console.error(`⚠️ Setup is not finished yet. Mode=${result.mode} device=${result.deviceName} envFile=${result.outputEnvFile}`)
393
393
  console.error('The first required telemetry sample did not publish successfully, so this device may not be linked yet.')
394
- console.error(`Retry with: idlewatch --once`)
394
+ console.error(`Retry with: set -a; source "${result.outputEnvFile}"; set +a && idlewatch --once`)
395
395
  console.error('Or rerun: idlewatch quickstart')
396
396
  process.exit(onceRun.status ?? 1)
397
397
  } catch (err) {
@@ -626,7 +626,7 @@ if (firebaseConfigError) {
626
626
 
627
627
  if (!appReady && !(CLOUD_INGEST_URL && CLOUD_API_KEY)) {
628
628
  console.error(
629
- 'Firebase is not configured. Running without Firebase writes. Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes, or configure cloud ingest via idlewatch setup.'
629
+ 'Firebase is not configured. Running without Firebase writes. Set FIREBASE_PROJECT_ID + FIREBASE_SERVICE_ACCOUNT_FILE (preferred, or FIREBASE_SERVICE_ACCOUNT_JSON / FIREBASE_SERVICE_ACCOUNT_B64), use FIREBASE_PROJECT_ID + FIRESTORE_EMULATOR_HOST for emulator writes, or run idlewatch quickstart to link cloud ingest.'
630
630
  )
631
631
  }
632
632
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idlewatch",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "Host telemetry collector for IdleWatch",
5
5
  "type": "module",
6
6
  "files": [
@@ -2,7 +2,7 @@ import fs from 'node:fs'
2
2
  import http from 'node:http'
3
3
  import os from 'node:os'
4
4
  import path from 'node:path'
5
- import { spawnSync } from 'node:child_process'
5
+ import { spawn } from 'node:child_process'
6
6
 
7
7
  const repoRoot = path.resolve(path.dirname(new URL(import.meta.url).pathname), '..')
8
8
  const binPath = path.join(repoRoot, 'bin', 'idlewatch-agent.js')
@@ -34,30 +34,47 @@ await new Promise((resolve, reject) => {
34
34
  const address = server.address()
35
35
  const cloudIngestUrl = `http://127.0.0.1:${address.port}/api/ingest`
36
36
 
37
+ function runQuickstart(env) {
38
+ return new Promise((resolve, reject) => {
39
+ const child = spawn(process.execPath, [binPath, 'quickstart'], {
40
+ env,
41
+ stdio: ['ignore', 'pipe', 'pipe']
42
+ })
43
+
44
+ let stdout = ''
45
+ let stderr = ''
46
+ child.stdout.on('data', (chunk) => {
47
+ stdout += chunk
48
+ })
49
+ child.stderr.on('data', (chunk) => {
50
+ stderr += chunk
51
+ })
52
+ child.on('error', reject)
53
+ child.on('close', (code) => resolve({ code, stdout, stderr }))
54
+ })
55
+ }
56
+
37
57
  try {
38
58
  const envOut = path.join(tmpRoot, 'generated.env')
39
59
  const configDir = path.join(tmpRoot, 'config')
40
60
  const localLogPath = path.join(configDir, 'logs', 'validator-box-metrics.ndjson')
41
61
  const lastGoodCachePath = path.join(configDir, 'cache', 'validator-box-openclaw-last-good.json')
42
62
 
43
- const run = spawnSync(process.execPath, [binPath, 'quickstart'], {
44
- env: {
45
- ...process.env,
46
- IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
47
- IDLEWATCH_ENROLL_MODE: 'production',
48
- IDLEWATCH_CLOUD_API_KEY: 'iwk_abcdefghijklmnopqrstuvwxyz123456',
49
- IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
50
- IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
51
- IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
52
- IDLEWATCH_DEVICE_NAME: 'Validator Box',
53
- IDLEWATCH_DEVICE_ID: 'validator-box',
54
- IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
55
- IDLEWATCH_OPENCLAW_USAGE: 'off'
56
- },
57
- encoding: 'utf8'
63
+ const run = await runQuickstart({
64
+ ...process.env,
65
+ IDLEWATCH_ENROLL_NON_INTERACTIVE: '1',
66
+ IDLEWATCH_ENROLL_MODE: 'production',
67
+ IDLEWATCH_CLOUD_API_KEY: 'iwk_abcdefghijklmnopqrstuvwxyz123456',
68
+ IDLEWATCH_CLOUD_INGEST_URL: cloudIngestUrl,
69
+ IDLEWATCH_ENROLL_OUTPUT_ENV_FILE: envOut,
70
+ IDLEWATCH_ENROLL_CONFIG_DIR: configDir,
71
+ IDLEWATCH_DEVICE_NAME: 'Validator Box',
72
+ IDLEWATCH_DEVICE_ID: 'validator-box',
73
+ IDLEWATCH_MONITOR_TARGETS: 'cpu,memory',
74
+ IDLEWATCH_OPENCLAW_USAGE: 'off'
58
75
  })
59
76
 
60
- if (run.status !== 0) {
77
+ if (run.code !== 0) {
61
78
  throw new Error(`quickstart failed\nstdout:\n${run.stdout}\nstderr:\n${run.stderr}`)
62
79
  }
63
80
 
@@ -82,6 +99,10 @@ try {
82
99
  }
83
100
  }
84
101
 
102
+ if (!run.stdout.includes('✅ Setup complete.')) {
103
+ throw new Error('quickstart success output did not include setup completion summary')
104
+ }
105
+
85
106
  if (requests.length === 0) {
86
107
  throw new Error('quickstart did not send the initial telemetry sample')
87
108
  }
@@ -93,6 +114,6 @@ try {
93
114
 
94
115
  console.log('onboarding validation passed')
95
116
  } finally {
96
- server.close()
117
+ await new Promise((resolve) => server.close(resolve))
97
118
  fs.rmSync(tmpRoot, { recursive: true, force: true })
98
119
  }
package/src/config.js CHANGED
@@ -21,6 +21,22 @@ const isNonNegFinite = (v) => Number.isFinite(v) && v >= 0
21
21
  const isNonNegInt = (v) => Number.isInteger(v) && v >= 0
22
22
  const isBool01 = (v) => v === 0 || v === 1
23
23
 
24
+ function expandSupportedPathVars(value) {
25
+ if (typeof value !== 'string' || !value) return value
26
+
27
+ const home = process.env.HOME || os.homedir()
28
+ const tmpdir = process.env.TMPDIR || os.tmpdir()
29
+
30
+ return value
31
+ .replace(/^~(?=$|\/)/, home)
32
+ .replace(/\$\{HOME\}|\$HOME/g, home)
33
+ .replace(/\$\{TMPDIR\}|\$TMPDIR/g, tmpdir)
34
+ }
35
+
36
+ function resolveEnvPath(value) {
37
+ return path.resolve(expandSupportedPathVars(value))
38
+ }
39
+
24
40
  /**
25
41
  * Build the full IdleWatch configuration from environment variables.
26
42
  * Throws on invalid values.
@@ -63,11 +79,11 @@ export function buildConfig() {
63
79
  const BASE_DIR = path.join(os.homedir(), '.idlewatch')
64
80
 
65
81
  const LOCAL_LOG_PATH = process.env.IDLEWATCH_LOCAL_LOG_PATH
66
- ? path.resolve(process.env.IDLEWATCH_LOCAL_LOG_PATH)
82
+ ? resolveEnvPath(process.env.IDLEWATCH_LOCAL_LOG_PATH)
67
83
  : path.join(BASE_DIR, 'logs', `${SAFE_HOST}-metrics.ndjson`)
68
84
 
69
85
  const OPENCLAW_LAST_GOOD_CACHE_PATH = process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH
70
- ? path.resolve(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
86
+ ? resolveEnvPath(process.env.IDLEWATCH_OPENCLAW_LAST_GOOD_CACHE_PATH)
71
87
  : path.join(BASE_DIR, 'cache', `${SAFE_HOST}-openclaw-last-good.json`)
72
88
 
73
89
  return Object.freeze({
package/src/enrollment.js CHANGED
@@ -3,6 +3,7 @@ import os from 'node:os'
3
3
  import path from 'node:path'
4
4
  import readline from 'node:readline/promises'
5
5
  import process from 'node:process'
6
+ import { fileURLToPath } from 'node:url'
6
7
  import { spawnSync } from 'node:child_process'
7
8
 
8
9
  function defaultConfigDir() {
@@ -24,6 +25,8 @@ function writeSecureFile(filePath, content) {
24
25
  }
25
26
 
26
27
  const MONITOR_TARGET_CHOICES = ['cpu', 'memory', 'gpu', 'openclaw']
28
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
29
+ const PACKAGE_ROOT = path.resolve(MODULE_DIR, '..')
27
30
 
28
31
  function commandExists(bin, args = ['--version']) {
29
32
  const result = spawnSync(bin, args, { stdio: 'ignore' })
@@ -91,15 +94,52 @@ function sanitizeDeviceId(raw, fallback = os.hostname()) {
91
94
  return sanitized || normalizeDeviceName(fallback).replace(/[^a-zA-Z0-9_.-]/g, '_')
92
95
  }
93
96
 
94
- function tryRustTui({ configDir, outputEnvFile }) {
95
- const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
96
- if (disabled) return false
97
-
97
+ function cargoAvailable() {
98
98
  const cargoProbe = spawnSync('cargo', ['--version'], { stdio: 'ignore' })
99
- if (cargoProbe.status !== 0) return false
99
+ return cargoProbe.status === 0
100
+ }
100
101
 
101
- const manifestPath = path.resolve(process.cwd(), 'tui', 'Cargo.toml')
102
- if (!fs.existsSync(manifestPath)) return false
102
+ function installRustToolchain() {
103
+ if (cargoAvailable()) return { ok: true, installed: false }
104
+ if (process.env.IDLEWATCH_DISABLE_RUST_INSTALL === '1') return { ok: false, reason: 'install-disabled' }
105
+ if (!process.stdin.isTTY || !process.stdout.isTTY) return { ok: false, reason: 'non-tty' }
106
+ if (!['darwin', 'linux'].includes(process.platform)) return { ok: false, reason: `unsupported-platform:${process.platform}` }
107
+
108
+ console.log('IdleWatch TUI needs Rust/Cargo. Installing rustup + Cargo now...')
109
+ const install = spawnSync('sh', ['-c', 'curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y'], {
110
+ stdio: 'inherit',
111
+ env: {
112
+ ...process.env,
113
+ CARGO_HOME: process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'),
114
+ RUSTUP_HOME: process.env.RUSTUP_HOME || path.join(os.homedir(), '.rustup')
115
+ }
116
+ })
117
+
118
+ if (install.status !== 0) return { ok: false, reason: `rustup-install-failed:${install.status ?? 'unknown'}` }
119
+
120
+ const cargoBinDir = path.join(process.env.CARGO_HOME || path.join(os.homedir(), '.cargo'), 'bin')
121
+ if (!process.env.PATH?.split(path.delimiter).includes(cargoBinDir)) {
122
+ process.env.PATH = `${cargoBinDir}${path.delimiter}${process.env.PATH || ''}`
123
+ }
124
+
125
+ return cargoAvailable() ? { ok: true, installed: true } : { ok: false, reason: 'cargo-still-missing' }
126
+ }
127
+
128
+ function tryRustTui({ configDir, outputEnvFile, autoInstallRust = true }) {
129
+ const disabled = process.env.IDLEWATCH_DISABLE_RUST_TUI === '1'
130
+ if (disabled) return { ok: false, reason: 'disabled' }
131
+
132
+ if (!cargoAvailable()) {
133
+ if (autoInstallRust) {
134
+ const installResult = installRustToolchain()
135
+ if (!installResult.ok) return { ok: false, reason: installResult.reason || 'cargo-missing' }
136
+ } else {
137
+ return { ok: false, reason: 'cargo-missing' }
138
+ }
139
+ }
140
+
141
+ const manifestPath = path.join(PACKAGE_ROOT, 'tui', 'Cargo.toml')
142
+ if (!fs.existsSync(manifestPath)) return { ok: false, reason: 'manifest-missing', manifestPath }
103
143
 
104
144
  const run = spawnSync('cargo', ['run', '--quiet', '--manifest-path', manifestPath], {
105
145
  stdio: 'inherit',
@@ -111,10 +151,10 @@ function tryRustTui({ configDir, outputEnvFile }) {
111
151
  })
112
152
 
113
153
  if (run.status === 0) {
114
- return true
154
+ return { ok: true, manifestPath }
115
155
  }
116
156
 
117
- return false
157
+ return { ok: false, reason: `cargo-run-failed:${run.status ?? 'unknown'}`, manifestPath }
118
158
  }
119
159
 
120
160
  function promptModeText() {
@@ -123,6 +163,7 @@ function promptModeText() {
123
163
 
124
164
  export async function runEnrollmentWizard(options = {}) {
125
165
  const nonInteractive = options.nonInteractive || process.env.IDLEWATCH_ENROLL_NON_INTERACTIVE === '1'
166
+ const noTui = options.noTui || process.env.IDLEWATCH_NO_TUI === '1'
126
167
  const configDir = path.resolve(options.configDir || process.env.IDLEWATCH_ENROLL_CONFIG_DIR || defaultConfigDir())
127
168
  const outputEnvFile = path.resolve(options.outputEnvFile || process.env.IDLEWATCH_ENROLL_OUTPUT_ENV_FILE || path.join(configDir, 'idlewatch.env'))
128
169
 
@@ -137,11 +178,18 @@ export async function runEnrollmentWizard(options = {}) {
137
178
  availableMonitorTargets
138
179
  )
139
180
 
140
- if (!nonInteractive && tryRustTui({ configDir, outputEnvFile })) {
141
- return {
142
- mode: 'tui',
143
- configDir,
144
- outputEnvFile
181
+ if (!nonInteractive && !noTui) {
182
+ const tuiResult = tryRustTui({ configDir, outputEnvFile, autoInstallRust: true })
183
+ if (tuiResult.ok) {
184
+ return {
185
+ mode: 'tui',
186
+ configDir,
187
+ outputEnvFile
188
+ }
189
+ }
190
+
191
+ if (!['disabled', 'cargo-missing'].includes(tuiResult.reason || '')) {
192
+ console.warn(`IdleWatch TUI unavailable (${tuiResult.reason || 'unknown'}). Falling back to text setup.`)
145
193
  }
146
194
  }
147
195