agent-message 0.1.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.
package/README.md ADDED
@@ -0,0 +1,227 @@
1
+ # Agent Messenger
2
+
3
+ Agent Messenger is a direct-message stack with three clients:
4
+ - HTTP/SSE server (`server/`)
5
+ - Web app (`web/`)
6
+ - CLI (`cli/`)
7
+
8
+ This README covers a Phase 7 quickstart for local development and local production-like testing.
9
+
10
+ ## Prerequisites
11
+
12
+ - Go `1.26+`
13
+ - Node.js `18+` and npm
14
+ - Docker + Docker Compose (for PostgreSQL compose flow)
15
+
16
+ ## Server Quickstart
17
+
18
+ ### Option A: Local server with SQLite (default)
19
+
20
+ ```bash
21
+ cd server
22
+ go run .
23
+ ```
24
+
25
+ Default server settings:
26
+ - `SERVER_ADDR=:8080`
27
+ - `DB_DRIVER=sqlite`
28
+ - `SQLITE_DSN=./agent_messenger.sqlite`
29
+ - `UPLOAD_DIR=./uploads`
30
+ - `CORS_ALLOWED_ORIGINS=*`
31
+
32
+ Example override:
33
+
34
+ ```bash
35
+ cd server
36
+ DB_DRIVER=sqlite SQLITE_DSN=./dev.sqlite UPLOAD_DIR=./uploads go run .
37
+ ```
38
+
39
+ ### Option B: Local production-like stack (Server + PostgreSQL)
40
+
41
+ ```bash
42
+ docker compose up --build
43
+ ```
44
+
45
+ This starts:
46
+ - `postgres` on `localhost:5432`
47
+ - `server` on `localhost:8080` with:
48
+ - `DB_DRIVER=postgres`
49
+ - `POSTGRES_DSN=postgres://agent:agent@postgres:5432/agent_messenger?sslmode=disable`
50
+
51
+ To stop and remove containers:
52
+
53
+ ```bash
54
+ docker compose down
55
+ ```
56
+
57
+ To also remove persisted DB/uploads volumes:
58
+
59
+ ```bash
60
+ docker compose down -v
61
+ ```
62
+
63
+ ## Web Quickstart
64
+
65
+ ```bash
66
+ cd web
67
+ npm ci
68
+ npm run dev
69
+ ```
70
+
71
+ In local dev, Vite proxies `/api/...` and `/static/uploads/...` to `http://localhost:8080`, so you usually do not need `VITE_API_BASE_URL`.
72
+ If your API is on a different origin, set `VITE_API_BASE_URL`:
73
+
74
+ ```bash
75
+ cd web
76
+ VITE_API_BASE_URL=http://localhost:8080 npm run dev
77
+ ```
78
+
79
+ When `VITE_API_BASE_URL` is set, requests become cross-origin and the server must allow that origin via `CORS_ALLOWED_ORIGINS`.
80
+
81
+ Build check:
82
+
83
+ ```bash
84
+ cd web
85
+ npm run build
86
+ ```
87
+
88
+ ## Local Bundle Commands
89
+
90
+ From the project root, you can start the SQLite-backed API server and the production-like local web gateway together:
91
+
92
+ ```bash
93
+ ./dev-up
94
+ ```
95
+
96
+ This will:
97
+ - build `web/dist`
98
+ - build the Go server binary into `~/.agent-messenger/bin`
99
+ - start the API on `127.0.0.1:18080`
100
+ - start the local web gateway on `127.0.0.1:8788`
101
+
102
+ To stop both processes:
103
+
104
+ ```bash
105
+ ./dev-stop
106
+ ```
107
+
108
+ If you also want to start or stop the named tunnel that serves `https://agent.namjaeyoun.com`, use:
109
+
110
+ ```bash
111
+ ./dev-up --with-tunnel
112
+ ./dev-stop --with-tunnel
113
+ ```
114
+
115
+ ## npm Distribution (macOS)
116
+
117
+ The repo now includes a publishable npm package surface for macOS (`arm64` and `x64`). The npm command keeps the existing CLI behavior and also adds local stack lifecycle commands:
118
+
119
+ ```bash
120
+ agent-messenger start
121
+ agent-messenger status
122
+ agent-messenger stop
123
+ ```
124
+
125
+ Default npm launcher ports:
126
+ - API: `127.0.0.1:8080`
127
+ - Web: `127.0.0.1:8788`
128
+
129
+ You can override runtime location and ports when needed:
130
+
131
+ ```bash
132
+ agent-messenger start --runtime-dir /tmp/agent-messenger --api-port 28080 --web-port 28788
133
+ agent-messenger status --runtime-dir /tmp/agent-messenger --api-port 28080 --web-port 28788
134
+ agent-messenger stop --runtime-dir /tmp/agent-messenger
135
+ ```
136
+
137
+ When publishing, `npm pack` / `npm publish` will run the package `prepack` hook, which:
138
+ - builds `web/dist`
139
+ - bundles `deploy/agent_gateway.mjs`
140
+ - cross-compiles macOS `arm64` and `x64` binaries for the Go CLI and API server into `npm/runtime/`
141
+
142
+ You can run the same packaging step manually from the repo root:
143
+
144
+ ```bash
145
+ npm run prepare:npm-bundle
146
+ ```
147
+
148
+ PWA install:
149
+
150
+ - Open the deployed web app in Safari on iPhone.
151
+ - Use `Share -> Add to Home Screen`.
152
+ - The app now ships with a web app manifest, service worker, and Apple touch icon so it can be installed like a standalone app.
153
+
154
+ ## Claude Code Skill
155
+
156
+ Install the agent-messenger CLI skill to give Claude Code full knowledge of this project's CLI commands, flags, and json_render component catalog:
157
+
158
+ ```bash
159
+ npx skills add https://github.com/siisee11/agent-messenger --skill agent-messenger-cli
160
+ ```
161
+
162
+ ## CLI Quickstart
163
+
164
+ Run from `cli/` with optional `--server-url` override.
165
+
166
+ ```bash
167
+ cd cli
168
+ go run . --server-url http://localhost:8080 register alice 1234
169
+ go run . --server-url http://localhost:8080 login alice 1234
170
+ go run . profile list
171
+ go run . profile switch alice
172
+ ```
173
+
174
+ Common commands:
175
+
176
+ ```bash
177
+ # Conversations
178
+ go run . --server-url http://localhost:8080 ls
179
+ go run . --server-url http://localhost:8080 open bob
180
+
181
+ # Messaging
182
+ go run . --server-url http://localhost:8080 send bob "hello"
183
+ go run . --server-url http://localhost:8080 read bob --n 20
184
+ go run . --server-url http://localhost:8080 edit 1 "edited text"
185
+ go run . --server-url http://localhost:8080 delete 1
186
+
187
+ # Reactions
188
+ go run . --server-url http://localhost:8080 react 1 👍
189
+ go run . --server-url http://localhost:8080 unreact 1 👍
190
+
191
+ # Realtime watch
192
+ go run . --server-url http://localhost:8080 watch bob
193
+ ```
194
+
195
+ CLI config is stored at `~/.agent-messenger/config` by default.
196
+ Each successful `login` or `register` also saves a named profile, and `go run . profile switch <username>` swaps the active account locally.
197
+
198
+ ## Validation and Constraints (Phase 7)
199
+
200
+ - Username identity fields: `3-32` chars, allowed `[A-Za-z0-9._-]`
201
+ - PIN: `4-6` numeric digits
202
+ - Uploads:
203
+ - max file size: `20 MB`
204
+ - unsupported file types are rejected
205
+
206
+ ## Dev Checks
207
+
208
+ Server:
209
+
210
+ ```bash
211
+ cd server
212
+ go test ./...
213
+ ```
214
+
215
+ CLI:
216
+
217
+ ```bash
218
+ cd cli
219
+ go test ./...
220
+ ```
221
+
222
+ Web:
223
+
224
+ ```bash
225
+ cd web
226
+ npm run build
227
+ ```
@@ -0,0 +1,386 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from 'node:child_process'
4
+ import { closeSync, existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
5
+ import { access, constants } from 'node:fs/promises'
6
+ import os from 'node:os'
7
+ import { dirname, join, resolve } from 'node:path'
8
+ import process from 'node:process'
9
+ import { fileURLToPath } from 'node:url'
10
+
11
+ const DEFAULT_API_HOST = '127.0.0.1'
12
+ const DEFAULT_API_PORT = 8080
13
+ const DEFAULT_WEB_HOST = '127.0.0.1'
14
+ const DEFAULT_WEB_PORT = 8788
15
+ const STARTUP_ATTEMPTS = 40
16
+ const STARTUP_DELAY_MS = 500
17
+ const PROCESS_STOP_DELAY_MS = 1000
18
+
19
+ const scriptDir = dirname(fileURLToPath(import.meta.url))
20
+ const packageRoot = resolve(scriptDir, '..', '..')
21
+ const bundleRoot = resolve(packageRoot, 'npm', 'runtime')
22
+ const bundleBinDir = join(bundleRoot, 'bin')
23
+ const bundleGatewayPath = join(bundleRoot, 'agent_gateway.mjs')
24
+ const bundleWebDistDir = join(bundleRoot, 'web-dist')
25
+
26
+ const lifecycleCommands = new Set(['start', 'stop', 'status'])
27
+
28
+ async function main() {
29
+ const [command, ...rest] = process.argv.slice(2)
30
+ if (!command) {
31
+ printRootUsage()
32
+ process.exit(1)
33
+ }
34
+
35
+ if (command === '--help' || command === '-h' || command === 'help') {
36
+ printRootUsage()
37
+ return
38
+ }
39
+
40
+ if (lifecycleCommands.has(command)) {
41
+ const options = parseLifecycleOptions(rest)
42
+ await ensureBundleReady()
43
+
44
+ if (command === 'start') {
45
+ await startStack(options)
46
+ return
47
+ }
48
+ if (command === 'stop') {
49
+ await stopStack(options, { quiet: false })
50
+ return
51
+ }
52
+ await printStatus(options)
53
+ return
54
+ }
55
+
56
+ await ensureBundleReady()
57
+ delegateToBundledCli([command, ...rest])
58
+ }
59
+
60
+ function printRootUsage() {
61
+ console.error(`Usage:
62
+ agent-messenger start [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
63
+ agent-messenger stop [--runtime-dir <dir>]
64
+ agent-messenger status [--runtime-dir <dir>] [--api-host <host>] [--api-port <port>] [--web-host <host>] [--web-port <port>]
65
+ agent-messenger <existing-cli-command> [...args]`)
66
+ }
67
+
68
+ function parseLifecycleOptions(args) {
69
+ const options = {
70
+ runtimeDir: join(os.homedir(), '.agent-messenger'),
71
+ apiHost: DEFAULT_API_HOST,
72
+ apiPort: DEFAULT_API_PORT,
73
+ webHost: DEFAULT_WEB_HOST,
74
+ webPort: DEFAULT_WEB_PORT,
75
+ }
76
+
77
+ for (let index = 0; index < args.length; index += 1) {
78
+ const arg = args[index]
79
+ if (arg === '--help' || arg === '-h') {
80
+ printRootUsage()
81
+ process.exit(0)
82
+ }
83
+
84
+ if (arg === '--runtime-dir') {
85
+ options.runtimeDir = requireOptionValue(args, ++index, arg)
86
+ continue
87
+ }
88
+ if (arg === '--api-host') {
89
+ options.apiHost = requireOptionValue(args, ++index, arg)
90
+ continue
91
+ }
92
+ if (arg === '--web-host') {
93
+ options.webHost = requireOptionValue(args, ++index, arg)
94
+ continue
95
+ }
96
+ if (arg === '--api-port') {
97
+ options.apiPort = parsePort(requireOptionValue(args, ++index, arg), arg)
98
+ continue
99
+ }
100
+ if (arg === '--web-port') {
101
+ options.webPort = parsePort(requireOptionValue(args, ++index, arg), arg)
102
+ continue
103
+ }
104
+
105
+ throw new Error(`unknown option: ${arg}`)
106
+ }
107
+
108
+ return options
109
+ }
110
+
111
+ function requireOptionValue(args, index, flag) {
112
+ const value = args[index]
113
+ if (!value || value.startsWith('-')) {
114
+ throw new Error(`missing value for ${flag}`)
115
+ }
116
+ return value
117
+ }
118
+
119
+ function parsePort(value, flag) {
120
+ const parsed = Number(value)
121
+ if (!Number.isInteger(parsed) || parsed <= 0 || parsed > 65535) {
122
+ throw new Error(`invalid port for ${flag}: ${value}`)
123
+ }
124
+ return parsed
125
+ }
126
+
127
+ async function ensureBundleReady() {
128
+ const requiredPaths = [
129
+ bundleGatewayPath,
130
+ bundleWebDistDir,
131
+ resolveBinaryPath('agent-messenger-server'),
132
+ resolveBinaryPath('agent-messenger-cli'),
133
+ ]
134
+
135
+ for (const target of requiredPaths) {
136
+ if (!existsSync(target)) {
137
+ throw new Error(
138
+ `npm bundle is incomplete: missing ${target}. Run "npm run prepare:npm-bundle" before packing or publishing.`,
139
+ )
140
+ }
141
+ }
142
+
143
+ await access(resolveBinaryPath('agent-messenger-server'), constants.X_OK)
144
+ await access(resolveBinaryPath('agent-messenger-cli'), constants.X_OK)
145
+ }
146
+
147
+ function resolveBinaryPath(baseName) {
148
+ if (process.platform !== 'darwin') {
149
+ throw new Error(`unsupported platform: ${process.platform}. This package currently supports macOS only.`)
150
+ }
151
+
152
+ let archSuffix
153
+ if (process.arch === 'arm64') {
154
+ archSuffix = 'darwin-arm64'
155
+ } else if (process.arch === 'x64') {
156
+ archSuffix = 'darwin-amd64'
157
+ } else {
158
+ throw new Error(`unsupported architecture: ${process.arch}. Expected arm64 or x64 on macOS.`)
159
+ }
160
+
161
+ return join(bundleBinDir, `${baseName}-${archSuffix}`)
162
+ }
163
+
164
+ function runtimePaths(runtimeDir) {
165
+ return {
166
+ runtimeDir,
167
+ logDir: join(runtimeDir, 'logs'),
168
+ uploadDir: join(runtimeDir, 'uploads'),
169
+ serverLog: join(runtimeDir, 'logs', 'server.log'),
170
+ gatewayLog: join(runtimeDir, 'logs', 'gateway.log'),
171
+ serverPidfile: join(runtimeDir, 'server.pid'),
172
+ gatewayPidfile: join(runtimeDir, 'gateway.pid'),
173
+ sqlitePath: join(runtimeDir, 'agent_messenger.sqlite'),
174
+ }
175
+ }
176
+
177
+ async function startStack(options) {
178
+ const paths = runtimePaths(options.runtimeDir)
179
+ mkdirSync(paths.runtimeDir, { recursive: true })
180
+ mkdirSync(paths.logDir, { recursive: true })
181
+ mkdirSync(paths.uploadDir, { recursive: true })
182
+
183
+ await stopStack(options, { quiet: true })
184
+
185
+ writeFileSync(paths.serverLog, '')
186
+ writeFileSync(paths.gatewayLog, '')
187
+
188
+ const serverBinary = resolveBinaryPath('agent-messenger-server')
189
+ const gatewayChild = { pid: null }
190
+
191
+ try {
192
+ const serverChild = spawnDetached(serverBinary, [], {
193
+ ...process.env,
194
+ SERVER_ADDR: `${options.apiHost}:${options.apiPort}`,
195
+ DB_DRIVER: 'sqlite',
196
+ SQLITE_DSN: paths.sqlitePath,
197
+ UPLOAD_DIR: paths.uploadDir,
198
+ CORS_ALLOWED_ORIGINS: '*',
199
+ }, paths.serverLog)
200
+ writeFileSync(paths.serverPidfile, `${serverChild.pid}\n`)
201
+
202
+ await waitForHttp(`http://${options.apiHost}:${options.apiPort}/healthz`, 'API server')
203
+
204
+ gatewayChild.pid = spawnDetached(
205
+ process.execPath,
206
+ [bundleGatewayPath],
207
+ {
208
+ ...process.env,
209
+ AGENT_GATEWAY_HOST: options.webHost,
210
+ AGENT_GATEWAY_PORT: String(options.webPort),
211
+ AGENT_API_ORIGIN: `http://${options.apiHost}:${options.apiPort}`,
212
+ AGENT_WEB_DIST: bundleWebDistDir,
213
+ },
214
+ paths.gatewayLog,
215
+ ).pid
216
+ writeFileSync(paths.gatewayPidfile, `${gatewayChild.pid}\n`)
217
+
218
+ await waitForHttp(`http://${options.webHost}:${options.webPort}`, 'web gateway')
219
+ } catch (error) {
220
+ await stopStack(options, { quiet: true })
221
+ throw error
222
+ }
223
+
224
+ console.log('Agent Messenger is up.')
225
+ console.log(`API: http://${options.apiHost}:${options.apiPort}`)
226
+ console.log(`Web: http://${options.webHost}:${options.webPort}`)
227
+ console.log(`Logs: ${paths.serverLog} ${paths.gatewayLog}`)
228
+ }
229
+
230
+ async function stopStack(options, { quiet }) {
231
+ const paths = runtimePaths(options.runtimeDir)
232
+ const stoppedServer = await killFromPidfile(paths.serverPidfile)
233
+ const stoppedGateway = await killFromPidfile(paths.gatewayPidfile)
234
+
235
+ if (!quiet) {
236
+ if (stoppedServer || stoppedGateway) {
237
+ console.log('Agent Messenger is stopped.')
238
+ } else {
239
+ console.log('Agent Messenger is not running.')
240
+ }
241
+ }
242
+ }
243
+
244
+ async function printStatus(options) {
245
+ const paths = runtimePaths(options.runtimeDir)
246
+ const serverPid = readPidfile(paths.serverPidfile)
247
+ const gatewayPid = readPidfile(paths.gatewayPidfile)
248
+ const serverRunning = serverPid !== null && isPidAlive(serverPid)
249
+ const gatewayRunning = gatewayPid !== null && isPidAlive(gatewayPid)
250
+ const apiHealthy = serverRunning ? await isHttpReady(`http://${options.apiHost}:${options.apiPort}/healthz`) : false
251
+ const webHealthy = gatewayRunning ? await isHttpReady(`http://${options.webHost}:${options.webPort}`) : false
252
+
253
+ console.log(`API server: ${serverRunning ? 'running' : 'stopped'}${apiHealthy ? ' (healthy)' : ''}`)
254
+ console.log(`Web gateway: ${gatewayRunning ? 'running' : 'stopped'}${webHealthy ? ' (healthy)' : ''}`)
255
+ console.log(`Runtime dir: ${paths.runtimeDir}`)
256
+ console.log(`API URL: http://${options.apiHost}:${options.apiPort}`)
257
+ console.log(`Web URL: http://${options.webHost}:${options.webPort}`)
258
+ }
259
+
260
+ function delegateToBundledCli(args) {
261
+ const cliBinary = resolveBinaryPath('agent-messenger-cli')
262
+ const result = spawnSync(cliBinary, args, {
263
+ stdio: 'inherit',
264
+ env: process.env,
265
+ })
266
+
267
+ if (result.error) {
268
+ throw result.error
269
+ }
270
+
271
+ if (result.signal) {
272
+ process.kill(process.pid, result.signal)
273
+ return
274
+ }
275
+
276
+ process.exit(result.status ?? 1)
277
+ }
278
+
279
+ function spawnDetached(command, args, env, logFile) {
280
+ const stdoutFd = openSync(logFile, 'a')
281
+ const stderrFd = openSync(logFile, 'a')
282
+
283
+ try {
284
+ const child = spawn(command, args, {
285
+ detached: true,
286
+ stdio: ['ignore', stdoutFd, stderrFd],
287
+ env,
288
+ })
289
+ child.unref()
290
+
291
+ if (!Number.isInteger(child.pid) || child.pid <= 0) {
292
+ throw new Error(`failed to launch background process: ${command}`)
293
+ }
294
+ return { pid: child.pid }
295
+ } finally {
296
+ closeSync(stdoutFd)
297
+ closeSync(stderrFd)
298
+ }
299
+ }
300
+
301
+ function readPidfile(pidfile) {
302
+ if (!existsSync(pidfile)) {
303
+ return null
304
+ }
305
+
306
+ const raw = readFileSync(pidfile, 'utf8').trim()
307
+ const pid = Number(raw)
308
+ if (!Number.isInteger(pid) || pid <= 0) {
309
+ rmSync(pidfile, { force: true })
310
+ return null
311
+ }
312
+ return pid
313
+ }
314
+
315
+ function isPidAlive(pid) {
316
+ try {
317
+ process.kill(pid, 0)
318
+ return true
319
+ } catch (error) {
320
+ if (error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH') {
321
+ return false
322
+ }
323
+ return true
324
+ }
325
+ }
326
+
327
+ async function killFromPidfile(pidfile) {
328
+ const pid = readPidfile(pidfile)
329
+ if (pid === null) {
330
+ return false
331
+ }
332
+
333
+ if (isPidAlive(pid)) {
334
+ safeKill(pid, 'SIGTERM')
335
+ await sleep(PROCESS_STOP_DELAY_MS)
336
+ if (isPidAlive(pid)) {
337
+ safeKill(pid, 'SIGKILL')
338
+ }
339
+ }
340
+
341
+ rmSync(pidfile, { force: true })
342
+ return true
343
+ }
344
+
345
+ async function waitForHttp(url, label) {
346
+ for (let attempt = 0; attempt < STARTUP_ATTEMPTS; attempt += 1) {
347
+ if (await isHttpReady(url)) {
348
+ return
349
+ }
350
+ await sleep(STARTUP_DELAY_MS)
351
+ }
352
+ throw new Error(`${label} did not become ready: ${url}`)
353
+ }
354
+
355
+ async function isHttpReady(url) {
356
+ try {
357
+ const controller = new AbortController()
358
+ const timeout = setTimeout(() => controller.abort(), 1000)
359
+ const response = await fetch(url, { signal: controller.signal })
360
+ clearTimeout(timeout)
361
+ return response.ok
362
+ } catch {
363
+ return false
364
+ }
365
+ }
366
+
367
+ function sleep(ms) {
368
+ return new Promise((resolvePromise) => {
369
+ setTimeout(resolvePromise, ms)
370
+ })
371
+ }
372
+
373
+ function safeKill(pid, signal) {
374
+ try {
375
+ process.kill(pid, signal)
376
+ } catch (error) {
377
+ if (!(error && typeof error === 'object' && 'code' in error && error.code === 'ESRCH')) {
378
+ throw error
379
+ }
380
+ }
381
+ }
382
+
383
+ main().catch((error) => {
384
+ console.error(error instanceof Error ? error.message : String(error))
385
+ process.exit(1)
386
+ })