@aexol/spectral 0.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +106 -0
- package/LICENSE +21 -0
- package/README.md +213 -0
- package/dist/cli.js +206 -0
- package/dist/commands/bind.js +96 -0
- package/dist/commands/login.js +109 -0
- package/dist/commands/logout.js +24 -0
- package/dist/commands/serve.js +374 -0
- package/dist/commands/unbind.js +36 -0
- package/dist/config.js +92 -0
- package/dist/extensions/aexol-mcp.js +117 -0
- package/dist/mcp-client.js +116 -0
- package/dist/preflight.js +36 -0
- package/dist/relay/client.js +240 -0
- package/dist/relay/dispatcher.js +504 -0
- package/dist/relay/machine-store.js +116 -0
- package/dist/relay/models-fetch.js +108 -0
- package/dist/relay/registration.js +135 -0
- package/dist/server/handlers/errors.js +34 -0
- package/dist/server/handlers/projects.js +86 -0
- package/dist/server/handlers/sessions.js +42 -0
- package/dist/server/paths.js +78 -0
- package/dist/server/pi-bridge.js +572 -0
- package/dist/server/session-stream.js +579 -0
- package/dist/server/shutdown.js +180 -0
- package/dist/server/storage.js +491 -0
- package/dist/server/title-generator.js +196 -0
- package/dist/server/wire.js +12 -0
- package/dist/studio-binding.js +97 -0
- package/package.json +67 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to `@aexol/spectral` are documented here.
|
|
4
|
+
Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
|
+
|
|
6
|
+
## [Unreleased]
|
|
7
|
+
|
|
8
|
+
### Added
|
|
9
|
+
- Admin OpenRouter catalog picker in `/studio/admin/models` (Base Models tab):
|
|
10
|
+
search across 368 models from `https://openrouter.ai/api/v1/models` and add
|
|
11
|
+
to the whitelist with one click. Picker marks models already present in the
|
|
12
|
+
whitelist (disabled add) regardless of their enabled state.
|
|
13
|
+
- `Query.adminOpenRouterCatalog: [OpenRouterModel!]!` — admin-gated, with a
|
|
14
|
+
1h in-memory TTL cache of the upstream catalog.
|
|
15
|
+
- `Mutation.adminCreateBaseModel(input: BaseModelCreateInput!): BaseModel!`
|
|
16
|
+
with duplicate guard on `(provider, modelId)`. Defaults `enabled=true`.
|
|
17
|
+
- Verbatim `modelId` routing for `provider="openrouter"` in the backend
|
|
18
|
+
inference proxy: no `${provider}/${modelId}` rebuild — picker-sourced rows
|
|
19
|
+
carry the canonical OR ID and are sent through unchanged. Legacy rebuild
|
|
20
|
+
fallback is preserved for older naive-provider rows
|
|
21
|
+
(`provider="deepseek"`, `"google"`, etc.).
|
|
22
|
+
- Per-session model selection: choose AI model from a whitelist managed by
|
|
23
|
+
admins. Selection persists per session in localStorage and is sent in the
|
|
24
|
+
`prompt` envelope to apply via pi `setModel()`.
|
|
25
|
+
- New SQLite column `sessions.model_id` for cross-restart recovery of
|
|
26
|
+
selected model.
|
|
27
|
+
- Synthetic pi providers `spectral-proxy-anthropic` and
|
|
28
|
+
`spectral-proxy-openai`, registered at `PiBridge` start, that point pi's
|
|
29
|
+
`ModelRegistry` at the backend's `/v1` proxy. `AuthStorage.inMemory()` and
|
|
30
|
+
`ModelRegistry.inMemory()` skip on-disk pi credentials in `serve` mode.
|
|
31
|
+
- Backend `/v1/messages` and `/v1/chat/completions` machine-JWT auth branch
|
|
32
|
+
with raw `modelId` resolution against the `BaseModel` whitelist.
|
|
33
|
+
- TTL-cached `fetchAllowedModels` GraphQL query used by `spectral serve` to
|
|
34
|
+
discover the team's allowed models from the backend at startup.
|
|
35
|
+
- Startup info log on `spectral serve`:
|
|
36
|
+
`✓ Inference routed via backend proxy (N model(s) available)`.
|
|
37
|
+
|
|
38
|
+
### Changed
|
|
39
|
+
- Soft-remove a model from the whitelist by reusing the existing per-row
|
|
40
|
+
`Switch` toggle (no separate Remove button). Disabled rows remain visible
|
|
41
|
+
in the admin table and can be re-enabled with the same Switch.
|
|
42
|
+
- Available models are now read from a backend-managed `BaseModel` table
|
|
43
|
+
(synced from https://models.dev/api.json by admins) instead of a
|
|
44
|
+
hardcoded frontend whitelist.
|
|
45
|
+
- `spectral serve` inference now routes through the backend proxy
|
|
46
|
+
(centralized API keys) instead of reading `~/.pi/agent/auth.json`. CLI
|
|
47
|
+
machines no longer need provider API keys locally; the backend manages
|
|
48
|
+
them. The per-machine machine JWT carries auth, and the per-team
|
|
49
|
+
`BaseModel` whitelist gates which models can be used.
|
|
50
|
+
|
|
51
|
+
### Fixed
|
|
52
|
+
- Admin "Add from OpenRouter" picker GraphQL syntax error
|
|
53
|
+
(`Expected Name, found String "modalities"`). Mutations
|
|
54
|
+
`adminCreateBaseModel` and `adminUpdateBaseModel` now pass their input
|
|
55
|
+
objects via Zeus `$()` variables instead of inline serialization,
|
|
56
|
+
so nested array fields (`modalities`, `supportedParameters`) parse
|
|
57
|
+
correctly server-side.
|
|
58
|
+
|
|
59
|
+
### Removed
|
|
60
|
+
- Hardcoded `landing/config/model-whitelist.ts` allowlist (replaced by
|
|
61
|
+
the DB-backed whitelist).
|
|
62
|
+
|
|
63
|
+
### Migration
|
|
64
|
+
- `spectral` (CLI / TUI subprocess mode): no change — still uses local
|
|
65
|
+
`~/.pi/agent/auth.json`.
|
|
66
|
+
- `spectral serve`: ensure the backend has the relevant provider keys
|
|
67
|
+
configured (`ANTHROPIC_API_KEY`, `OPENROUTER_API_KEY`, etc.). Local
|
|
68
|
+
`~/.pi/agent/auth.json` is ignored when running `serve`.
|
|
69
|
+
|
|
70
|
+
## [0.1.0] — 2026-04-29
|
|
71
|
+
|
|
72
|
+
Initial release.
|
|
73
|
+
|
|
74
|
+
### Added
|
|
75
|
+
- `spectral login` — interactive authentication. Verifies the team API key
|
|
76
|
+
against the Aexol MCP backend before persisting credentials to
|
|
77
|
+
`~/.spectral/config.json` (mode `0600`).
|
|
78
|
+
- `spectral logout` — removes stored credentials. Idempotent.
|
|
79
|
+
- `spectral serve` — always-on agent that connects this machine to the
|
|
80
|
+
Aexol relay over a single long-lived WebSocket.
|
|
81
|
+
- Registers a machine identity with your team on first run; reuses the
|
|
82
|
+
issued JWT on subsequent runs.
|
|
83
|
+
- Reconnects automatically with exponential backoff.
|
|
84
|
+
- Graceful shutdown on `SIGINT` / `SIGTERM`: drains in-flight responses
|
|
85
|
+
and closes the relay cleanly before exiting.
|
|
86
|
+
- `--machine-name <name>` overrides the default `os.hostname()`.
|
|
87
|
+
- Browser-driven sessions through the Aexol web UI:
|
|
88
|
+
- Machine picker for switching between paired devices.
|
|
89
|
+
- Multi-tab sync of project and session lifecycle changes (create,
|
|
90
|
+
rename, delete) via a per-machine meta channel.
|
|
91
|
+
- Stuck-turn watchdog re-enables the composer after 60s of silence.
|
|
92
|
+
- Local-first storage: projects, sessions, and messages live in a SQLite
|
|
93
|
+
database at `~/.spectral/sessions.db`. They never leave the machine.
|
|
94
|
+
- Bundled Aexol MCP extension auto-loaded for the local TUI path so
|
|
95
|
+
`spectral` (no subcommand) acts as a fully-configured pi session.
|
|
96
|
+
- Plain pi pass-through: any flag that isn't a Spectral subcommand is
|
|
97
|
+
forwarded verbatim to `pi`.
|
|
98
|
+
|
|
99
|
+
### Notes
|
|
100
|
+
- Backend storage is identity + machine metadata only. Message content,
|
|
101
|
+
code, and model API keys never leave your machine.
|
|
102
|
+
- One SQLite database per machine — switching machines in the browser
|
|
103
|
+
shows a different project list, by design.
|
|
104
|
+
- Pi auth tokens (Anthropic, OpenAI, Cerebras, Google, custom endpoints)
|
|
105
|
+
are managed by pi itself in `~/.pi/agent/auth.json` and are not read or
|
|
106
|
+
transmitted by Spectral.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Aexol
|
|
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,213 @@
|
|
|
1
|
+
# @aexol/spectral
|
|
2
|
+
|
|
3
|
+
> Coding agent that never sleeps.
|
|
4
|
+
|
|
5
|
+
## What is Spectral?
|
|
6
|
+
|
|
7
|
+
Spectral is the always-on coding agent for [Aexol](https://aexol.com). You install
|
|
8
|
+
it on every machine you want to code on, run `spectral serve`, and your devices
|
|
9
|
+
appear in the Aexol web UI as live agents you can drive from any browser tab — no
|
|
10
|
+
port forwarding, no SSH, no exposing localhost.
|
|
11
|
+
|
|
12
|
+
Under the hood, Spectral is a thin branded wrapper around
|
|
13
|
+
[pi](https://www.npmjs.com/package/@mariozechner/pi-coding-agent) by Mario
|
|
14
|
+
Zechner, plus a relay client that connects each machine to Aexol's backend.
|
|
15
|
+
Browsers talk to your machines through that relay; the backend never sees your
|
|
16
|
+
code or your messages — those stay on the device. Model API keys are handled
|
|
17
|
+
differently depending on which command you run (see
|
|
18
|
+
[How inference is routed](#how-inference-is-routed)).
|
|
19
|
+
|
|
20
|
+
## Install
|
|
21
|
+
|
|
22
|
+
```bash
|
|
23
|
+
npm install -g @aexol/spectral
|
|
24
|
+
```
|
|
25
|
+
|
|
26
|
+
Requires Node.js **20 or newer**.
|
|
27
|
+
|
|
28
|
+
## Quickstart
|
|
29
|
+
|
|
30
|
+
1. **Authenticate**
|
|
31
|
+
|
|
32
|
+
```bash
|
|
33
|
+
spectral login
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
You'll be prompted for your Aexol MCP URL (defaults to `https://api.aexol.ai/mcp`)
|
|
37
|
+
and a team API key (`sk-aexol-team-…`). Credentials are written to
|
|
38
|
+
`~/.spectral/config.json` with mode `0600`.
|
|
39
|
+
|
|
40
|
+
2. **Start the agent**
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
spectral serve
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
This registers the machine with your team, opens a long-lived WebSocket to
|
|
47
|
+
the Aexol relay, and stays up. Reconnects are automatic with exponential
|
|
48
|
+
backoff. Leave it running — that's the point.
|
|
49
|
+
|
|
50
|
+
3. **Open the browser**
|
|
51
|
+
|
|
52
|
+
Visit the Aexol web UI. Your machine appears in the picker; pick it and you
|
|
53
|
+
can create projects, open sessions, and chat with the agent on that machine
|
|
54
|
+
from any tab.
|
|
55
|
+
|
|
56
|
+
You can also run Spectral as a plain local TUI without the relay — just invoke
|
|
57
|
+
`spectral` (no subcommand) and it acts as a normal pi terminal session with the
|
|
58
|
+
Aexol MCP extension auto-loaded.
|
|
59
|
+
|
|
60
|
+
## Commands
|
|
61
|
+
|
|
62
|
+
| Command | Description |
|
|
63
|
+
|--------------------|-------------------------------------------------------------------------------|
|
|
64
|
+
| `spectral` | Local TUI. Forwards all flags to pi; loads the bundled Aexol MCP extension. |
|
|
65
|
+
| `spectral login` | Interactive auth. Verifies the key against the MCP backend and stores it. |
|
|
66
|
+
| `spectral logout` | Removes `~/.spectral/config.json`. Idempotent. |
|
|
67
|
+
| `spectral serve` | Connect this machine to the Aexol relay. Stays up; survives reconnects. |
|
|
68
|
+
| `spectral --version` | Print version. |
|
|
69
|
+
| `spectral --help` | Print Spectral header, then pi's full help. |
|
|
70
|
+
|
|
71
|
+
### `spectral serve` flags
|
|
72
|
+
|
|
73
|
+
| Flag | Description |
|
|
74
|
+
|----------------------------|-------------------------------------------------------------------|
|
|
75
|
+
| `--machine-name <name>` | Override the display name (default: `os.hostname()`). |
|
|
76
|
+
|
|
77
|
+
Anything that isn't a Spectral subcommand is forwarded verbatim to pi, so any
|
|
78
|
+
pi flag you know works. Example: `spectral -p "summarize this repo"`.
|
|
79
|
+
|
|
80
|
+
## How it works
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
┌────────────────┐ ┌─────────────────┐ ┌────────────────┐
|
|
84
|
+
│ Browser tab │────────▶│ Aexol backend │◀────────│ spectral serve │
|
|
85
|
+
│ (Aexol web UI) │ WSS │ (relay) │ WSS │ (your machine) │
|
|
86
|
+
└────────────────┘ └─────────────────┘ └────────────────┘
|
|
87
|
+
│
|
|
88
|
+
identity + routing only
|
|
89
|
+
(no message content, no code)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
- Your machine runs `spectral serve` and registers with the relay using a
|
|
93
|
+
machine JWT issued at first run.
|
|
94
|
+
- Browser sessions for that machine open a WebSocket to the backend. The
|
|
95
|
+
backend forwards every frame to your machine and back — it never reads or
|
|
96
|
+
stores message content.
|
|
97
|
+
- All your local state — projects, sessions, messages, pi auth tokens —
|
|
98
|
+
lives on the device. The backend only knows machine identity (id, display
|
|
99
|
+
name, hostname, last-seen) and team membership.
|
|
100
|
+
|
|
101
|
+
## How inference is routed
|
|
102
|
+
|
|
103
|
+
Spectral has two distinct execution paths, and they handle model API keys
|
|
104
|
+
differently. This is intentional — pick the one that matches your security
|
|
105
|
+
model.
|
|
106
|
+
|
|
107
|
+
```
|
|
108
|
+
spectral (CLI / TUI mode) spectral serve (relay mode)
|
|
109
|
+
───────────────────────── ─────────────────────────────
|
|
110
|
+
pi reads ~/.pi/agent/auth.json → pi runs in-process via PiBridge
|
|
111
|
+
↓ ↓
|
|
112
|
+
local Anthropic / OpenAI keys ALL inference → backend `/v1` proxy
|
|
113
|
+
↓ ↓
|
|
114
|
+
direct call to provider backend uses centralized API keys
|
|
115
|
+
↓
|
|
116
|
+
scoped to team's BaseModel whitelist
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
- **`spectral` (CLI subprocess mode)** — pi runs as a normal subprocess and
|
|
120
|
+
uses whatever provider keys you've stored locally in `~/.pi/agent/auth.json`
|
|
121
|
+
(Anthropic, OpenAI, Cerebras, Google, custom OpenAI-compatible endpoints).
|
|
122
|
+
Spectral never reads or transmits these. This is the classic local-only
|
|
123
|
+
flow.
|
|
124
|
+
- **`spectral serve` (relay mode)** — pi runs in-process inside the serve
|
|
125
|
+
daemon. All inference traffic is proxied through the Aexol backend's
|
|
126
|
+
`/v1/messages` and `/v1/chat/completions` endpoints, authenticated with the
|
|
127
|
+
per-machine machine JWT. The backend holds the upstream provider keys and
|
|
128
|
+
enforces a per-team `BaseModel` whitelist server-side. The local
|
|
129
|
+
`~/.pi/agent/auth.json` is **not read** in this mode (`AuthStorage.inMemory()`).
|
|
130
|
+
|
|
131
|
+
Why two paths? `spectral serve` is designed for shared / managed machines
|
|
132
|
+
where the team controls which models are usable and operators don't want
|
|
133
|
+
provider keys sitting on every box. `spectral` (no subcommand) is the
|
|
134
|
+
unmanaged TUI path and behaves like a vanilla pi install.
|
|
135
|
+
|
|
136
|
+
## Configuration
|
|
137
|
+
|
|
138
|
+
| Path / variable | Purpose |
|
|
139
|
+
|-------------------------------------------|--------------------------------------------------------|
|
|
140
|
+
| `~/.spectral/config.json` | Aexol MCP URL + team API key. Created by `spectral login`. Mode `0600`. |
|
|
141
|
+
| `~/.spectral/machine.json` | Machine identity + relay JWT. Created on first `spectral serve`. |
|
|
142
|
+
| `~/.spectral/sessions.db` | Local SQLite for projects, sessions, messages. |
|
|
143
|
+
| `SPECTRAL_CONFIG_DIR` | Override the directory above. |
|
|
144
|
+
| `SPECTRAL_MCP_URL` | Override the MCP URL at login time. |
|
|
145
|
+
| `SPECTRAL_BACKEND_URL` | Override the backend HTTP base for `spectral serve`. |
|
|
146
|
+
| `SPECTRAL_RELAY_URL` | Override the derived relay WebSocket URL. |
|
|
147
|
+
|
|
148
|
+
Pi's own auth state for the local TUI path (Anthropic, OpenAI, etc.) lives
|
|
149
|
+
in `~/.pi/agent/auth.json` on the same machine. Spectral never reads it and
|
|
150
|
+
never sends it anywhere. Note that `spectral serve` does **not** use this
|
|
151
|
+
file — it routes inference through the backend proxy instead (see
|
|
152
|
+
[How inference is routed](#how-inference-is-routed)).
|
|
153
|
+
|
|
154
|
+
## Multiple machines
|
|
155
|
+
|
|
156
|
+
You can run `spectral serve` on as many machines as you like under one team —
|
|
157
|
+
each gets its own machine identity and its own SQLite. The browser picker
|
|
158
|
+
lists all of them; switching machines shows that machine's project list and
|
|
159
|
+
session history. Switching is a hard context change: the previous selection
|
|
160
|
+
is cleared so you don't accidentally talk to the wrong device.
|
|
161
|
+
|
|
162
|
+
## Troubleshooting
|
|
163
|
+
|
|
164
|
+
- **My machine isn't showing up in the browser picker.**
|
|
165
|
+
Make sure `spectral serve` is still running (it logs reconnect attempts to
|
|
166
|
+
stderr). If `spectral login` was run a long time ago and the team key was
|
|
167
|
+
rotated, re-run `spectral login`.
|
|
168
|
+
|
|
169
|
+
- **WebSocket keeps disconnecting.**
|
|
170
|
+
The relay client reconnects automatically with exponential backoff. Brief
|
|
171
|
+
network blips are expected and handled. If the backoff loop is constant,
|
|
172
|
+
check that your team API key is still valid and that your network allows
|
|
173
|
+
outbound WebSocket connections to the configured backend.
|
|
174
|
+
|
|
175
|
+
- **`better-sqlite3` errors on first `spectral serve`.**
|
|
176
|
+
This usually means the native module didn't compile during install. Try
|
|
177
|
+
`cd ~/.spectral && npm rebuild better-sqlite3`, or reinstall Spectral after
|
|
178
|
+
ensuring you have a working C/C++ toolchain (`make`, a C compiler, Python).
|
|
179
|
+
|
|
180
|
+
- **I want to revoke a machine.**
|
|
181
|
+
Stop `spectral serve` on that device. Machine revocation from the Aexol UI
|
|
182
|
+
is on the roadmap; today the most reliable approach is to rotate the team
|
|
183
|
+
API key, which invalidates every machine's relay JWT for that team.
|
|
184
|
+
|
|
185
|
+
## Privacy & data
|
|
186
|
+
|
|
187
|
+
- **Model API keys**:
|
|
188
|
+
- For `spectral` (CLI / TUI mode): live ONLY on the machine, in pi's own
|
|
189
|
+
`~/.pi/agent/auth.json`. Never read or transmitted by Spectral.
|
|
190
|
+
- For `spectral serve` (relay mode): live on the **backend**, not the
|
|
191
|
+
machine. The local machine holds only its machine JWT; provider keys
|
|
192
|
+
are managed centrally and scoped to the team's `BaseModel` whitelist.
|
|
193
|
+
- **Code, messages, file contents, generated artifacts** live ONLY on the
|
|
194
|
+
machine, in `~/.spectral/sessions.db` and the working directory you point
|
|
195
|
+
`spectral serve` at.
|
|
196
|
+
- **The backend stores**: machine identity (id, display name, hostname,
|
|
197
|
+
last-seen timestamps), the relay JWT issued at registration, and team
|
|
198
|
+
membership. For `spectral serve`, it also holds the centralized provider
|
|
199
|
+
API keys used to fulfil inference requests on behalf of authorized
|
|
200
|
+
machines.
|
|
201
|
+
- **The backend does not store**: prompts, responses, tool calls, files,
|
|
202
|
+
artifacts, or any other message-channel content.
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT — see [LICENSE](./LICENSE).
|
|
207
|
+
|
|
208
|
+
## Links
|
|
209
|
+
|
|
210
|
+
- Website: <https://aexol.com>
|
|
211
|
+
- Source is currently hosted in an internal Aexol repository; public mirror TBD.
|
|
212
|
+
- Issues: please file them with your Aexol contact (a public issue tracker
|
|
213
|
+
is not yet available).
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* @aexol/spectral — Coding agent that never sleeps
|
|
4
|
+
*
|
|
5
|
+
* Thin branding wrapper around @mariozechner/pi-coding-agent (pi).
|
|
6
|
+
*
|
|
7
|
+
* Delegation strategy: SUBPROCESS spawn of pi's bin.
|
|
8
|
+
*
|
|
9
|
+
* Why subprocess and not in-process import?
|
|
10
|
+
* - pi's CLI entry has top-level side effects (TUI bootstrap, signal handlers,
|
|
11
|
+
* raw stdin mode). Running it in-process means our wrapper process owns those,
|
|
12
|
+
* and any future pre-processing we add (skills, system prompts) becomes
|
|
13
|
+
* entangled with pi's lifecycle.
|
|
14
|
+
* - A child process gives us clean exit-code propagation, clean SIGINT
|
|
15
|
+
* forwarding, and a stable boundary for future iterations to inject things
|
|
16
|
+
* like extra args, env vars, or post-run hooks without monkey-patching pi.
|
|
17
|
+
* - stdio: "inherit" gives pi direct TTY access, so its TUI renders correctly
|
|
18
|
+
* and raw stdin works as expected.
|
|
19
|
+
*
|
|
20
|
+
* Subcommand routing:
|
|
21
|
+
* - `spectral login` → interactive auth flow (writes ~/.spectral/config.json)
|
|
22
|
+
* - `spectral logout` → deletes ~/.spectral/config.json
|
|
23
|
+
* - `spectral --version` / `--help` → branded short-circuits, no auth needed.
|
|
24
|
+
* - anything else → pre-flight: require ~/.spectral/config.json, then spawn pi
|
|
25
|
+
* with the bundled Aexol MCP extension auto-loaded.
|
|
26
|
+
*/
|
|
27
|
+
import { spawn } from "node:child_process";
|
|
28
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
29
|
+
import { constants as osConstants } from "node:os";
|
|
30
|
+
import { dirname, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
import { requireLogin } from "./preflight.js";
|
|
33
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
34
|
+
// ---- Read our own version ----------------------------------------------------
|
|
35
|
+
const pkgPath = resolve(__dirname, "..", "package.json");
|
|
36
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
37
|
+
const VERSION = pkg.version;
|
|
38
|
+
const TAGLINE = "Coding agent that never sleeps";
|
|
39
|
+
// ---- Resolve pi's bin --------------------------------------------------------
|
|
40
|
+
// pi exports `.` as ESM only and does not export `./package.json`, so
|
|
41
|
+
// require.resolve(...) is unreliable across Node versions. Instead we walk
|
|
42
|
+
// upward from this file's location looking for a `node_modules/<pi>/package.json`.
|
|
43
|
+
function resolvePiBin() {
|
|
44
|
+
const piPkgRel = "node_modules/@mariozechner/pi-coding-agent/package.json";
|
|
45
|
+
let dir = __dirname;
|
|
46
|
+
let piPkgJsonPath;
|
|
47
|
+
for (let i = 0; i < 20; i++) {
|
|
48
|
+
const candidate = resolve(dir, piPkgRel);
|
|
49
|
+
try {
|
|
50
|
+
readFileSync(candidate, "utf8");
|
|
51
|
+
piPkgJsonPath = candidate;
|
|
52
|
+
break;
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* keep walking */
|
|
56
|
+
}
|
|
57
|
+
const parent = dirname(dir);
|
|
58
|
+
if (parent === dir)
|
|
59
|
+
break;
|
|
60
|
+
dir = parent;
|
|
61
|
+
}
|
|
62
|
+
if (!piPkgJsonPath) {
|
|
63
|
+
throw new Error("Unable to locate @mariozechner/pi-coding-agent in any ancestor node_modules.");
|
|
64
|
+
}
|
|
65
|
+
const piPkg = JSON.parse(readFileSync(piPkgJsonPath, "utf8"));
|
|
66
|
+
let binRel;
|
|
67
|
+
if (typeof piPkg.bin === "string") {
|
|
68
|
+
binRel = piPkg.bin;
|
|
69
|
+
}
|
|
70
|
+
else if (piPkg.bin && typeof piPkg.bin === "object") {
|
|
71
|
+
binRel = piPkg.bin.pi ?? Object.values(piPkg.bin)[0];
|
|
72
|
+
}
|
|
73
|
+
if (!binRel) {
|
|
74
|
+
throw new Error("Unable to locate pi bin in @mariozechner/pi-coding-agent package.json");
|
|
75
|
+
}
|
|
76
|
+
return resolve(dirname(piPkgJsonPath), binRel);
|
|
77
|
+
}
|
|
78
|
+
/** Absolute path to the bundled aexol-mcp extension, sitting next to this file in dist/. */
|
|
79
|
+
function resolveAexolExtensionPath() {
|
|
80
|
+
return resolve(__dirname, "extensions", "aexol-mcp.js");
|
|
81
|
+
}
|
|
82
|
+
// ---- Branded helpers ---------------------------------------------------------
|
|
83
|
+
function printVersion() {
|
|
84
|
+
process.stdout.write(`spectral ${VERSION} — ${TAGLINE}\n`);
|
|
85
|
+
}
|
|
86
|
+
function printHeader() {
|
|
87
|
+
process.stdout.write([
|
|
88
|
+
`spectral ${VERSION}`,
|
|
89
|
+
TAGLINE,
|
|
90
|
+
"",
|
|
91
|
+
"Subcommands:",
|
|
92
|
+
" spectral login Authenticate with the Aexol MCP backend",
|
|
93
|
+
" spectral logout Remove stored Aexol credentials",
|
|
94
|
+
" spectral serve Connect this machine to the Aexol relay backend",
|
|
95
|
+
" spectral bind Link this directory to an Aexol Studio project",
|
|
96
|
+
" spectral unbind Remove the Aexol Studio project binding",
|
|
97
|
+
"",
|
|
98
|
+
"Powered by pi (@mariozechner/pi-coding-agent).",
|
|
99
|
+
"All other flags are forwarded to pi. Run: spectral <pi-args>",
|
|
100
|
+
"",
|
|
101
|
+
].join("\n"));
|
|
102
|
+
}
|
|
103
|
+
// ---- Delegate to pi ----------------------------------------------------------
|
|
104
|
+
function delegateToPi(args) {
|
|
105
|
+
const piBin = resolvePiBin();
|
|
106
|
+
const child = spawn(process.execPath, [piBin, ...args], {
|
|
107
|
+
stdio: "inherit",
|
|
108
|
+
env: process.env,
|
|
109
|
+
});
|
|
110
|
+
// Forward common termination signals to pi so its TUI can clean up.
|
|
111
|
+
const signals = ["SIGINT", "SIGTERM", "SIGHUP", "SIGQUIT"];
|
|
112
|
+
for (const sig of signals) {
|
|
113
|
+
process.on(sig, () => {
|
|
114
|
+
if (!child.killed) {
|
|
115
|
+
try {
|
|
116
|
+
child.kill(sig);
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
/* ignore */
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
}
|
|
124
|
+
child.on("exit", (code, signal) => {
|
|
125
|
+
if (signal) {
|
|
126
|
+
const sigNum = osConstants.signals[signal] ?? 0;
|
|
127
|
+
process.exit(128 + sigNum);
|
|
128
|
+
}
|
|
129
|
+
process.exit(code ?? 0);
|
|
130
|
+
});
|
|
131
|
+
child.on("error", (err) => {
|
|
132
|
+
process.stderr.write(`spectral: failed to launch pi: ${err.message}\n`);
|
|
133
|
+
process.exit(1);
|
|
134
|
+
});
|
|
135
|
+
// spawn returns; keep the type system happy.
|
|
136
|
+
// We never actually reach here because of the exit handlers above, but
|
|
137
|
+
// TypeScript needs a `never` terminator.
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
// ---- Main --------------------------------------------------------------------
|
|
141
|
+
async function main() {
|
|
142
|
+
const args = process.argv.slice(2);
|
|
143
|
+
const first = args[0];
|
|
144
|
+
// Branded short-circuits: never require login.
|
|
145
|
+
if (first === "--version" || first === "-v") {
|
|
146
|
+
printVersion();
|
|
147
|
+
process.exit(0);
|
|
148
|
+
}
|
|
149
|
+
if (first === "--help" || first === "-h") {
|
|
150
|
+
printHeader();
|
|
151
|
+
// Spawn pi to append its own help, then bail. delegateToPi is
|
|
152
|
+
// annotated `: never` because its child.on("exit") handler calls
|
|
153
|
+
// process.exit, but the function itself returns synchronously after
|
|
154
|
+
// spawn — so we must not fall through to the pre-flight check below.
|
|
155
|
+
delegateToPi(args);
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
// Subcommands. Dynamic import keeps the cold-start path light when users
|
|
159
|
+
// are just running pi.
|
|
160
|
+
if (first === "login") {
|
|
161
|
+
const { runLogin } = await import("./commands/login.js");
|
|
162
|
+
await runLogin();
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
if (first === "logout") {
|
|
166
|
+
const { runLogout } = await import("./commands/logout.js");
|
|
167
|
+
await runLogout();
|
|
168
|
+
process.exit(0);
|
|
169
|
+
}
|
|
170
|
+
if (first === "serve") {
|
|
171
|
+
const { runServeCli } = await import("./commands/serve.js");
|
|
172
|
+
// runServeCli does the pre-flight login check itself and keeps the
|
|
173
|
+
// process alive on the bound socket; signal handlers it installs handle
|
|
174
|
+
// graceful shutdown. We intentionally don't process.exit here.
|
|
175
|
+
await runServeCli(args.slice(1));
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (first === "bind") {
|
|
179
|
+
const { runBind } = await import("./commands/bind.js");
|
|
180
|
+
await runBind(args.slice(1));
|
|
181
|
+
process.exit(0);
|
|
182
|
+
}
|
|
183
|
+
if (first === "unbind") {
|
|
184
|
+
const { runUnbind } = await import("./commands/unbind.js");
|
|
185
|
+
await runUnbind();
|
|
186
|
+
process.exit(0);
|
|
187
|
+
}
|
|
188
|
+
// Pre-flight: every other invocation requires authenticated state. We do NOT
|
|
189
|
+
// auto-launch the login flow — that would fight pi's stdio inheritance and
|
|
190
|
+
// hide auth state changes from the user.
|
|
191
|
+
await requireLogin();
|
|
192
|
+
// Resolve and inject the Aexol MCP extension. Sanity-check existence so we
|
|
193
|
+
// fail with a clear message if someone publishes a broken bundle.
|
|
194
|
+
const extPath = resolveAexolExtensionPath();
|
|
195
|
+
if (!existsSync(extPath)) {
|
|
196
|
+
process.stderr.write(`spectral: bundled Aexol MCP extension not found at ${extPath}. This is a packaging bug.\n`);
|
|
197
|
+
process.exit(1);
|
|
198
|
+
}
|
|
199
|
+
const finalArgs = ["--extension", extPath, ...args];
|
|
200
|
+
delegateToPi(finalArgs);
|
|
201
|
+
}
|
|
202
|
+
main().catch((err) => {
|
|
203
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
204
|
+
process.stderr.write(`spectral: ${msg}\n`);
|
|
205
|
+
process.exit(1);
|
|
206
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `spectral bind` — link the current directory to an Aexol Studio project.
|
|
3
|
+
*
|
|
4
|
+
* Usage:
|
|
5
|
+
* spectral bind <project-id> [--team-id <id>] [--name <name>] [--force]
|
|
6
|
+
*
|
|
7
|
+
* Writes `.aexol/aexol.jsonc` so that `spectral` knows which Studio project
|
|
8
|
+
* this repository belongs to. By default it refuses to overwrite an existing
|
|
9
|
+
* binding; pass `--force` to rebind.
|
|
10
|
+
*/
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
import { readStudioBinding, writeStudioBinding, } from "../studio-binding.js";
|
|
13
|
+
export async function runBind(args) {
|
|
14
|
+
// ---- Parse args ----------------------------------------------------------
|
|
15
|
+
let projectId = "";
|
|
16
|
+
let teamId;
|
|
17
|
+
let name;
|
|
18
|
+
let force = false;
|
|
19
|
+
for (let i = 0; i < args.length; i++) {
|
|
20
|
+
const a = args[i];
|
|
21
|
+
if (a === "--team-id") {
|
|
22
|
+
const next = args[i + 1];
|
|
23
|
+
if (!next) {
|
|
24
|
+
process.stderr.write(pc.red("--team-id requires a value\n"));
|
|
25
|
+
process.exit(1);
|
|
26
|
+
}
|
|
27
|
+
teamId = next;
|
|
28
|
+
i++;
|
|
29
|
+
}
|
|
30
|
+
else if (a.startsWith("--team-id=")) {
|
|
31
|
+
teamId = a.slice("--team-id=".length);
|
|
32
|
+
}
|
|
33
|
+
else if (a === "--name") {
|
|
34
|
+
const next = args[i + 1];
|
|
35
|
+
if (!next) {
|
|
36
|
+
process.stderr.write(pc.red("--name requires a value\n"));
|
|
37
|
+
process.exit(1);
|
|
38
|
+
}
|
|
39
|
+
name = next;
|
|
40
|
+
i++;
|
|
41
|
+
}
|
|
42
|
+
else if (a.startsWith("--name=")) {
|
|
43
|
+
name = a.slice("--name=".length);
|
|
44
|
+
}
|
|
45
|
+
else if (a === "--force") {
|
|
46
|
+
force = true;
|
|
47
|
+
}
|
|
48
|
+
else if (a.startsWith("-")) {
|
|
49
|
+
process.stderr.write(pc.red(`Unknown flag: ${a}\n`));
|
|
50
|
+
process.exit(1);
|
|
51
|
+
}
|
|
52
|
+
else if (!projectId) {
|
|
53
|
+
projectId = a;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
process.stderr.write(pc.red(`Unexpected argument: ${a}\n`));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
if (!projectId) {
|
|
61
|
+
process.stderr.write(pc.red("Usage: spectral bind <project-id> [--team-id <id>] [--name <name>] [--force]\n"));
|
|
62
|
+
process.exit(1);
|
|
63
|
+
}
|
|
64
|
+
// ---- Check existing ------------------------------------------------------
|
|
65
|
+
let existing = null;
|
|
66
|
+
try {
|
|
67
|
+
existing = await readStudioBinding();
|
|
68
|
+
}
|
|
69
|
+
catch (err) {
|
|
70
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
71
|
+
process.stderr.write(pc.red(`Failed to read existing binding: ${msg}\n`));
|
|
72
|
+
process.exit(1);
|
|
73
|
+
}
|
|
74
|
+
if (existing && !force) {
|
|
75
|
+
const existingName = existing.name ?? "(unnamed)";
|
|
76
|
+
process.stderr.write(pc.yellow(`Already bound to project ${existingName} (${existing.projectId}). Use --force to rebind.\n`));
|
|
77
|
+
process.exit(0);
|
|
78
|
+
}
|
|
79
|
+
// ---- Write ---------------------------------------------------------------
|
|
80
|
+
const binding = {
|
|
81
|
+
$schema: "https://aexol.ai/schemas/studio-binding.json",
|
|
82
|
+
projectId,
|
|
83
|
+
...(teamId ? { teamId } : {}),
|
|
84
|
+
...(name ? { name } : {}),
|
|
85
|
+
};
|
|
86
|
+
try {
|
|
87
|
+
await writeStudioBinding(binding);
|
|
88
|
+
}
|
|
89
|
+
catch (err) {
|
|
90
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
91
|
+
process.stderr.write(pc.red(`Failed to write binding: ${msg}\n`));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
const displayName = name ?? projectId;
|
|
95
|
+
process.stdout.write(pc.green(`✓ Bound to Studio project: ${displayName} (${projectId})\n`));
|
|
96
|
+
}
|