@aiolah/cli 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/LICENSE +21 -0
- package/README.md +125 -0
- package/dist/cli.js +749 -0
- package/package.json +43 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 ibnutron
|
|
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,125 @@
|
|
|
1
|
+
# aiolah
|
|
2
|
+
|
|
3
|
+
Terminal AI CLI (like opencode / Claude Code) with remote-control support: start a
|
|
4
|
+
session on one machine and drive it from another. The model can read/write files
|
|
5
|
+
and run shell commands in a scoped workspace.
|
|
6
|
+
|
|
7
|
+
## Setup
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npm install
|
|
11
|
+
cp .env.example .env # fill in ANTHROPIC_API_KEY
|
|
12
|
+
npm run build
|
|
13
|
+
npm link # puts the `aiolah` binary on PATH globally
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
The `.env` next to the package is loaded automatically on every `aiolah` invocation
|
|
17
|
+
(regardless of the current directory), so `npm run code` from another repo still
|
|
18
|
+
finds the key. Variables already exported in the environment take precedence.
|
|
19
|
+
|
|
20
|
+
## Commands
|
|
21
|
+
|
|
22
|
+
- `aiolah chat` — interactive chat with tool-use (file read/write/edit, `run_bash`)
|
|
23
|
+
scoped to `--workspace` (default: current directory).
|
|
24
|
+
- `aiolah serve` — host a chat session and print a token + WebSocket address that a
|
|
25
|
+
remote client can attach to. Tools always execute on the host, never on the
|
|
26
|
+
attach client.
|
|
27
|
+
- `aiolah attach ws://<host>:<port>` — join a running `serve` session from another
|
|
28
|
+
machine, using the token it printed. You'll be prompted for the token; auth
|
|
29
|
+
uses an HMAC challenge/response so the token itself never crosses the wire.
|
|
30
|
+
- `aiolah sessions list` — list saved sessions (id, last updated, workspace).
|
|
31
|
+
|
|
32
|
+
### Flags
|
|
33
|
+
|
|
34
|
+
- `-w, --workspace <dir>` — root directory tools are scoped to (default `.`).
|
|
35
|
+
File paths that resolve outside this directory are rejected.
|
|
36
|
+
- `--resume <id>` / `--continue` — resume a specific saved session, or the most
|
|
37
|
+
recently updated one. Full history (including tool calls) is persisted to
|
|
38
|
+
`~/.aiolah/sessions/<id>.json` after every turn.
|
|
39
|
+
- `--yolo` — skip the y/n confirmation prompt before `write_file`, `edit_file`,
|
|
40
|
+
or `run_bash`. Off by default: those three tools always ask first.
|
|
41
|
+
- `--cert <path> --key <path>` (serve only) — serve over `wss://` (TLS) using a
|
|
42
|
+
self-signed cert or one from Tailscale/Let's Encrypt, for confidentiality
|
|
43
|
+
when attaching over a real network instead of localhost.
|
|
44
|
+
|
|
45
|
+
## Wire protocol (for building other clients)
|
|
46
|
+
|
|
47
|
+
`serve` speaks newline-free JSON messages over a single WebSocket. Any client
|
|
48
|
+
(web page, mobile app, editor extension) can implement it:
|
|
49
|
+
|
|
50
|
+
1. Server → `{type:"challenge", nonce}`; client → `{type:"auth", hmac}` where
|
|
51
|
+
`hmac = hex(HMAC-SHA256(key = token, message = nonce))`. Wrong answer →
|
|
52
|
+
`{type:"error", text:"unauthorized"}` and the socket closes (3 attempts per
|
|
53
|
+
socket, 5 per IP per minute).
|
|
54
|
+
2. Server → `{type:"authed", sessionId, workspace, model, history}` where
|
|
55
|
+
`history` is the conversation so far, flattened for rendering:
|
|
56
|
+
`{role:"user"|"assistant", text}` and `{role:"tool", name, input, result}`.
|
|
57
|
+
3. Client → `{type:"user", text}` starts a turn. While it runs the server sends
|
|
58
|
+
`{type:"busy"}`, then per tool call `{type:"tool", name, input}` and
|
|
59
|
+
`{type:"tool_result", name, result}`, then `{type:"assistant", text}` and
|
|
60
|
+
`{type:"idle"}`. A `user` message sent while busy gets `error: "busy"`.
|
|
61
|
+
Other connected clients receive the same `user` message so they stay in sync.
|
|
62
|
+
4. Unless the host runs with `--yolo`, mutating tools (`write_file`,
|
|
63
|
+
`edit_file`, `run_bash`) pause with `{type:"confirm", id, description}` sent
|
|
64
|
+
to every client and printed on the host terminal. The first answer wins —
|
|
65
|
+
a client replies `{type:"confirm_reply", id, allow:true|false}`, or the host
|
|
66
|
+
operator types `y`/`n`. No answer within 5 minutes counts as denied.
|
|
67
|
+
|
|
68
|
+
The token never crosses the wire, but everything else does: use `wss://` (see
|
|
69
|
+
production notes below) whenever the connection leaves localhost. Browsers
|
|
70
|
+
also refuse `ws://` from an `https://` page.
|
|
71
|
+
|
|
72
|
+
## Remote-controlling another repo
|
|
73
|
+
|
|
74
|
+
Any repo can expose its own `aiolah serve`, rooted at that repo, via an npm
|
|
75
|
+
script, e.g.:
|
|
76
|
+
|
|
77
|
+
```json
|
|
78
|
+
{ "scripts": { "code": "aiolah serve --port 4318" } }
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Then `npm run code` in that repo, and `aiolah attach ws://<host>:4318` from
|
|
82
|
+
another machine to work on it remotely.
|
|
83
|
+
|
|
84
|
+
## Running `aiolah serve` in production (exposed beyond localhost)
|
|
85
|
+
|
|
86
|
+
`serve` is designed for one trusted operator driving one machine. Before
|
|
87
|
+
exposing it past `localhost`, treat the following as required, not optional:
|
|
88
|
+
|
|
89
|
+
1. **Always use TLS.** Auth is HMAC challenge/response (the token never crosses
|
|
90
|
+
the wire), but every prompt, tool call, file content, and command output
|
|
91
|
+
*does*. Run with `--cert/--key` (`wss://`), or terminate TLS in front of it
|
|
92
|
+
(nginx/Caddy/Cloudflare Tunnel proxying to `ws://127.0.0.1:<port>`). The
|
|
93
|
+
Tailscale + `tailscale cert` route is the least effort for a personal setup.
|
|
94
|
+
2. **Set a long, fixed `AIOLAH_REMOTE_TOKEN`** in the package `.env` (32+ random
|
|
95
|
+
bytes, e.g. `node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"`).
|
|
96
|
+
Without it a new random token is printed at every start, which is fine for
|
|
97
|
+
an interactive terminal but useless for a service.
|
|
98
|
+
3. **Bind narrowly / firewall the port.** `serve` listens on all interfaces.
|
|
99
|
+
Don't open the port on a public IP; put it behind a VPN (Tailscale/WireGuard),
|
|
100
|
+
an SSH tunnel (`ssh -L 4317:localhost:4317 host`), or a reverse proxy with
|
|
101
|
+
its own auth.
|
|
102
|
+
4. **Never run with `--yolo` on a machine you care about.** Without it, every
|
|
103
|
+
`write_file` / `edit_file` / `run_bash` waits for a y/N on the *host*
|
|
104
|
+
terminal — which means an unattended service (stdin closed) will hang on
|
|
105
|
+
the first mutating tool call. For an unattended host, either accept
|
|
106
|
+
`--yolo` inside a sandboxed workspace/VM, or keep a terminal attached
|
|
107
|
+
(tmux/screen) to approve calls.
|
|
108
|
+
5. **Scope the workspace** with `-w` to the single repo the session should
|
|
109
|
+
touch; `run_bash` still executes with the host user's full privileges inside
|
|
110
|
+
that cwd, so run the service as a low-privilege user.
|
|
111
|
+
6. **Keep it alive** with a supervisor, e.g. `pm2 start aiolah --name aiolah-ai5 -- serve --port 4318 -w /srv/ai5`
|
|
112
|
+
or a `systemd` unit with `Restart=on-failure`; stdin will be closed, so
|
|
113
|
+
pair this with point 4.
|
|
114
|
+
7. **Sessions are plaintext JSON** in `~/.aiolah/sessions/` (full conversation,
|
|
115
|
+
tool inputs, file contents). Protect that directory's permissions and
|
|
116
|
+
rotate/delete old sessions.
|
|
117
|
+
|
|
118
|
+
Not yet live-tested: `wss://` against a real certificate, and `attach` across
|
|
119
|
+
the public internet (only localhost so far).
|
|
120
|
+
|
|
121
|
+
## Status
|
|
122
|
+
|
|
123
|
+
Chat, tool-use, session persistence, and remote attach/serve work over a
|
|
124
|
+
single shared conversation (verified end-to-end with real API calls); no
|
|
125
|
+
multi-session concurrency within one process.
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,749 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { readFileSync as readFileSync4 } from "fs";
|
|
5
|
+
import { dirname as dirname2, join as join3 } from "path";
|
|
6
|
+
import { fileURLToPath } from "url";
|
|
7
|
+
import { parseEnv } from "util";
|
|
8
|
+
import { Command } from "commander";
|
|
9
|
+
|
|
10
|
+
// src/commands/chat.ts
|
|
11
|
+
import * as readline from "readline/promises";
|
|
12
|
+
import { stdin, stdout } from "process";
|
|
13
|
+
import { resolve as resolve2 } from "path";
|
|
14
|
+
|
|
15
|
+
// src/session.ts
|
|
16
|
+
import { EventEmitter } from "events";
|
|
17
|
+
import Anthropic from "@anthropic-ai/sdk";
|
|
18
|
+
|
|
19
|
+
// src/tools/fileTools.ts
|
|
20
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "fs";
|
|
21
|
+
import { dirname, isAbsolute, join, relative, resolve } from "path";
|
|
22
|
+
var WorkspaceViolationError = class extends Error {
|
|
23
|
+
};
|
|
24
|
+
function resolveInWorkspace(workspaceRoot, targetPath) {
|
|
25
|
+
const resolved = isAbsolute(targetPath) ? resolve(targetPath) : resolve(workspaceRoot, targetPath);
|
|
26
|
+
const rel = relative(workspaceRoot, resolved);
|
|
27
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
28
|
+
throw new WorkspaceViolationError(`Path "${targetPath}" is outside the workspace`);
|
|
29
|
+
}
|
|
30
|
+
return resolved;
|
|
31
|
+
}
|
|
32
|
+
function readFile(workspaceRoot, path) {
|
|
33
|
+
return readFileSync(resolveInWorkspace(workspaceRoot, path), "utf8");
|
|
34
|
+
}
|
|
35
|
+
function writeFile(workspaceRoot, path, content) {
|
|
36
|
+
const absolute = resolveInWorkspace(workspaceRoot, path);
|
|
37
|
+
mkdirSync(dirname(absolute), { recursive: true });
|
|
38
|
+
writeFileSync(absolute, content, "utf8");
|
|
39
|
+
}
|
|
40
|
+
function editFile(workspaceRoot, path, oldString, newString) {
|
|
41
|
+
const absolute = resolveInWorkspace(workspaceRoot, path);
|
|
42
|
+
const content = readFileSync(absolute, "utf8");
|
|
43
|
+
const occurrences = content.split(oldString).length - 1;
|
|
44
|
+
if (occurrences === 0) {
|
|
45
|
+
throw new Error(`old_string not found in ${path}`);
|
|
46
|
+
}
|
|
47
|
+
if (occurrences > 1) {
|
|
48
|
+
throw new Error(`old_string is not unique in ${path} (${occurrences} matches)`);
|
|
49
|
+
}
|
|
50
|
+
writeFileSync(absolute, content.replace(oldString, newString), "utf8");
|
|
51
|
+
}
|
|
52
|
+
function listDir(workspaceRoot, path) {
|
|
53
|
+
const absolute = resolveInWorkspace(workspaceRoot, path);
|
|
54
|
+
return readdirSync(absolute).map((entry) => {
|
|
55
|
+
const isDir = statSync(join(absolute, entry)).isDirectory();
|
|
56
|
+
return isDir ? `${entry}/` : entry;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// src/tools/bashTool.ts
|
|
61
|
+
import { exec } from "child_process";
|
|
62
|
+
var MAX_OUTPUT_CHARS = 2e4;
|
|
63
|
+
var TIMEOUT_MS = 12e4;
|
|
64
|
+
function truncate(text) {
|
|
65
|
+
return text.length > MAX_OUTPUT_CHARS ? `${text.slice(0, MAX_OUTPUT_CHARS)}
|
|
66
|
+
...[truncated]` : text;
|
|
67
|
+
}
|
|
68
|
+
function runBash(workspaceRoot, command) {
|
|
69
|
+
return new Promise((resolvePromise) => {
|
|
70
|
+
exec(
|
|
71
|
+
command,
|
|
72
|
+
{ cwd: workspaceRoot, timeout: TIMEOUT_MS, maxBuffer: 10 * 1024 * 1024 },
|
|
73
|
+
(error, stdout5, stderr) => {
|
|
74
|
+
resolvePromise({
|
|
75
|
+
stdout: truncate(stdout5),
|
|
76
|
+
stderr: truncate(stderr),
|
|
77
|
+
exitCode: error && typeof error.code === "number" ? error.code : error ? 1 : 0
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
);
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/tools/index.ts
|
|
85
|
+
var TOOL_SCHEMAS = [
|
|
86
|
+
{
|
|
87
|
+
name: "read_file",
|
|
88
|
+
description: "Read a text file from the workspace.",
|
|
89
|
+
input_schema: {
|
|
90
|
+
type: "object",
|
|
91
|
+
properties: { path: { type: "string", description: "Path relative to the workspace root" } },
|
|
92
|
+
required: ["path"]
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
{
|
|
96
|
+
name: "write_file",
|
|
97
|
+
description: "Create or overwrite a text file in the workspace. Requires user confirmation.",
|
|
98
|
+
input_schema: {
|
|
99
|
+
type: "object",
|
|
100
|
+
properties: {
|
|
101
|
+
path: { type: "string", description: "Path relative to the workspace root" },
|
|
102
|
+
content: { type: "string", description: "Full file content to write" }
|
|
103
|
+
},
|
|
104
|
+
required: ["path", "content"]
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
name: "edit_file",
|
|
109
|
+
description: "Replace a unique occurrence of old_string with new_string in an existing file. Requires user confirmation.",
|
|
110
|
+
input_schema: {
|
|
111
|
+
type: "object",
|
|
112
|
+
properties: {
|
|
113
|
+
path: { type: "string", description: "Path relative to the workspace root" },
|
|
114
|
+
old_string: { type: "string", description: "Exact text to find (must be unique in the file)" },
|
|
115
|
+
new_string: { type: "string", description: "Replacement text" }
|
|
116
|
+
},
|
|
117
|
+
required: ["path", "old_string", "new_string"]
|
|
118
|
+
}
|
|
119
|
+
},
|
|
120
|
+
{
|
|
121
|
+
name: "list_dir",
|
|
122
|
+
description: "List the contents of a directory in the workspace.",
|
|
123
|
+
input_schema: {
|
|
124
|
+
type: "object",
|
|
125
|
+
properties: { path: { type: "string", description: 'Path relative to the workspace root, "." for root' } },
|
|
126
|
+
required: ["path"]
|
|
127
|
+
}
|
|
128
|
+
},
|
|
129
|
+
{
|
|
130
|
+
name: "run_bash",
|
|
131
|
+
description: "Run a shell command in the workspace root. Requires user confirmation.",
|
|
132
|
+
input_schema: {
|
|
133
|
+
type: "object",
|
|
134
|
+
properties: { command: { type: "string", description: "Shell command to execute" } },
|
|
135
|
+
required: ["command"]
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
];
|
|
139
|
+
async function executeTool(name, input, context) {
|
|
140
|
+
const { workspaceRoot, confirm } = context;
|
|
141
|
+
switch (name) {
|
|
142
|
+
case "read_file":
|
|
143
|
+
return readFile(workspaceRoot, String(input.path));
|
|
144
|
+
case "list_dir":
|
|
145
|
+
return listDir(workspaceRoot, String(input.path)).join("\n");
|
|
146
|
+
case "write_file": {
|
|
147
|
+
const path = String(input.path);
|
|
148
|
+
if (!await confirm(`write_file: ${path}`)) {
|
|
149
|
+
return "User declined this action.";
|
|
150
|
+
}
|
|
151
|
+
writeFile(workspaceRoot, path, String(input.content));
|
|
152
|
+
return `Wrote ${path}`;
|
|
153
|
+
}
|
|
154
|
+
case "edit_file": {
|
|
155
|
+
const path = String(input.path);
|
|
156
|
+
if (!await confirm(`edit_file: ${path}`)) {
|
|
157
|
+
return "User declined this action.";
|
|
158
|
+
}
|
|
159
|
+
editFile(workspaceRoot, path, String(input.old_string), String(input.new_string));
|
|
160
|
+
return `Edited ${path}`;
|
|
161
|
+
}
|
|
162
|
+
case "run_bash": {
|
|
163
|
+
const command = String(input.command);
|
|
164
|
+
if (!await confirm(`run_bash: ${command}`)) {
|
|
165
|
+
return "User declined this action.";
|
|
166
|
+
}
|
|
167
|
+
const result = await runBash(workspaceRoot, command);
|
|
168
|
+
return `exit code: ${result.exitCode}
|
|
169
|
+
stdout:
|
|
170
|
+
${result.stdout}
|
|
171
|
+
stderr:
|
|
172
|
+
${result.stderr}`;
|
|
173
|
+
}
|
|
174
|
+
default:
|
|
175
|
+
throw new Error(`Unknown tool: ${name}`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/persistence.ts
|
|
180
|
+
import { mkdirSync as mkdirSync2, readFileSync as readFileSync2, readdirSync as readdirSync2, writeFileSync as writeFileSync2 } from "fs";
|
|
181
|
+
import { homedir } from "os";
|
|
182
|
+
import { join as join2 } from "path";
|
|
183
|
+
import { randomBytes } from "crypto";
|
|
184
|
+
var SESSIONS_DIR = join2(homedir(), ".aiolah", "sessions");
|
|
185
|
+
function ensureDir() {
|
|
186
|
+
mkdirSync2(SESSIONS_DIR, { recursive: true });
|
|
187
|
+
}
|
|
188
|
+
function generateSessionId() {
|
|
189
|
+
return `${Date.now().toString(36)}-${randomBytes(4).toString("hex")}`;
|
|
190
|
+
}
|
|
191
|
+
function pathFor(id) {
|
|
192
|
+
return join2(SESSIONS_DIR, `${id}.json`);
|
|
193
|
+
}
|
|
194
|
+
function saveSession(record) {
|
|
195
|
+
ensureDir();
|
|
196
|
+
writeFileSync2(pathFor(record.id), JSON.stringify(record, null, 2), "utf8");
|
|
197
|
+
}
|
|
198
|
+
function loadSession(id) {
|
|
199
|
+
return JSON.parse(readFileSync2(pathFor(id), "utf8"));
|
|
200
|
+
}
|
|
201
|
+
function listSessions() {
|
|
202
|
+
ensureDir();
|
|
203
|
+
return readdirSync2(SESSIONS_DIR).filter((file) => file.endsWith(".json")).map((file) => JSON.parse(readFileSync2(join2(SESSIONS_DIR, file), "utf8"))).sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
|
204
|
+
}
|
|
205
|
+
function findLatestSession() {
|
|
206
|
+
return listSessions()[0];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// src/session.ts
|
|
210
|
+
var ChatSession = class extends EventEmitter {
|
|
211
|
+
client;
|
|
212
|
+
model;
|
|
213
|
+
workspaceRoot;
|
|
214
|
+
confirm;
|
|
215
|
+
id;
|
|
216
|
+
createdAt;
|
|
217
|
+
history;
|
|
218
|
+
constructor(options) {
|
|
219
|
+
super();
|
|
220
|
+
const apiKey = options.apiKey ?? process.env.ANTHROPIC_API_KEY;
|
|
221
|
+
if (!apiKey) {
|
|
222
|
+
throw new Error("ANTHROPIC_API_KEY is not set. Copy .env.example to .env and fill it in.");
|
|
223
|
+
}
|
|
224
|
+
this.client = new Anthropic({ apiKey });
|
|
225
|
+
this.model = options.model;
|
|
226
|
+
this.workspaceRoot = options.workspaceRoot;
|
|
227
|
+
this.confirm = options.confirm;
|
|
228
|
+
if (options.resumeId) {
|
|
229
|
+
const record = loadSession(options.resumeId);
|
|
230
|
+
this.id = record.id;
|
|
231
|
+
this.createdAt = record.createdAt;
|
|
232
|
+
this.history = record.history;
|
|
233
|
+
} else {
|
|
234
|
+
this.id = generateSessionId();
|
|
235
|
+
this.createdAt = (/* @__PURE__ */ new Date()).toISOString();
|
|
236
|
+
this.history = [];
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
get sessionId() {
|
|
240
|
+
return this.id;
|
|
241
|
+
}
|
|
242
|
+
get modelId() {
|
|
243
|
+
return this.model;
|
|
244
|
+
}
|
|
245
|
+
get workspace() {
|
|
246
|
+
return this.workspaceRoot;
|
|
247
|
+
}
|
|
248
|
+
/** Conversation flattened to what a client renders (tool_use/tool_result pairs joined by id). */
|
|
249
|
+
renderHistory() {
|
|
250
|
+
const items = [];
|
|
251
|
+
const pendingTools = /* @__PURE__ */ new Map();
|
|
252
|
+
for (const message of this.history) {
|
|
253
|
+
if (typeof message.content === "string") {
|
|
254
|
+
items.push({ role: message.role, text: message.content });
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
for (const block of message.content) {
|
|
258
|
+
if (block.type === "text" && block.text.trim()) {
|
|
259
|
+
items.push({ role: message.role, text: block.text });
|
|
260
|
+
} else if (block.type === "tool_use") {
|
|
261
|
+
pendingTools.set(block.id, { name: block.name, input: block.input });
|
|
262
|
+
} else if (block.type === "tool_result") {
|
|
263
|
+
const call = pendingTools.get(block.tool_use_id);
|
|
264
|
+
const result = typeof block.content === "string" ? block.content : (block.content ?? []).map((part) => part.type === "text" ? part.text : "").join("");
|
|
265
|
+
items.push({ role: "tool", name: call?.name ?? "unknown", input: call?.input, result });
|
|
266
|
+
pendingTools.delete(block.tool_use_id);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return items;
|
|
271
|
+
}
|
|
272
|
+
async send(userMessage) {
|
|
273
|
+
this.history.push({ role: "user", content: userMessage });
|
|
274
|
+
while (true) {
|
|
275
|
+
const response = await this.client.messages.create({
|
|
276
|
+
model: this.model,
|
|
277
|
+
max_tokens: 4096,
|
|
278
|
+
tools: TOOL_SCHEMAS,
|
|
279
|
+
messages: this.history
|
|
280
|
+
});
|
|
281
|
+
this.history.push({ role: "assistant", content: response.content });
|
|
282
|
+
this.persist();
|
|
283
|
+
if (response.stop_reason !== "tool_use") {
|
|
284
|
+
const reply = response.content.filter((block) => block.type === "text").map((block) => block.text).join("\n");
|
|
285
|
+
return { reply };
|
|
286
|
+
}
|
|
287
|
+
const toolResults = [];
|
|
288
|
+
for (const block of response.content) {
|
|
289
|
+
if (block.type !== "tool_use") {
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
this.emit("tool", { name: block.name, input: block.input });
|
|
293
|
+
let content;
|
|
294
|
+
try {
|
|
295
|
+
content = await executeTool(block.name, block.input, {
|
|
296
|
+
workspaceRoot: this.workspaceRoot,
|
|
297
|
+
confirm: this.confirm
|
|
298
|
+
});
|
|
299
|
+
} catch (error) {
|
|
300
|
+
content = `Error: ${error instanceof Error ? error.message : String(error)}`;
|
|
301
|
+
}
|
|
302
|
+
this.emit("tool_result", { name: block.name, result: content });
|
|
303
|
+
toolResults.push({ type: "tool_result", tool_use_id: block.id, content });
|
|
304
|
+
}
|
|
305
|
+
this.history.push({ role: "user", content: toolResults });
|
|
306
|
+
this.persist();
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
persist() {
|
|
310
|
+
const record = {
|
|
311
|
+
id: this.id,
|
|
312
|
+
model: this.model,
|
|
313
|
+
workspace: this.workspaceRoot,
|
|
314
|
+
createdAt: this.createdAt,
|
|
315
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
316
|
+
history: this.history
|
|
317
|
+
};
|
|
318
|
+
saveSession(record);
|
|
319
|
+
}
|
|
320
|
+
};
|
|
321
|
+
|
|
322
|
+
// src/prompt.ts
|
|
323
|
+
async function ask(rl, prompt) {
|
|
324
|
+
try {
|
|
325
|
+
return await rl.question(prompt);
|
|
326
|
+
} catch (error) {
|
|
327
|
+
if (error.code === "ERR_USE_AFTER_CLOSE") {
|
|
328
|
+
return null;
|
|
329
|
+
}
|
|
330
|
+
throw error;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// src/commands/chat.ts
|
|
335
|
+
async function chatCommand(options) {
|
|
336
|
+
const rl = readline.createInterface({ input: stdin, output: stdout });
|
|
337
|
+
const workspaceRoot = resolve2(options.workspace);
|
|
338
|
+
const resumeId = options.resume ?? (options.continue ? findLatestSession()?.id : void 0);
|
|
339
|
+
const confirm = options.yolo ? async () => true : async (description) => {
|
|
340
|
+
const answer = await ask(rl, `Allow ${description}? [y/N] `);
|
|
341
|
+
return answer?.trim().toLowerCase() === "y";
|
|
342
|
+
};
|
|
343
|
+
const session = new ChatSession({ model: options.model, workspaceRoot, confirm, resumeId });
|
|
344
|
+
session.on("tool", ({ name, input }) => {
|
|
345
|
+
stdout.write(`
|
|
346
|
+
[tool] ${name} ${JSON.stringify(input)}
|
|
347
|
+
`);
|
|
348
|
+
});
|
|
349
|
+
stdout.write(
|
|
350
|
+
`aiolah chat \u2014 model ${options.model}, workspace ${workspaceRoot}, session ${session.sessionId}
|
|
351
|
+
Type "exit" to quit.
|
|
352
|
+
`
|
|
353
|
+
);
|
|
354
|
+
try {
|
|
355
|
+
while (true) {
|
|
356
|
+
const input = await ask(rl, "you> ");
|
|
357
|
+
if (input === null || input.trim().toLowerCase() === "exit") {
|
|
358
|
+
break;
|
|
359
|
+
}
|
|
360
|
+
if (!input.trim()) {
|
|
361
|
+
continue;
|
|
362
|
+
}
|
|
363
|
+
const { reply } = await session.send(input);
|
|
364
|
+
stdout.write(`
|
|
365
|
+
assistant> ${reply}
|
|
366
|
+
|
|
367
|
+
`);
|
|
368
|
+
}
|
|
369
|
+
} finally {
|
|
370
|
+
rl.close();
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// src/commands/serve.ts
|
|
375
|
+
import * as readline2 from "readline/promises";
|
|
376
|
+
import { stdin as stdin2, stdout as stdout2 } from "process";
|
|
377
|
+
import { randomBytes as randomBytes3 } from "crypto";
|
|
378
|
+
import { readFileSync as readFileSync3 } from "fs";
|
|
379
|
+
import { createServer as createHttpsServer } from "https";
|
|
380
|
+
import { resolve as resolve3 } from "path";
|
|
381
|
+
import { WebSocketServer } from "ws";
|
|
382
|
+
|
|
383
|
+
// src/auth.ts
|
|
384
|
+
import { createHmac, randomBytes as randomBytes2, timingSafeEqual } from "crypto";
|
|
385
|
+
function generateNonce() {
|
|
386
|
+
return randomBytes2(24).toString("hex");
|
|
387
|
+
}
|
|
388
|
+
function signChallenge(token, nonce) {
|
|
389
|
+
return createHmac("sha256", token).update(nonce).digest("hex");
|
|
390
|
+
}
|
|
391
|
+
function verifyChallenge(token, nonce, hmac) {
|
|
392
|
+
const expected = Buffer.from(signChallenge(token, nonce), "hex");
|
|
393
|
+
const actual = Buffer.from(hmac, "hex");
|
|
394
|
+
return expected.length === actual.length && timingSafeEqual(expected, actual);
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// src/commands/serve.ts
|
|
398
|
+
var MAX_AUTH_ATTEMPTS = 3;
|
|
399
|
+
var RATE_LIMIT_WINDOW_MS = 6e4;
|
|
400
|
+
var RATE_LIMIT_MAX_ATTEMPTS = 5;
|
|
401
|
+
var CONFIRM_TIMEOUT_MS = 5 * 6e4;
|
|
402
|
+
async function serveCommand(options) {
|
|
403
|
+
const token = process.env.AIOLAH_REMOTE_TOKEN ?? randomBytes3(16).toString("hex");
|
|
404
|
+
const port = Number(options.port);
|
|
405
|
+
const workspaceRoot = resolve3(options.workspace);
|
|
406
|
+
const resumeId = options.resume ?? (options.continue ? findLatestSession()?.id : void 0);
|
|
407
|
+
const clients = /* @__PURE__ */ new Set();
|
|
408
|
+
const attemptsByIp = /* @__PURE__ */ new Map();
|
|
409
|
+
const pendingConfirms = /* @__PURE__ */ new Map();
|
|
410
|
+
let busy = false;
|
|
411
|
+
const confirm = options.yolo ? async () => true : (description) => new Promise((resolveConfirm) => {
|
|
412
|
+
const id = randomBytes3(6).toString("hex");
|
|
413
|
+
const timer = setTimeout(() => settle(false), CONFIRM_TIMEOUT_MS);
|
|
414
|
+
const settle = (allow) => {
|
|
415
|
+
if (!pendingConfirms.has(id)) {
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
clearTimeout(timer);
|
|
419
|
+
pendingConfirms.delete(id);
|
|
420
|
+
resolveConfirm(allow);
|
|
421
|
+
};
|
|
422
|
+
pendingConfirms.set(id, settle);
|
|
423
|
+
broadcast({ type: "confirm", id, description });
|
|
424
|
+
stdout2.write(`
|
|
425
|
+
[confirm] Allow ${description}? Type y or n here, or answer from a client.
|
|
426
|
+
`);
|
|
427
|
+
});
|
|
428
|
+
const session = new ChatSession({ model: options.model, workspaceRoot, confirm, resumeId });
|
|
429
|
+
session.on("tool", ({ name, input }) => {
|
|
430
|
+
stdout2.write(`
|
|
431
|
+
[tool] ${name} ${JSON.stringify(input)}
|
|
432
|
+
`);
|
|
433
|
+
broadcast({ type: "tool", name, input });
|
|
434
|
+
});
|
|
435
|
+
session.on("tool_result", ({ name, result }) => {
|
|
436
|
+
broadcast({ type: "tool_result", name, result });
|
|
437
|
+
});
|
|
438
|
+
const wss = options.cert && options.key ? new WebSocketServer({
|
|
439
|
+
server: createHttpsServer({
|
|
440
|
+
cert: readFileSync3(options.cert),
|
|
441
|
+
key: readFileSync3(options.key)
|
|
442
|
+
}).listen(port)
|
|
443
|
+
}) : new WebSocketServer({ port });
|
|
444
|
+
wss.on("connection", (socket, request) => {
|
|
445
|
+
const ip = request.socket.remoteAddress ?? "unknown";
|
|
446
|
+
if (isRateLimited(ip)) {
|
|
447
|
+
send(socket, { type: "error", text: "rate limited" });
|
|
448
|
+
socket.close();
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
const nonce = generateNonce();
|
|
452
|
+
let attempts = 0;
|
|
453
|
+
let authed = false;
|
|
454
|
+
send(socket, { type: "challenge", nonce });
|
|
455
|
+
socket.on("message", (raw, isBinary) => {
|
|
456
|
+
void handleIncoming(isBinary ? raw.toString() : raw.toString("utf8"));
|
|
457
|
+
});
|
|
458
|
+
socket.on("close", () => clients.delete(socket));
|
|
459
|
+
async function handleIncoming(raw) {
|
|
460
|
+
let message;
|
|
461
|
+
try {
|
|
462
|
+
message = JSON.parse(raw);
|
|
463
|
+
} catch {
|
|
464
|
+
send(socket, { type: "error", text: "invalid message" });
|
|
465
|
+
return;
|
|
466
|
+
}
|
|
467
|
+
if (!authed) {
|
|
468
|
+
if (message.type !== "auth") {
|
|
469
|
+
send(socket, { type: "error", text: "expected auth" });
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
attempts += 1;
|
|
473
|
+
recordAttempt(ip);
|
|
474
|
+
if (attempts > MAX_AUTH_ATTEMPTS || !verifyChallenge(token, nonce, message.hmac)) {
|
|
475
|
+
send(socket, { type: "error", text: "unauthorized" });
|
|
476
|
+
socket.close();
|
|
477
|
+
return;
|
|
478
|
+
}
|
|
479
|
+
authed = true;
|
|
480
|
+
clients.add(socket);
|
|
481
|
+
send(socket, {
|
|
482
|
+
type: "authed",
|
|
483
|
+
sessionId: session.sessionId,
|
|
484
|
+
workspace: workspaceRoot,
|
|
485
|
+
model: session.modelId,
|
|
486
|
+
history: session.renderHistory()
|
|
487
|
+
});
|
|
488
|
+
if (busy) {
|
|
489
|
+
send(socket, { type: "busy" });
|
|
490
|
+
}
|
|
491
|
+
stdout2.write("\n[remote client connected]\n");
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (message.type === "confirm_reply") {
|
|
495
|
+
pendingConfirms.get(message.id)?.(message.allow);
|
|
496
|
+
return;
|
|
497
|
+
}
|
|
498
|
+
if (message.type === "user") {
|
|
499
|
+
if (busy) {
|
|
500
|
+
send(socket, { type: "error", text: "busy" });
|
|
501
|
+
return;
|
|
502
|
+
}
|
|
503
|
+
stdout2.write(`
|
|
504
|
+
remote> ${message.text}
|
|
505
|
+
`);
|
|
506
|
+
broadcast({ type: "user", text: message.text }, socket);
|
|
507
|
+
await runTurn(message.text);
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
async function runTurn(text) {
|
|
512
|
+
busy = true;
|
|
513
|
+
broadcast({ type: "busy" });
|
|
514
|
+
try {
|
|
515
|
+
const { reply } = await session.send(text);
|
|
516
|
+
broadcast({ type: "assistant", text: reply });
|
|
517
|
+
stdout2.write(`
|
|
518
|
+
assistant> ${reply}
|
|
519
|
+
|
|
520
|
+
`);
|
|
521
|
+
} catch (error) {
|
|
522
|
+
const text2 = error instanceof Error ? error.message : String(error);
|
|
523
|
+
broadcast({ type: "error", text: text2 });
|
|
524
|
+
stdout2.write(`
|
|
525
|
+
[error] ${text2}
|
|
526
|
+
|
|
527
|
+
`);
|
|
528
|
+
} finally {
|
|
529
|
+
busy = false;
|
|
530
|
+
broadcast({ type: "idle" });
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function broadcast(message, except) {
|
|
534
|
+
for (const client of clients) {
|
|
535
|
+
if (client !== except) {
|
|
536
|
+
send(client, message);
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
function isRateLimited(ip) {
|
|
541
|
+
const now = Date.now();
|
|
542
|
+
const attempts = (attemptsByIp.get(ip) ?? []).filter((t) => now - t < RATE_LIMIT_WINDOW_MS);
|
|
543
|
+
attemptsByIp.set(ip, attempts);
|
|
544
|
+
return attempts.length >= RATE_LIMIT_MAX_ATTEMPTS;
|
|
545
|
+
}
|
|
546
|
+
function recordAttempt(ip) {
|
|
547
|
+
const attempts = attemptsByIp.get(ip) ?? [];
|
|
548
|
+
attempts.push(Date.now());
|
|
549
|
+
attemptsByIp.set(ip, attempts);
|
|
550
|
+
}
|
|
551
|
+
const scheme = options.cert && options.key ? "wss" : "ws";
|
|
552
|
+
stdout2.write(
|
|
553
|
+
`aiolah serve \u2014 listening on ${scheme}://localhost:${port}, workspace ${workspaceRoot}, session ${session.sessionId}
|
|
554
|
+
Share this token with attach clients (never sent over the wire): ${token}
|
|
555
|
+
Type here to chat locally too. Ctrl+C to stop.
|
|
556
|
+
|
|
557
|
+
`
|
|
558
|
+
);
|
|
559
|
+
const hostRl = readline2.createInterface({ input: stdin2, output: stdout2 });
|
|
560
|
+
while (true) {
|
|
561
|
+
const input = await ask(hostRl, "you> ");
|
|
562
|
+
if (input === null) {
|
|
563
|
+
stdout2.write("[local stdin closed \u2014 serving remote clients only]\n");
|
|
564
|
+
return new Promise(() => {
|
|
565
|
+
});
|
|
566
|
+
}
|
|
567
|
+
const trimmed = input.trim();
|
|
568
|
+
if (!trimmed) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
const [pendingId] = pendingConfirms.keys();
|
|
572
|
+
if (pendingId !== void 0 && /^[yn]$/i.test(trimmed)) {
|
|
573
|
+
pendingConfirms.get(pendingId)?.(trimmed.toLowerCase() === "y");
|
|
574
|
+
continue;
|
|
575
|
+
}
|
|
576
|
+
if (busy) {
|
|
577
|
+
stdout2.write("[busy \u2014 wait for the current turn to finish]\n");
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
broadcast({ type: "user", text: input });
|
|
581
|
+
await runTurn(input);
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
function send(socket, message) {
|
|
585
|
+
socket.send(JSON.stringify(message));
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// src/commands/attach.ts
|
|
589
|
+
import * as readline3 from "readline/promises";
|
|
590
|
+
import { stdin as stdin3, stdout as stdout3 } from "process";
|
|
591
|
+
import WebSocket from "ws";
|
|
592
|
+
var RESULT_PREVIEW_CHARS = 300;
|
|
593
|
+
async function attachCommand(address) {
|
|
594
|
+
const rl = readline3.createInterface({ input: stdin3, output: stdout3 });
|
|
595
|
+
const token = process.env.AIOLAH_REMOTE_TOKEN ?? await rl.question("Remote token: ");
|
|
596
|
+
const socket = new WebSocket(address);
|
|
597
|
+
const pendingConfirms = [];
|
|
598
|
+
await new Promise((resolveOpen, reject) => {
|
|
599
|
+
socket.once("open", resolveOpen);
|
|
600
|
+
socket.once("error", reject);
|
|
601
|
+
});
|
|
602
|
+
const authed = new Promise((resolveAuthed, reject) => {
|
|
603
|
+
socket.on("message", (raw) => {
|
|
604
|
+
const message = JSON.parse(raw.toString());
|
|
605
|
+
switch (message.type) {
|
|
606
|
+
case "challenge":
|
|
607
|
+
send2(socket, { type: "auth", hmac: signChallenge(token, message.nonce) });
|
|
608
|
+
return;
|
|
609
|
+
case "authed":
|
|
610
|
+
stdout3.write(`session ${message.sessionId} \u2014 model ${message.model}, workspace ${message.workspace}
|
|
611
|
+
`);
|
|
612
|
+
for (const item of message.history) {
|
|
613
|
+
renderHistoryItem(item);
|
|
614
|
+
}
|
|
615
|
+
resolveAuthed();
|
|
616
|
+
return;
|
|
617
|
+
case "error":
|
|
618
|
+
if (message.text === "unauthorized" || message.text === "rate limited") {
|
|
619
|
+
reject(new Error(message.text));
|
|
620
|
+
} else {
|
|
621
|
+
stdout3.write(`
|
|
622
|
+
[error] ${message.text}
|
|
623
|
+
`);
|
|
624
|
+
}
|
|
625
|
+
return;
|
|
626
|
+
case "user":
|
|
627
|
+
stdout3.write(`
|
|
628
|
+
other> ${message.text}
|
|
629
|
+
`);
|
|
630
|
+
return;
|
|
631
|
+
case "assistant":
|
|
632
|
+
stdout3.write(`
|
|
633
|
+
assistant> ${message.text}
|
|
634
|
+
|
|
635
|
+
`);
|
|
636
|
+
return;
|
|
637
|
+
case "tool":
|
|
638
|
+
stdout3.write(`
|
|
639
|
+
[tool] ${message.name} ${JSON.stringify(message.input)}
|
|
640
|
+
`);
|
|
641
|
+
return;
|
|
642
|
+
case "tool_result":
|
|
643
|
+
stdout3.write(`[tool_result] ${message.name}: ${preview(message.result)}
|
|
644
|
+
`);
|
|
645
|
+
return;
|
|
646
|
+
case "confirm":
|
|
647
|
+
pendingConfirms.push(message.id);
|
|
648
|
+
stdout3.write(`
|
|
649
|
+
[confirm] Allow ${message.description}? Type y or n.
|
|
650
|
+
`);
|
|
651
|
+
return;
|
|
652
|
+
case "busy":
|
|
653
|
+
stdout3.write("[working\u2026]\n");
|
|
654
|
+
return;
|
|
655
|
+
case "idle":
|
|
656
|
+
case "auth":
|
|
657
|
+
case "confirm_reply":
|
|
658
|
+
return;
|
|
659
|
+
}
|
|
660
|
+
});
|
|
661
|
+
});
|
|
662
|
+
await authed;
|
|
663
|
+
stdout3.write(`aiolah attach \u2014 connected to ${address}. Type "exit" to quit.
|
|
664
|
+
|
|
665
|
+
`);
|
|
666
|
+
try {
|
|
667
|
+
while (true) {
|
|
668
|
+
const input = await ask(rl, "you> ");
|
|
669
|
+
if (input === null || input.trim().toLowerCase() === "exit") {
|
|
670
|
+
break;
|
|
671
|
+
}
|
|
672
|
+
const trimmed = input.trim();
|
|
673
|
+
if (!trimmed) {
|
|
674
|
+
continue;
|
|
675
|
+
}
|
|
676
|
+
if (pendingConfirms.length > 0 && /^[yn]$/i.test(trimmed)) {
|
|
677
|
+
const id = pendingConfirms.shift();
|
|
678
|
+
send2(socket, { type: "confirm_reply", id, allow: trimmed.toLowerCase() === "y" });
|
|
679
|
+
continue;
|
|
680
|
+
}
|
|
681
|
+
send2(socket, { type: "user", text: input });
|
|
682
|
+
}
|
|
683
|
+
} finally {
|
|
684
|
+
rl.close();
|
|
685
|
+
socket.close();
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
function renderHistoryItem(item) {
|
|
689
|
+
if (item.role === "user") {
|
|
690
|
+
stdout3.write(`you> ${item.text}
|
|
691
|
+
`);
|
|
692
|
+
} else if (item.role === "assistant") {
|
|
693
|
+
stdout3.write(`assistant> ${item.text}
|
|
694
|
+
|
|
695
|
+
`);
|
|
696
|
+
} else {
|
|
697
|
+
stdout3.write(`[tool] ${item.name} ${JSON.stringify(item.input)} \u2192 ${preview(item.result)}
|
|
698
|
+
`);
|
|
699
|
+
}
|
|
700
|
+
}
|
|
701
|
+
function preview(text) {
|
|
702
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
703
|
+
return flat.length > RESULT_PREVIEW_CHARS ? `${flat.slice(0, RESULT_PREVIEW_CHARS)}\u2026` : flat;
|
|
704
|
+
}
|
|
705
|
+
function send2(socket, message) {
|
|
706
|
+
socket.send(JSON.stringify(message));
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// src/commands/sessions.ts
|
|
710
|
+
import { stdout as stdout4 } from "process";
|
|
711
|
+
function sessionsListCommand() {
|
|
712
|
+
const sessions2 = listSessions();
|
|
713
|
+
if (sessions2.length === 0) {
|
|
714
|
+
stdout4.write("No saved sessions.\n");
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
for (const session of sessions2) {
|
|
718
|
+
const firstUserMessage = session.history.find((message) => message.role === "user");
|
|
719
|
+
const snippet = typeof firstUserMessage?.content === "string" ? firstUserMessage.content.slice(0, 60) : "(tool result)";
|
|
720
|
+
stdout4.write(
|
|
721
|
+
`${session.id} ${session.updatedAt} ${session.workspace}
|
|
722
|
+
${snippet}
|
|
723
|
+
`
|
|
724
|
+
);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
// src/cli.ts
|
|
729
|
+
loadPackageEnv();
|
|
730
|
+
var program = new Command();
|
|
731
|
+
program.name("aiolah").description("Terminal AI CLI with remote control support").version("0.1.0");
|
|
732
|
+
program.command("chat").description("Start an interactive chat session in this terminal").option("-m, --model <model>", "Anthropic model id", "claude-sonnet-5").option("-w, --workspace <dir>", "workspace root for file/bash tools", ".").option("--resume <id>", "resume a saved session by id").option("--continue", "resume the most recently updated session").option("--yolo", "skip confirmation prompts for write_file/edit_file/run_bash").action(chatCommand);
|
|
733
|
+
program.command("serve").description('Host the current chat session so a remote "aiolah attach" client can join').option("-p, --port <port>", "port to listen on", "4317").option("-m, --model <model>", "Anthropic model id", "claude-sonnet-5").option("-w, --workspace <dir>", "workspace root for file/bash tools", ".").option("--resume <id>", "resume a saved session by id").option("--continue", "resume the most recently updated session").option("--yolo", "skip confirmation prompts for write_file/edit_file/run_bash").option("--cert <path>", "TLS certificate path (enables wss)").option("--key <path>", "TLS private key path (enables wss)").action(serveCommand);
|
|
734
|
+
program.command("attach <address>").description('Attach to a running "aiolah serve" session, e.g. aiolah attach ws://host:4317').action(attachCommand);
|
|
735
|
+
var sessions = program.command("sessions").description("Manage saved chat sessions");
|
|
736
|
+
sessions.command("list").description("List saved sessions").action(sessionsListCommand);
|
|
737
|
+
program.parse();
|
|
738
|
+
function loadPackageEnv() {
|
|
739
|
+
const envPath = join3(dirname2(fileURLToPath(import.meta.url)), "..", ".env");
|
|
740
|
+
let contents;
|
|
741
|
+
try {
|
|
742
|
+
contents = readFileSync4(envPath, "utf8");
|
|
743
|
+
} catch {
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
for (const [key, value] of Object.entries(parseEnv(contents))) {
|
|
747
|
+
process.env[key] ??= value;
|
|
748
|
+
}
|
|
749
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@aiolah/cli",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Terminal AI CLI with remote control support",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"aiolah": "dist/cli.js"
|
|
8
|
+
},
|
|
9
|
+
"files": [
|
|
10
|
+
"dist"
|
|
11
|
+
],
|
|
12
|
+
"repository": {
|
|
13
|
+
"type": "git",
|
|
14
|
+
"url": "git+https://github.com/ibnutron/aiocli.git"
|
|
15
|
+
},
|
|
16
|
+
"engines": {
|
|
17
|
+
"node": ">=22"
|
|
18
|
+
},
|
|
19
|
+
"scripts": {
|
|
20
|
+
"build": "tsup src/cli.ts --format esm --target node22 --clean",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"dev": "tsx src/cli.ts",
|
|
23
|
+
"lint": "eslint .",
|
|
24
|
+
"format": "prettier --write .",
|
|
25
|
+
"typecheck": "tsc --noEmit"
|
|
26
|
+
},
|
|
27
|
+
"license": "MIT",
|
|
28
|
+
"dependencies": {
|
|
29
|
+
"@anthropic-ai/sdk": "^0.35.0",
|
|
30
|
+
"commander": "^13.0.0",
|
|
31
|
+
"ws": "^8.18.0"
|
|
32
|
+
},
|
|
33
|
+
"devDependencies": {
|
|
34
|
+
"@types/node": "^22.10.0",
|
|
35
|
+
"@types/ws": "^8.5.13",
|
|
36
|
+
"eslint": "^9.16.0",
|
|
37
|
+
"prettier": "^3.4.0",
|
|
38
|
+
"tsup": "^8.3.5",
|
|
39
|
+
"tsx": "^4.19.2",
|
|
40
|
+
"typescript": "^5.7.2",
|
|
41
|
+
"typescript-eslint": "^8.18.0"
|
|
42
|
+
}
|
|
43
|
+
}
|