neoagent 1.0.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.
Files changed (54) hide show
  1. package/.env.example +28 -0
  2. package/LICENSE +21 -0
  3. package/README.md +42 -0
  4. package/bin/neoagent.js +8 -0
  5. package/com.neoagent.plist +45 -0
  6. package/docs/configuration.md +45 -0
  7. package/docs/skills.md +45 -0
  8. package/lib/manager.js +459 -0
  9. package/package.json +61 -0
  10. package/server/db/database.js +239 -0
  11. package/server/index.js +442 -0
  12. package/server/middleware/auth.js +35 -0
  13. package/server/public/app.html +559 -0
  14. package/server/public/css/app.css +608 -0
  15. package/server/public/css/styles.css +472 -0
  16. package/server/public/favicon.svg +17 -0
  17. package/server/public/js/app.js +3283 -0
  18. package/server/public/login.html +313 -0
  19. package/server/routes/agents.js +125 -0
  20. package/server/routes/auth.js +105 -0
  21. package/server/routes/browser.js +116 -0
  22. package/server/routes/mcp.js +164 -0
  23. package/server/routes/memory.js +193 -0
  24. package/server/routes/messaging.js +153 -0
  25. package/server/routes/protocols.js +87 -0
  26. package/server/routes/scheduler.js +63 -0
  27. package/server/routes/settings.js +98 -0
  28. package/server/routes/skills.js +107 -0
  29. package/server/routes/store.js +1192 -0
  30. package/server/services/ai/compaction.js +82 -0
  31. package/server/services/ai/engine.js +1690 -0
  32. package/server/services/ai/models.js +46 -0
  33. package/server/services/ai/multiStep.js +112 -0
  34. package/server/services/ai/providers/anthropic.js +181 -0
  35. package/server/services/ai/providers/base.js +40 -0
  36. package/server/services/ai/providers/google.js +187 -0
  37. package/server/services/ai/providers/grok.js +121 -0
  38. package/server/services/ai/providers/ollama.js +162 -0
  39. package/server/services/ai/providers/openai.js +167 -0
  40. package/server/services/ai/toolRunner.js +218 -0
  41. package/server/services/browser/controller.js +320 -0
  42. package/server/services/cli/executor.js +204 -0
  43. package/server/services/mcp/client.js +260 -0
  44. package/server/services/memory/embeddings.js +126 -0
  45. package/server/services/memory/manager.js +431 -0
  46. package/server/services/messaging/base.js +23 -0
  47. package/server/services/messaging/discord.js +238 -0
  48. package/server/services/messaging/manager.js +328 -0
  49. package/server/services/messaging/telegram.js +243 -0
  50. package/server/services/messaging/telnyx.js +693 -0
  51. package/server/services/messaging/whatsapp.js +304 -0
  52. package/server/services/scheduler/cron.js +312 -0
  53. package/server/services/websocket.js +191 -0
  54. package/server/utils/security.js +71 -0
package/.env.example ADDED
@@ -0,0 +1,28 @@
1
+ PORT=3060
2
+ NODE_ENV=development
3
+ SESSION_SECRET=change-this-to-a-random-secret-in-production
4
+
5
+ # Comma-separated list of allowed CORS origins (leave empty to block all cross-origin requests)
6
+ # Example: ALLOWED_ORIGINS=http://localhost:3060,https://yourdomain.com
7
+ ALLOWED_ORIGINS=
8
+
9
+ # xAI API key — used for:
10
+ # • Main AI reasoning (grok-4-1-fast-reasoning — all agent tasks and conversations)
11
+ # • Image generation (grok-imagine-image — generate_image tool)
12
+ # • Image vision / analysis (grok-4-1-fast-reasoning has native image input — analyze_image tool, incoming WhatsApp photos)
13
+ # Get your key at: https://console.x.ai
14
+ XAI_API_KEY=your-xai-api-key-here
15
+
16
+ # OpenAI API key — used for:
17
+ # • Semantic memory embeddings (text-embedding-3-small, 1536 dims)
18
+ # • WhatsApp voice note transcription (whisper-1 — incoming audio messages are auto-transcribed)
19
+ # • Telnyx Voice TTS (tts-1 / tts-1-hd / gpt-4o-mini-tts)
20
+ # • Telnyx Voice STT (whisper-1 / gpt-4o-transcribe — phone call speech recognition)
21
+ # Without this key: memory falls back to keyword search, voice calls and WhatsApp audio transcription are unavailable.
22
+ # Get your key at: https://platform.openai.com/api-keys
23
+ OPENAI_API_KEY=your-openai-api-key-here
24
+
25
+ # Google AI Studio API key — used for:
26
+ # • Gemini models (e.g. gemini-3.1-flash-lite-preview)
27
+ # Get your key at: https://aistudio.google.com/app/apikey
28
+ GOOGLE_AI_KEY=your-google-ai-key-here
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Neo
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,42 @@
1
+ <div align="center">
2
+
3
+ # NeoAgent
4
+
5
+ **Your agent. Your server. Your rules.**
6
+
7
+ [![Node.js](https://img.shields.io/badge/Node.js-18+-5fa04e?style=flat-square&logo=node.js&logoColor=white)](https://nodejs.org)
8
+ [![SQLite](https://img.shields.io/badge/SQLite-WAL-003b57?style=flat-square&logo=sqlite&logoColor=white)](https://sqlite.org)
9
+ [![Multi-platform](https://img.shields.io/badge/macOS%20%2F%20Linux-supported-6366f1?style=flat-square&logo=apple&logoColor=white)](#)
10
+ [![License](https://img.shields.io/badge/License-MIT-a855f7?style=flat-square)](LICENSE)
11
+ [![Android APK](https://img.shields.io/badge/Android-Download%20APK-3ddc84?style=flat-square&logo=android&logoColor=white)](https://github.com/NeoLabs-Systems/NeoAgent/releases/latest/download/app-debug.apk)
12
+
13
+ A self-hosted, proactive AI agent with a web UI — no cloud dependency, no limits.
14
+ Connects to Anthropic, OpenAI, xAI, Google and local Ollama models.
15
+ Runs tasks on a schedule, controls a browser, manages files, and talks to you over Telegram, Discord, or WhatsApp.
16
+
17
+ ```bash
18
+ npm install -g neoagent
19
+ neoagent install
20
+ ```
21
+
22
+ From source:
23
+ ```bash
24
+ bash <(curl -fsSL https://raw.githubusercontent.com/NeoLabs-Systems/NeoAgent/main/install.sh)
25
+ ```
26
+
27
+ Manage the service:
28
+ ```bash
29
+ neoagent status
30
+ neoagent update
31
+ neoagent logs
32
+ ```
33
+
34
+ ---
35
+
36
+ [⚙️ Configuration](docs/configuration.md) · [🧰 Skills](docs/skills.md) · [🐛 Issues](https://github.com/NeoLabs-Systems/NeoAgent/issues)
37
+
38
+ ---
39
+
40
+ *Made with ❤️ by [Neo](https://github.com/neooriginal) · [NeoLabs Systems](https://github.com/NeoLabs-Systems)*
41
+
42
+ </div>
@@ -0,0 +1,8 @@
1
+ #!/usr/bin/env node
2
+
3
+ const { runCLI } = require('../lib/manager');
4
+
5
+ runCLI(process.argv.slice(2)).catch((err) => {
6
+ console.error(`[neoagent] ${err.message}`);
7
+ process.exit(1);
8
+ });
@@ -0,0 +1,45 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.neoagent</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>/usr/local/bin/node</string>
11
+ <string>/Users/neo/NeoAgent/server/index.js</string>
12
+ </array>
13
+
14
+ <key>WorkingDirectory</key>
15
+ <string>/Users/neo/NeoAgent</string>
16
+
17
+ <key>EnvironmentVariables</key>
18
+ <dict>
19
+ <key>PATH</key>
20
+ <string>/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
21
+ <key>HOME</key>
22
+ <string>/Users/neo</string>
23
+ <key>NODE_ENV</key>
24
+ <string>production</string>
25
+ </dict>
26
+
27
+ <!-- Auto-restart if the process exits for any reason -->
28
+ <key>KeepAlive</key>
29
+ <true/>
30
+
31
+ <!-- Start immediately when the plist is loaded / on login -->
32
+ <key>RunAtLoad</key>
33
+ <true/>
34
+
35
+ <!-- Throttle rapid restart loops: wait 10s before restarting -->
36
+ <key>ThrottleInterval</key>
37
+ <integer>10</integer>
38
+
39
+ <key>StandardOutPath</key>
40
+ <string>/Users/neo/NeoAgent/data/logs/neoagent.log</string>
41
+
42
+ <key>StandardErrorPath</key>
43
+ <string>/Users/neo/NeoAgent/data/logs/neoagent.error.log</string>
44
+ </dict>
45
+ </plist>
@@ -0,0 +1,45 @@
1
+ # Configuration
2
+
3
+ All settings live in `.env` at the project root. Run `neoagent setup` to regenerate interactively.
4
+
5
+ ## Variables
6
+
7
+ ### Core
8
+
9
+ | Variable | Default | Description |
10
+ |---|---|---|
11
+ | `PORT` | `3060` | HTTP port |
12
+ | `SESSION_SECRET` | *(required)* | Random string for session signing — generate with `openssl rand -hex 32` |
13
+ | `NODE_ENV` | `production` | Set to `development` to enable verbose logs |
14
+ | `SECURE_COOKIES` | `false` | Set `true` when behind a TLS-terminating proxy |
15
+ | `ALLOWED_ORIGINS` | *(none)* | Comma-separated CORS origins, e.g. `https://example.com` |
16
+
17
+ ### AI Providers
18
+
19
+ At least one API key is required. The active provider and model are configured in the web UI.
20
+
21
+ | Variable | Provider |
22
+ |---|---|
23
+ | `ANTHROPIC_API_KEY` | Claude (Anthropic) |
24
+ | `OPENAI_API_KEY` | GPT-4o / Whisper (OpenAI) |
25
+ | `XAI_API_KEY` | Grok (xAI) |
26
+ | `GOOGLE_AI_KEY` | Gemini (Google) |
27
+ | `OLLAMA_URL` | Local Ollama (`http://localhost:11434`) |
28
+
29
+ ### Messaging
30
+
31
+ | Variable | Description |
32
+ |---|---|
33
+ | `TELNYX_WEBHOOK_TOKEN` | Telnyx webhook signature verification |
34
+
35
+ Telegram, Discord, and WhatsApp tokens are stored in the database via the web UI Settings page — not in `.env`.
36
+
37
+ ---
38
+
39
+ ## Minimal `.env` example
40
+
41
+ ```dotenv
42
+ PORT=3060
43
+ SESSION_SECRET=change-me-to-something-random
44
+ ANTHROPIC_API_KEY=sk-ant-...
45
+ ```
package/docs/skills.md ADDED
@@ -0,0 +1,45 @@
1
+ # Skills
2
+
3
+ Skills are Markdown files in `agent-data/skills/` that describe capabilities the agent can use when responding to tasks. They are loaded at runtime — no restart needed after editing.
4
+
5
+ ## Built-in skills
6
+
7
+ | Skill | Description |
8
+ |---|---|
9
+ | `browser.md` | Puppeteer-powered web browsing and scraping |
10
+ | `cli.md` | Execute shell commands in a persistent terminal |
11
+ | `files.md` | Read, write, search files on the host |
12
+ | `memory.md` | Store and recall long-term memories |
13
+ | `messaging.md` | Send messages via Telegram, Discord, WhatsApp |
14
+ | `system-stats.md` | CPU, memory, disk usage |
15
+ | `weather.md` | Current weather via wttr.in |
16
+ | `ip-info.md` | Public IP and geolocation |
17
+ | `port-check.md` | Check if a TCP port is open |
18
+ | `ping-host.md` | Ping a host |
19
+ | `process-monitor.md` | List running processes |
20
+ | `disk-usage.md` | Directory size breakdown |
21
+ | `find-large-files.md` | Locate large files |
22
+ | `docker-status.md` | Docker container status |
23
+ | `tail-log.md` | Tail any log file |
24
+ | `news-hackernews.md` | Fetch Hacker News top stories |
25
+ | `qr-code.md` | Generate QR codes |
26
+
27
+ ## Adding a skill
28
+
29
+ Create a Markdown file in `agent-data/skills/`:
30
+
31
+ ```markdown
32
+ # My Skill Name
33
+
34
+ Brief description of what this skill does and when to use it.
35
+
36
+ ## Usage
37
+
38
+ Explain the steps or commands the agent should follow.
39
+ ```
40
+
41
+ The agent reads all `.md` files in the skills directory on each conversation turn.
42
+
43
+ ## MCP tools
44
+
45
+ External tools are connected via the [Model Context Protocol](https://modelcontextprotocol.io). Configure MCP servers in the web UI under **Settings → MCP**. Connected tools appear alongside built-in skills automatically.
package/lib/manager.js ADDED
@@ -0,0 +1,459 @@
1
+ const fs = require('fs');
2
+ const os = require('os');
3
+ const path = require('path');
4
+ const net = require('net');
5
+ const crypto = require('crypto');
6
+ const readline = require('readline');
7
+ const { spawn, spawnSync } = require('child_process');
8
+
9
+ const APP_DIR = path.resolve(__dirname, '..');
10
+ const APP_NAME = 'NeoAgent';
11
+ const SERVICE_LABEL = 'com.neoagent';
12
+ const PLIST_SRC = path.join(APP_DIR, 'com.neoagent.plist');
13
+ const PLIST_DST = path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.neoagent.plist');
14
+ const SYSTEMD_UNIT = path.join(os.homedir(), '.config', 'systemd', 'user', 'neoagent.service');
15
+ const LOG_DIR = path.join(APP_DIR, 'data', 'logs');
16
+ const ENV_FILE = path.join(APP_DIR, '.env');
17
+
18
+ const COLORS = process.stdout.isTTY
19
+ ? {
20
+ reset: '\x1b[0m',
21
+ bold: '\x1b[1m',
22
+ red: '\x1b[1;31m',
23
+ green: '\x1b[1;32m',
24
+ yellow: '\x1b[1;33m',
25
+ blue: '\x1b[1;34m',
26
+ cyan: '\x1b[1;36m',
27
+ dim: '\x1b[2m'
28
+ }
29
+ : { reset: '', bold: '', red: '', green: '', yellow: '', blue: '', cyan: '', dim: '' };
30
+
31
+ function logInfo(msg) {
32
+ console.log(` ${COLORS.blue}->${COLORS.reset} ${msg}`);
33
+ }
34
+
35
+ function logOk(msg) {
36
+ console.log(` ${COLORS.green}ok${COLORS.reset} ${msg}`);
37
+ }
38
+
39
+ function logWarn(msg) {
40
+ console.warn(` ${COLORS.yellow}warn${COLORS.reset} ${msg}`);
41
+ }
42
+
43
+ function logErr(msg) {
44
+ console.error(` ${COLORS.red}err${COLORS.reset} ${msg}`);
45
+ }
46
+
47
+ function heading(text) {
48
+ console.log(`\n${COLORS.bold}${text}${COLORS.reset}`);
49
+ }
50
+
51
+ function detectPlatform() {
52
+ if (process.platform === 'darwin') return 'macos';
53
+ if (process.platform === 'linux') return 'linux';
54
+ return 'other';
55
+ }
56
+
57
+ function loadEnvPort() {
58
+ try {
59
+ const env = fs.readFileSync(ENV_FILE, 'utf8');
60
+ const line = env.split('\n').find((entry) => entry.startsWith('PORT='));
61
+ if (!line) return 3060;
62
+ const raw = line.split('=')[1]?.trim();
63
+ const num = Number(raw);
64
+ return Number.isFinite(num) && num > 0 ? num : 3060;
65
+ } catch {
66
+ return 3060;
67
+ }
68
+ }
69
+
70
+ function runOrThrow(cmd, args, options = {}) {
71
+ const res = spawnSync(cmd, args, { stdio: 'inherit', cwd: APP_DIR, ...options });
72
+ if (res.status !== 0) {
73
+ throw new Error(`Command failed: ${cmd} ${args.join(' ')}`);
74
+ }
75
+ }
76
+
77
+ function runQuiet(cmd, args, options = {}) {
78
+ return spawnSync(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8', cwd: APP_DIR, ...options });
79
+ }
80
+
81
+ function commandExists(cmd) {
82
+ const res = runQuiet('bash', ['-lc', `command -v ${cmd}`]);
83
+ return res.status === 0;
84
+ }
85
+
86
+ function ensureLogDir() {
87
+ fs.mkdirSync(LOG_DIR, { recursive: true });
88
+ }
89
+
90
+ function killByPort(port) {
91
+ if (!commandExists('lsof')) return false;
92
+ const res = runQuiet('bash', ['-lc', `lsof -ti tcp:${port}`]);
93
+ if (res.status !== 0 || !res.stdout.trim()) return false;
94
+ const pids = res.stdout
95
+ .trim()
96
+ .split('\n')
97
+ .map((v) => Number(v.trim()))
98
+ .filter((v) => Number.isFinite(v) && v > 0);
99
+ let killed = false;
100
+ for (const pid of pids) {
101
+ try {
102
+ process.kill(pid, 'SIGTERM');
103
+ killed = true;
104
+ } catch {
105
+ // Ignore stale pids.
106
+ }
107
+ }
108
+ return killed;
109
+ }
110
+
111
+ function isPortOpen(port) {
112
+ return new Promise((resolve) => {
113
+ const sock = new net.Socket();
114
+ let done = false;
115
+
116
+ const finish = (open) => {
117
+ if (done) return;
118
+ done = true;
119
+ sock.destroy();
120
+ resolve(open);
121
+ };
122
+
123
+ sock.setTimeout(700);
124
+ sock.once('connect', () => finish(true));
125
+ sock.once('timeout', () => finish(false));
126
+ sock.once('error', () => finish(false));
127
+ sock.connect(port, '127.0.0.1');
128
+ });
129
+ }
130
+
131
+ function randomSecret() {
132
+ return crypto.randomBytes(24).toString('hex');
133
+ }
134
+
135
+ async function ask(question, fallback = '') {
136
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
137
+ return new Promise((resolve) => {
138
+ const suffix = fallback ? ` [${fallback}]` : '';
139
+ rl.question(` ? ${question}${suffix}: `, (answer) => {
140
+ rl.close();
141
+ const trimmed = answer.trim();
142
+ resolve(trimmed || fallback);
143
+ });
144
+ });
145
+ }
146
+
147
+ async function cmdSetup() {
148
+ heading('Environment Setup');
149
+
150
+ const current = {};
151
+ if (fs.existsSync(ENV_FILE)) {
152
+ const lines = fs.readFileSync(ENV_FILE, 'utf8').split('\n');
153
+ for (const line of lines) {
154
+ if (!line || line.startsWith('#') || !line.includes('=')) continue;
155
+ const idx = line.indexOf('=');
156
+ const key = line.slice(0, idx).trim();
157
+ const value = line.slice(idx + 1).trim();
158
+ current[key] = value;
159
+ }
160
+ }
161
+
162
+ const port = await ask('Server port', current.PORT || '3060');
163
+ const sessionSecret = await ask('Session secret', current.SESSION_SECRET || randomSecret());
164
+ const anthropic = await ask('Anthropic API key', current.ANTHROPIC_API_KEY || '');
165
+ const openai = await ask('OpenAI API key', current.OPENAI_API_KEY || '');
166
+ const xai = await ask('xAI API key', current.XAI_API_KEY || '');
167
+ const google = await ask('Google API key', current.GOOGLE_AI_KEY || '');
168
+ const ollama = await ask('Ollama URL', current.OLLAMA_URL || 'http://localhost:11434');
169
+ const origins = await ask('Allowed CORS origins', current.ALLOWED_ORIGINS || '');
170
+
171
+ const lines = [
172
+ `NODE_ENV=production`,
173
+ `PORT=${port}`,
174
+ `SESSION_SECRET=${sessionSecret}`,
175
+ anthropic ? `ANTHROPIC_API_KEY=${anthropic}` : '',
176
+ openai ? `OPENAI_API_KEY=${openai}` : '',
177
+ xai ? `XAI_API_KEY=${xai}` : '',
178
+ google ? `GOOGLE_AI_KEY=${google}` : '',
179
+ ollama ? `OLLAMA_URL=${ollama}` : '',
180
+ origins ? `ALLOWED_ORIGINS=${origins}` : ''
181
+ ].filter(Boolean);
182
+
183
+ fs.writeFileSync(ENV_FILE, `${lines.join('\n')}\n`, { mode: 0o600 });
184
+ logOk(`Wrote ${ENV_FILE}`);
185
+ }
186
+
187
+ function installDependencies() {
188
+ heading('Dependencies');
189
+ runOrThrow('npm', ['install', '--omit=dev', '--no-audit', '--no-fund']);
190
+ logOk('Dependencies installed');
191
+ }
192
+
193
+ function installMacService() {
194
+ ensureLogDir();
195
+ fs.mkdirSync(path.dirname(PLIST_DST), { recursive: true });
196
+
197
+ if (!fs.existsSync(PLIST_SRC)) {
198
+ throw new Error(`Missing plist template at ${PLIST_SRC}`);
199
+ }
200
+
201
+ const nodeBin = process.execPath;
202
+ const content = fs
203
+ .readFileSync(PLIST_SRC, 'utf8')
204
+ .replace(/\/usr\/local\/bin\/node/g, nodeBin)
205
+ .replace(/\/Users\/neo\/NeoAgent/g, APP_DIR)
206
+ .replace(/\/Users\/neo/g, os.homedir());
207
+
208
+ fs.writeFileSync(PLIST_DST, content);
209
+
210
+ runQuiet('launchctl', ['unload', PLIST_DST]);
211
+ runOrThrow('launchctl', ['load', PLIST_DST]);
212
+ logOk(`launchd service loaded (${SERVICE_LABEL})`);
213
+ }
214
+
215
+ function installLinuxService() {
216
+ ensureLogDir();
217
+ fs.mkdirSync(path.dirname(SYSTEMD_UNIT), { recursive: true });
218
+
219
+ const unit = `[Unit]\nDescription=NeoAgent — Proactive personal AI agent\nAfter=network.target\n\n[Service]\nType=simple\nWorkingDirectory=${APP_DIR}\nExecStart=${process.execPath} ${path.join(APP_DIR, 'server', 'index.js')}\nRestart=always\nRestartSec=10\nEnvironmentFile=-${ENV_FILE}\nEnvironment=NODE_ENV=production\nStandardOutput=append:${path.join(LOG_DIR, 'neoagent.log')}\nStandardError=append:${path.join(LOG_DIR, 'neoagent.error.log')}\n\n[Install]\nWantedBy=default.target\n`;
220
+
221
+ fs.writeFileSync(SYSTEMD_UNIT, unit);
222
+
223
+ runOrThrow('systemctl', ['--user', 'daemon-reload']);
224
+ runOrThrow('systemctl', ['--user', 'enable', 'neoagent']);
225
+ runOrThrow('systemctl', ['--user', 'start', 'neoagent']);
226
+ logOk('systemd user service installed and started');
227
+ }
228
+
229
+ function startFallback() {
230
+ ensureLogDir();
231
+ const out = fs.openSync(path.join(LOG_DIR, 'neoagent.log'), 'a');
232
+ const err = fs.openSync(path.join(LOG_DIR, 'neoagent.error.log'), 'a');
233
+
234
+ const child = spawn(process.execPath, [path.join(APP_DIR, 'server', 'index.js')], {
235
+ cwd: APP_DIR,
236
+ detached: true,
237
+ stdio: ['ignore', out, err]
238
+ });
239
+ child.unref();
240
+
241
+ fs.mkdirSync(path.join(APP_DIR, 'data'), { recursive: true });
242
+ fs.writeFileSync(path.join(APP_DIR, 'data', 'neoagent.pid'), String(child.pid));
243
+ logOk(`Started detached process (pid ${child.pid})`);
244
+ }
245
+
246
+ async function cmdInstall() {
247
+ heading(`Install ${APP_NAME}`);
248
+ if (!fs.existsSync(ENV_FILE)) {
249
+ logWarn('.env not found; starting setup');
250
+ await cmdSetup();
251
+ }
252
+
253
+ installDependencies();
254
+
255
+ const platform = detectPlatform();
256
+ if (platform === 'macos' && commandExists('launchctl')) {
257
+ installMacService();
258
+ } else if (platform === 'linux' && commandExists('systemctl')) {
259
+ installLinuxService();
260
+ } else {
261
+ startFallback();
262
+ }
263
+
264
+ const port = loadEnvPort();
265
+ logOk(`Running on http://localhost:${port}`);
266
+ }
267
+
268
+ function cmdStart() {
269
+ heading(`Start ${APP_NAME}`);
270
+ const platform = detectPlatform();
271
+
272
+ if (platform === 'macos' && fs.existsSync(PLIST_DST)) {
273
+ runQuiet('launchctl', ['load', PLIST_DST]);
274
+ logOk('launchd start requested');
275
+ return;
276
+ }
277
+
278
+ if (platform === 'linux' && fs.existsSync(SYSTEMD_UNIT)) {
279
+ runOrThrow('systemctl', ['--user', 'start', 'neoagent']);
280
+ logOk('systemd start requested');
281
+ return;
282
+ }
283
+
284
+ startFallback();
285
+ }
286
+
287
+ function cmdStop() {
288
+ heading(`Stop ${APP_NAME}`);
289
+ const platform = detectPlatform();
290
+
291
+ if (platform === 'macos' && fs.existsSync(PLIST_DST)) {
292
+ runQuiet('launchctl', ['unload', PLIST_DST]);
293
+ logOk('launchd stop requested');
294
+ return;
295
+ }
296
+
297
+ if (platform === 'linux' && fs.existsSync(SYSTEMD_UNIT)) {
298
+ runQuiet('systemctl', ['--user', 'stop', 'neoagent']);
299
+ logOk('systemd stop requested');
300
+ return;
301
+ }
302
+
303
+ const pidPath = path.join(APP_DIR, 'data', 'neoagent.pid');
304
+ let stopped = false;
305
+ if (fs.existsSync(pidPath)) {
306
+ const pid = Number(fs.readFileSync(pidPath, 'utf8').trim());
307
+ if (Number.isFinite(pid) && pid > 0) {
308
+ try {
309
+ process.kill(pid, 'SIGTERM');
310
+ logOk(`Stopped pid ${pid}`);
311
+ stopped = true;
312
+ } catch {
313
+ logWarn(`pid ${pid} not running`);
314
+ }
315
+ }
316
+ fs.rmSync(pidPath, { force: true });
317
+ }
318
+
319
+ const port = loadEnvPort();
320
+ if (killByPort(port)) {
321
+ logOk(`Stopped process listening on port ${port}`);
322
+ stopped = true;
323
+ }
324
+ if (!stopped) logWarn('No running process found');
325
+ }
326
+
327
+ function cmdRestart() {
328
+ heading(`Restart ${APP_NAME}`);
329
+ cmdStop();
330
+ cmdStart();
331
+ }
332
+
333
+ function cmdUninstall() {
334
+ heading(`Uninstall ${APP_NAME}`);
335
+ const platform = detectPlatform();
336
+
337
+ if (platform === 'macos') {
338
+ runQuiet('launchctl', ['unload', PLIST_DST]);
339
+ fs.rmSync(PLIST_DST, { force: true });
340
+ logOk('Removed launchd service');
341
+ return;
342
+ }
343
+
344
+ if (platform === 'linux') {
345
+ runQuiet('systemctl', ['--user', 'stop', 'neoagent']);
346
+ runQuiet('systemctl', ['--user', 'disable', 'neoagent']);
347
+ fs.rmSync(SYSTEMD_UNIT, { force: true });
348
+ runQuiet('systemctl', ['--user', 'daemon-reload']);
349
+ logOk('Removed systemd service');
350
+ return;
351
+ }
352
+
353
+ cmdStop();
354
+ }
355
+
356
+ async function cmdStatus() {
357
+ heading(`${APP_NAME} Status`);
358
+ const port = loadEnvPort();
359
+ const running = await isPortOpen(port);
360
+
361
+ if (running) {
362
+ logOk(`running on http://localhost:${port}`);
363
+ } else {
364
+ logWarn(`not reachable on port ${port}`);
365
+ }
366
+
367
+ const gitSha = runQuiet('git', ['rev-parse', '--short', 'HEAD']);
368
+ if (gitSha.status === 0) {
369
+ console.log(` version ${gitSha.stdout.trim()}`);
370
+ }
371
+ }
372
+
373
+ function cmdLogs() {
374
+ heading('Logs');
375
+ ensureLogDir();
376
+ const log = path.join(LOG_DIR, 'neoagent.log');
377
+ const err = path.join(LOG_DIR, 'neoagent.error.log');
378
+ if (!fs.existsSync(log)) fs.writeFileSync(log, '');
379
+ if (!fs.existsSync(err)) fs.writeFileSync(err, '');
380
+
381
+ runOrThrow('tail', ['-f', log, err], { cwd: APP_DIR });
382
+ }
383
+
384
+ function cmdUpdate() {
385
+ heading(`Update ${APP_NAME}`);
386
+
387
+ if (fs.existsSync(path.join(APP_DIR, '.git')) && commandExists('git')) {
388
+ const branch = runQuiet('git', ['rev-parse', '--abbrev-ref', 'HEAD']);
389
+ const current = runQuiet('git', ['rev-parse', '--short', 'HEAD']);
390
+
391
+ runOrThrow('git', ['fetch', 'origin']);
392
+ if (branch.status === 0) {
393
+ runOrThrow('git', ['pull', '--rebase', 'origin', branch.stdout.trim()]);
394
+ } else {
395
+ runOrThrow('git', ['pull', '--rebase']);
396
+ }
397
+
398
+ const next = runQuiet('git', ['rev-parse', '--short', 'HEAD']);
399
+ if (current.status === 0 && next.status === 0 && current.stdout.trim() !== next.stdout.trim()) {
400
+ logOk(`Updated ${current.stdout.trim()} -> ${next.stdout.trim()}`);
401
+ installDependencies();
402
+ } else {
403
+ logOk('Already up to date');
404
+ }
405
+ } else {
406
+ logWarn('No git repo detected; update skipped. Use npm update when installed from npm.');
407
+ }
408
+
409
+ cmdRestart();
410
+ }
411
+
412
+ function printHelp() {
413
+ console.log(`${APP_NAME} manager`);
414
+ console.log('Usage: neoagent <command>');
415
+ console.log('Commands: install | setup | update | restart | start | stop | status | logs | uninstall');
416
+ }
417
+
418
+ async function runCLI(argv) {
419
+ const command = argv[0] || 'help';
420
+
421
+ switch (command) {
422
+ case 'install':
423
+ await cmdInstall();
424
+ break;
425
+ case 'setup':
426
+ await cmdSetup();
427
+ break;
428
+ case 'update':
429
+ cmdUpdate();
430
+ break;
431
+ case 'restart':
432
+ cmdRestart();
433
+ break;
434
+ case 'start':
435
+ cmdStart();
436
+ break;
437
+ case 'stop':
438
+ cmdStop();
439
+ break;
440
+ case 'status':
441
+ await cmdStatus();
442
+ break;
443
+ case 'logs':
444
+ cmdLogs();
445
+ break;
446
+ case 'uninstall':
447
+ cmdUninstall();
448
+ break;
449
+ case 'help':
450
+ case '--help':
451
+ case '-h':
452
+ printHelp();
453
+ break;
454
+ default:
455
+ throw new Error(`Unknown command: ${command}`);
456
+ }
457
+ }
458
+
459
+ module.exports = { runCLI };