lugent 0.0.0-stage → 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.
Files changed (116) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +330 -2
  3. package/package.json +28 -4
  4. package/src/cli/commands.test.ts +50 -0
  5. package/src/cli/commands.ts +34 -0
  6. package/src/cli/context.test.ts +56 -0
  7. package/src/cli/context.ts +34 -0
  8. package/src/cli/logo.test.ts +109 -0
  9. package/src/cli/logo.ts +116 -0
  10. package/src/cli/repl.ts +210 -0
  11. package/src/cli/tui/agent-picker.test.tsx +46 -0
  12. package/src/cli/tui/agent-picker.tsx +44 -0
  13. package/src/cli/tui/app.test.tsx +1102 -0
  14. package/src/cli/tui/app.tsx +391 -0
  15. package/src/cli/tui/components.test.tsx +120 -0
  16. package/src/cli/tui/harness.tsx +43 -0
  17. package/src/cli/tui/input.test.tsx +455 -0
  18. package/src/cli/tui/input.tsx +224 -0
  19. package/src/cli/tui/markdown.test.ts +88 -0
  20. package/src/cli/tui/markdown.ts +104 -0
  21. package/src/cli/tui/mcp-list.test.tsx +116 -0
  22. package/src/cli/tui/mcp-list.tsx +86 -0
  23. package/src/cli/tui/model-picker.test.tsx +107 -0
  24. package/src/cli/tui/model-picker.tsx +100 -0
  25. package/src/cli/tui/permission-mode.tsx +33 -0
  26. package/src/cli/tui/permissions.tsx +14 -0
  27. package/src/cli/tui/replay.test.ts +34 -0
  28. package/src/cli/tui/session-picker.test.tsx +43 -0
  29. package/src/cli/tui/session-picker.tsx +57 -0
  30. package/src/cli/tui/skill-list.test.tsx +53 -0
  31. package/src/cli/tui/skill-list.tsx +47 -0
  32. package/src/cli/tui/status.tsx +87 -0
  33. package/src/cli/tui/transcript.test.tsx +73 -0
  34. package/src/cli/tui/transcript.tsx +182 -0
  35. package/src/cli/ui.test.ts +67 -0
  36. package/src/cli/ui.ts +95 -0
  37. package/src/core/__snapshots__/prompt.test.ts.snap +123 -0
  38. package/src/core/agents.test.ts +213 -0
  39. package/src/core/agents.ts +197 -0
  40. package/src/core/config.test.ts +264 -0
  41. package/src/core/config.ts +157 -0
  42. package/src/core/images.test.ts +55 -0
  43. package/src/core/images.ts +58 -0
  44. package/src/core/loop.test.ts +476 -0
  45. package/src/core/loop.ts +183 -0
  46. package/src/core/mcp/__fixtures__/mcp-echo-server.ts +62 -0
  47. package/src/core/mcp/client.test.ts +106 -0
  48. package/src/core/mcp/client.ts +130 -0
  49. package/src/core/mcp/connect.ts +32 -0
  50. package/src/core/mcp/framing.test.ts +35 -0
  51. package/src/core/mcp/framing.ts +37 -0
  52. package/src/core/mcp/http.test.ts +147 -0
  53. package/src/core/mcp/http.ts +93 -0
  54. package/src/core/mcp/lifecycle.test.ts +49 -0
  55. package/src/core/mcp/manager.test.ts +248 -0
  56. package/src/core/mcp/manager.ts +219 -0
  57. package/src/core/mcp/oauth/callback.test.ts +117 -0
  58. package/src/core/mcp/oauth/callback.ts +176 -0
  59. package/src/core/mcp/oauth/discovery.test.ts +94 -0
  60. package/src/core/mcp/oauth/discovery.ts +75 -0
  61. package/src/core/mcp/oauth/errors.ts +15 -0
  62. package/src/core/mcp/oauth/flow.test.ts +169 -0
  63. package/src/core/mcp/oauth/flow.ts +117 -0
  64. package/src/core/mcp/oauth/pkce.test.ts +20 -0
  65. package/src/core/mcp/oauth/pkce.ts +16 -0
  66. package/src/core/mcp/oauth/register.test.ts +35 -0
  67. package/src/core/mcp/oauth/register.ts +24 -0
  68. package/src/core/mcp/oauth/request.test.ts +59 -0
  69. package/src/core/mcp/oauth/request.ts +60 -0
  70. package/src/core/mcp/oauth/session.test.ts +119 -0
  71. package/src/core/mcp/oauth/session.ts +74 -0
  72. package/src/core/mcp/oauth/store.test.ts +71 -0
  73. package/src/core/mcp/oauth/store.ts +61 -0
  74. package/src/core/mcp/oauth/token.test.ts +73 -0
  75. package/src/core/mcp/oauth/token.ts +76 -0
  76. package/src/core/mcp/protocol.ts +32 -0
  77. package/src/core/mcp/stdio.test.ts +67 -0
  78. package/src/core/mcp/stdio.ts +90 -0
  79. package/src/core/mcp/tools.test.ts +42 -0
  80. package/src/core/mcp/tools.ts +39 -0
  81. package/src/core/mcp/transport.ts +14 -0
  82. package/src/core/permissions.test.ts +48 -0
  83. package/src/core/permissions.ts +20 -0
  84. package/src/core/platform.test.ts +40 -0
  85. package/src/core/platform.ts +34 -0
  86. package/src/core/prompt.test.ts +116 -0
  87. package/src/core/prompt.ts +82 -0
  88. package/src/core/provider.test.ts +239 -0
  89. package/src/core/provider.ts +197 -0
  90. package/src/core/skills.test.ts +71 -0
  91. package/src/core/skills.ts +58 -0
  92. package/src/core/store.test.ts +220 -0
  93. package/src/core/store.ts +141 -0
  94. package/src/core/tools/diff.ts +7 -0
  95. package/src/core/tools/edit.test.ts +42 -0
  96. package/src/core/tools/edit.ts +36 -0
  97. package/src/core/tools/glob.ts +19 -0
  98. package/src/core/tools/grep.test.ts +28 -0
  99. package/src/core/tools/grep.ts +33 -0
  100. package/src/core/tools/index.ts +13 -0
  101. package/src/core/tools/read.test.ts +38 -0
  102. package/src/core/tools/read.ts +37 -0
  103. package/src/core/tools/registry.test.ts +36 -0
  104. package/src/core/tools/registry.ts +35 -0
  105. package/src/core/tools/shell.test.ts +26 -0
  106. package/src/core/tools/shell.ts +42 -0
  107. package/src/core/tools/skill.test.ts +28 -0
  108. package/src/core/tools/skill.ts +16 -0
  109. package/src/core/tools/subagent.test.ts +25 -0
  110. package/src/core/tools/subagent.ts +21 -0
  111. package/src/core/tools/write.test.ts +19 -0
  112. package/src/core/tools/write.ts +24 -0
  113. package/src/core/types.ts +72 -0
  114. package/src/e2e.test.ts +209 -0
  115. package/src/main.test.ts +7 -0
  116. package/src/main.ts +68 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Luiz
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 CHANGED
@@ -1,3 +1,331 @@
1
- # Temporary Holding Version
1
+ <p align="center"><img src="docs/lugent.svg" alt="lugent — CLI agent" width="480"></p>
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ # lugent
4
+
5
+ A minimal OpenCode-style coding agent. REPL CLI over a pure core library — TypeScript + Bun, zero runtime dependencies.
6
+
7
+ **[lugent(1) — landing page](https://luizrgf2.github.io/lugent/)**
8
+
9
+ ## Install
10
+
11
+ ```bash
12
+ npm i -g lugent # requires Bun installed (bun >= 1.4)
13
+ # or straight from bunx
14
+ bunx lugent
15
+ ```
16
+
17
+ Runs the TUI on a TTY, plain REPL otherwise (`--plain` forces it).
18
+
19
+ ## Run from source
20
+
21
+ ```
22
+ bun src/main.ts
23
+ ```
24
+
25
+ ## Requirements
26
+
27
+ - [Bun](https://bun.sh) 1.3+
28
+ - Any OpenAI-compatible chat endpoint (`/v1`): Ollama, LM Studio, OpenRouter, local routers, etc.
29
+
30
+ ## Configuration
31
+
32
+ For `providers`, `defaultProvider`, `defaultModel` and `defaultAgent` lugent reads the **first file found** — no merging between the two:
33
+
34
+ 1. `<project>/lugent.json` (wins)
35
+ 2. `~/.config/lugent/config.json` (global fallback; works on Windows too)
36
+
37
+ (`mcp`, agents and skills merge both files instead — see below.)
38
+
39
+ ```json
40
+ {
41
+ "providers": {
42
+ "ollama": {
43
+ "baseUrl": "http://localhost:11434/v1",
44
+ "apiKey": "{env:OLLAMA_API_KEY}"
45
+ }
46
+ },
47
+ "defaultProvider": "ollama",
48
+ "defaultModel": "qwen3.5:14b",
49
+ "mcp": {
50
+ "chrome-devtools": { "type": "local", "command": ["npx", "-y", "chrome-devtools-mcp@latest"] },
51
+ "takeflow": {
52
+ "type": "remote",
53
+ "url": "https://chatback.takeflow.com.br/mcp",
54
+ "headers": { "X-Tenant": "{env:TAKEFLOW_TENANT}" }
55
+ }
56
+ }
57
+ }
58
+ ```
59
+
60
+ | Field | Description |
61
+ | --- | --- |
62
+ | `providers.<name>.baseUrl` | OpenAI-compatible base URL. Must include `/v1` |
63
+ | `providers.<name>.apiKey` | Optional. `{env:VAR}` is substituted at load; unknown vars become `""` |
64
+ | `defaultProvider` | Key into `providers` used at startup |
65
+ | `defaultModel` | Model id sent as `model` on every request |
66
+ | `defaultAgent` | Optional agent id selected at startup. Defaults to `build`; unknown id → warning, then `build` |
67
+ | `permissionMode` | Optional, `normal` (default) or `yolo`. Startup permission mode — TUI only. Saved to the global config by `/permissions`; a project value overrides it while present |
68
+ | `mcp.<name>.type` | `local` (spawns `command`) or `remote` (`url`); `http` is an alias for `remote`. Inferred when absent |
69
+ | `mcp.<name>.command` | `local`: argv array, e.g. `["npx","-y","pkg@latest"]` |
70
+ | `mcp.<name>.environment` / `.cwd` | `local`: extra env (values support `{env:VAR}`) and working dir relative to the project |
71
+ | `mcp.<name>.url` | `remote`: Streamable HTTP endpoint |
72
+ | `mcp.<name>.headers` | `remote`: extra headers (values support `{env:VAR}`) |
73
+ | `mcp.<name>.scopes` | `remote`: optional string array requested during OAuth sign-in. Without it, no `scope` parameter is sent and the authorization server's consent screen decides |
74
+ | `mcp.<name>.enabled` | `false` skips the server at startup (the entry stays, listed `disabled` by `/mcp`, so it can be re-enabled for the session) |
75
+ | `mcp.<name>.timeout` | Per-request timeout in ms (default `30000`) |
76
+
77
+ Unlike `providers` (first file found wins), `mcp` **merges** both config files — global first, project wins per server — the same way agents and skills merge. A project entry replaces the whole global entry; to disable a server declared in the other file, repeat it with `enabled: false` — either the whole entry, or just `{ "enabled": false }` as a marker. Markers are applied last and win wherever the server was declared, so your global config can switch off a server a repo declares.
78
+
79
+ At startup each provider gets a health check (`GET /models`, 8s timeout) — unreachable ones are reported as `OFF` but lugent still starts.
80
+
81
+ ## Usage
82
+
83
+ In a terminal, `lugent` starts a **full-screen TUI** (React + Ink): the big L-mark logo (chevron + base + agent dot, half-block raster of the SVG logo, SVG-exact gradient) with a block-letter `LUGENT` wordmark (white/cyan, five rows of 5×5 glyphs) beneath opens **horizontally and vertically centered** on the empty screen; the moment the first item lands, the logo is committed once to the conversation's top (terminal scrollback) and never re-rendered — it stays at the start of the conversation, not between messages; scrollable transcript (items separated by blank lines, answers indented) with live model reasoning (labeled `… pensando` block, indented), markdown-rendered answers (headings, tables, lists), tool lines, inline y/n/a permission dialogs, and one chrome line above the composer (a blank row keeps the transcript off it): `§ AGENTS.md loaded │ agent <id> │ YOLO` on the left, spinner + `working…` (while a turn runs) and the always-visible `provider · model` on the right.
84
+
85
+ ```
86
+ ██▄
87
+ ▀██▄
88
+ ▀██▄
89
+ ▀██▄
90
+ ▄███▀ ●
91
+ ▄███▀
92
+ ▄███▀
93
+ ███▀
94
+ ▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄▄
95
+ ▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀▀
96
+ LUGENT
97
+
98
+ > go ▏ composer: Enter submits, ↑/↓ history
99
+ pondering which tool to use… reasoning (dim, live)
100
+ ▸ read src/main.ts tool calls
101
+ all **done** answer (markdown-lite)
102
+ ```
103
+
104
+ (The logo above is shown as it opens on a 30-row terminal — 9 blank rows sit above it, none below in this excerpt.)
105
+
106
+ - **Ctrl+C once** aborts the running turn (and denies a pending permission); **twice** exits.
107
+ - **Runaway guards** (nothing runs forever): a model stream ends after 10min even if the proxy keeps sending keep-alive bytes, and an MCP server that never stops paginating its tools fails to connect instead of hanging startup. Tool rounds are uncapped — the model decides when a turn is done, ctrl+c aborts. Provider failures (`429`/`5xx`, stalled streams, hosts that never answer — 15s connect cap) retry **indefinitely** with backoff (starts at 10s, doubles, 60s cap) — every failed attempt shows in the UI (`attempt 3 failed — HTTP 503 · retrying in 40s`), and ESC stops the loop. Client errors (401, 400 — bad key, bad request) fail immediately instead of looping. When the loop is stopped it surfaces (`HTTP 503 (after N attempts)`), including mid-stream error events and 200-with-JSON-error responses.
108
+ - **Scrolling works like any terminal program**: finished turns are printed into the terminal's own scrollback, so the **mouse wheel, touchpad and click-drag selection** scroll the whole conversation natively — no capture, no commands, copying always works. The live frame below holds only the turn in progress: **PageUp/PageDown** (or **Shift+↑/↓**) page through it, **Home/End** jump to its top/tail, and scrolling up while the answer streams **pins the view** — new content grows below without jumping the screen. Submitting a new message snaps back to the tail. The frame is a fixed-height column: the composer and the chrome line stay put, and a long answer that wraps (or a picker opening) can never push the input off-screen.
109
+ - Slash commands: `/exit` quits; `/model` opens a two-level picker (provider → live model list via `listModels()`, `↑/↓`+Enter, Esc cancels, Ctrl+C closes) — the choice is pinned to the session, so `/resume` brings it back; `/skills` browses loaded skills; `/mcp` lists the MCP servers with their status and lets you reconnect, disable or enable one, sign in with OAuth (`authenticate`) or sign out (`logout`); `/resume` picks a past session of this directory (replays its transcript with its last provider/model, next turn continues it — not a fork); `/permissions` toggles normal/yolo (yolo auto-approves every tool call; a red `YOLO` badge shows on the chrome line above the composer); `/agent` opens the agent picker — the chosen profile's prompt, model and toolset apply from the next turn, and the chrome line shows the active agent (`build` by default). Typing `/` in the composer shows an autocomplete box — it filters as you type, `↑/↓` move, Enter or Tab completes. Others work in `--plain` mode.
110
+ - **Pasting multi-line text** collapses into an inline token — `[paste #1: 100 lines]` (or `X chars` for a long single line, threshold 200 chars) — instead of flooding the composer. Tokens are atomic: the caret can't enter one, ←/→ step over the whole token, and backspace deletes it as a unit. `↑/↓` history recall preserves tokens; on Enter the full pasted text is expanded back into what the model receives (so the transcript and the stored message show the expanded paste, while the composer and history stay compact).
111
+ - `--plain` forces the inline REPL below; piped stdin (non-TTY) falls back to it automatically. When the TUI starts it erases the visible viewport (scrollback is kept): shell leftovers and startup warnings stay reachable above, not interleaved with the UI.
112
+ - **Image attachments**: mention a file with `@path/to.png` in your message (`.png` `.jpg` `.jpeg` `.gif` `.webp`, ≤20 MB, relative to the project root). It is sent to the model as an OpenAI vision `image_url` part — works with any multimodal model behind an OpenAI-compatible endpoint. Unresolvable markers stay literal text.
113
+
114
+ Plain REPL:
115
+
116
+ ```
117
+ ▟▙ ANSI L-mark logo (terminal colors,
118
+ ▟▛ ▚▖ gradient from docs/lugent.svg)
119
+ ▟▛ ▚▖
120
+ ▀▀▀▀▙
121
+ LUGENT
122
+ ┌─ lugent ──────────────┐
123
+ │ session ses_676f… │ banner on startup
124
+ │ 9router · glm/glm-5.2 │
125
+ └───────────────────────┘
126
+ > read src/main.ts # plain message: the model drives the tools
127
+ ▸ read src/main.ts # tool lines use glyphs (▸ read, ✎ edit, $ shell…)
128
+ ⠙ thinking… # spinner while the turn runs
129
+ > /model # list models of the current provider
130
+ > /model ollama qwen3.5:14b # switch provider (+ optional model)
131
+ > /skills # list advertised skills
132
+ > /mcp # list MCP servers and their status
133
+ > /mcp disable echo # take a server (and its tools) out of the session
134
+ > /mcp auth takeflow # open the browser and sign in to a remote server (OAuth)
135
+ > /clear # start a fresh session
136
+ > /resume # list this directory's sessions, pick one to continue
137
+ > /exit # quit
138
+ ```
139
+
140
+ - UI is hand-rolled ANSI (`src/cli/ui.ts`) in plain mode; colors/spinner auto-disable when output is piped.
141
+ - Permission prompts answer `y` / `n` / `a` (always, session-scoped). Anything else — including Enter — denies.
142
+ - `--db <path>` overrides the database location.
143
+
144
+ ## Tools
145
+
146
+ | Tool | Default | Does |
147
+ | --- | --- | --- |
148
+ | `read` | allow | File with numbered lines (offset/limit) or directory listing; rejects binary |
149
+ | `glob` | allow | Glob file search (`**/*.ts`), limit 100 |
150
+ | `grep` | allow | Content search, `path:line:preview`, regex or literal, skips binary |
151
+ | `edit` | ask | Unique-match string replace (`replaceAll` opt-in), returns diff |
152
+ | `write` | ask | Create/replace file, creates parent dirs |
153
+ | `shell` | ask | `bash -c` (`cmd /c` on Windows), timeout default 2min, `background: true` |
154
+ | `skill` | allow | Load a skill body by id |
155
+ | `subagent` | ask | Launch a subagent in a child session (id from the `# Subagents` list, fresh context); returns the child's final message |
156
+
157
+ **Extra guard:** any resource outside the project root (absolute path, `..`, `~`) forces an `ask` even for `allow` tools, unless already granted for the session.
158
+
159
+ MCP tools are added on top, named `<server>_<tool>` (non-`[A-Za-z0-9_-]` characters become `_`, runs of `_` collapse to one, capped at 64 chars); a name collision drops the later tool, not the server. They always default to `ask` — see [MCP](#mcp) below.
160
+
161
+ ## MCP
162
+
163
+ lugent speaks the Model Context Protocol over two transports, declared in the `mcp` map (see [Configuration](#configuration)):
164
+
165
+ - **`local`** — spawns a child process and talks newline-delimited JSON-RPC over its stdio (e.g. `chrome-devtools`, `browsermcp`).
166
+ - **`remote`** (alias `http`) — Streamable HTTP: one POST per JSON-RPC message, with `Mcp-Session-Id` handling, SSE responses and `202` notifications.
167
+
168
+ At startup every enabled server is connected in parallel, its tools are listed and bridged into the tool registry as `<server>_<tool>`. A server that fails to start is reported as `mcp: <name>: <reason>` and **never stops lugent** — the rest keep working. A `remote` server that answers `401` is reported as needing authentication instead of failing (see below). A `local` server whose child dies later flips to `failed` on its own, so `/mcp` can bring it back (a `remote` server is only noticed on its next failed call). MCP servers are untrusted third parties, so their tools are permission-gated (`ask` by default) and an agent's `tools`/`permissions` can allow, ask or deny them by name.
169
+
170
+ **Trust note:** a project `lugent.json` can declare `local` servers, whose `command` is spawned at startup with no prompt — same trust channel as a project `AGENTS.md`, skills or agents (see `docs/decisions.md`, 2026-10-24). Read a repo's `mcp` block before running lugent in it; every server is listed by `/mcp` and any failure is printed at startup.
171
+
172
+ `/mcp` lists every server with its status — `connected` (with a tool count), `connecting` (a handshake still in flight), `failed` (with the error), `auth_required` (needs a sign-in) or `disabled` — and lets you act on one:
173
+
174
+ - `connected` (remote) → **logout** or **disable**; `connected` (local) → **disable**
175
+ - `disabled` → **enable** (reconnects)
176
+ - `auth_required` → **authenticate** or **disable**
177
+ - `failed` → **reconnect** or **disable**
178
+
179
+ In `--plain` mode the same actions are explicit: `/mcp`, `/mcp reconnect <name>`, `/mcp disable <name>`, `/mcp enable <name>`, `/mcp auth <name>` (also `/mcp authenticate <name>`) and `/mcp logout <name>`.
180
+
181
+ Enabling and disabling is **session-scoped** — lugent never rewrites `lugent.json` (a turn must not silently edit a file that can hold secrets). Restart to fall back to the configured state; persistence is deferred (see `docs/future-features.md`).
182
+
183
+ ### Signing in to a remote server (OAuth)
184
+
185
+ A `remote` server that refuses the configured headers (or has none) answers `401` with a discovery hint. lugent does **not** fail it — it reports the server as **`auth_required`** at startup and in `/mcp`, because the fix is interactive:
186
+
187
+ ```
188
+ mcp: takeflow: needs authentication — run /mcp and choose authenticate
189
+ ```
190
+
191
+ Choose `authenticate` (`/mcp auth takeflow` in `--plain` mode) and lugent:
192
+
193
+ 1. probes the endpoint, reads its protected-resource metadata and the authorization server's metadata (RFC 9728 / RFC 8414, **https only**);
194
+ 2. registers itself as a public client via dynamic client registration (RFC 7591) — once per server, then reuses the stored `client_id`;
195
+ 3. starts a loopback listener on `http://127.0.0.1:48762/oauth/callback`, builds the consent URL with PKCE S256, prints it as a fallback and opens your browser;
196
+ 4. waits up to **5 minutes** for the redirect (ctrl+c cancels), then exchanges the code — always sending the `resource` parameter, so the token is bound to that MCP endpoint — and verifies the `iss` when the server advertises it;
197
+ 5. stores the tokens in `~/.local/share/lugent/mcp-oauth.json` (mode `0600`) and reconnects the server.
198
+
199
+ The token is bound to the URL that issued it: a project `lugent.json` shadowing a global server name never hands over the global credential. Access tokens are refreshed proactively (60 s before expiry) and once more, forcibly, when the server answers `401` mid-session; if the refresh token itself is rejected the tokens are dropped and the server returns to `auth_required`. `logout` revokes at the authorization server when it offers a revocation endpoint, then deletes the local copy.
200
+
201
+ The port is fixed on purpose — it must match the registered redirect URI — and a busy port fails loudly instead of hanging. Scopes are optional (`mcp.<name>.scopes`); without it, no `scope` parameter is sent at all.
202
+
203
+ **The agent knows this surface.** lugent's system prompt carries a short `# MCP` section — always present, and *outside* the agent prompt, so it stays when you switch to `plan` or a custom agent. You can therefore ask how MCP works here or where the config lives, and ask it to install a server: it edits `lugent.json` with the normal `write`/`edit` tools (both `ask`, so nothing is rewritten without your approval) and tells you to restart. Put tokens in `{env:VAR}`, never inline — especially in a project file that may be committed.
204
+
205
+ **Index:** [`docs/architecture/mcp.md`](docs/architecture/mcp.md) (transports, client, tool bridge) · [`docs/architecture/mcp-oauth.md`](docs/architecture/mcp-oauth.md) (sign-in, token store, refresh) · [`docs/decisions/mcp.md`](docs/decisions/mcp.md) (why each choice) · [`docs/future-features.md`](docs/future-features.md) (what is deferred).
206
+
207
+ ## Skills
208
+
209
+ Drop markdown files in `.agents/skills/` — nested dir or flat. Both scopes load (user `~/.agents/skills/` + project `.agents/skills/`; on Windows the user scope is `%USERPROFILE%\.agents\skills` — same layout). Same id → project wins:
210
+
211
+ ```
212
+ .agents/skills/git-release/SKILL.md → id "git-release"
213
+ .agents/skills/review.md → id "review"
214
+ ```
215
+
216
+ ```markdown
217
+ ---
218
+ name: Git Release
219
+ description: Prepare and publish releases
220
+ ---
221
+ Step 1: ...
222
+ ```
223
+
224
+ Skills with a `description` in the frontmatter are advertised in the system prompt (id, name, description only). The model loads the full body on demand via the `skill` tool — skill content never bloats the prompt upfront.
225
+
226
+ Using skills in a session:
227
+
228
+ - **Let the model pick them.** Advertised skills appear in the system prompt; when your request matches a description ("prepare a release"), the model calls `skill git-release` itself — you see the `▸ skill` line in the transcript.
229
+ - **Force one:** mention the id in your message ("use skill git-release to …").
230
+ - **Browse:** TUI `/skills` lists what is loaded (read-only, ↑/↓+Esc); plain REPL `/skills` lists ids. Toggling skills on/off is not implemented — every loaded skill is active in every session (selector design deferred, see `docs/future-features.md`).
231
+
232
+ **Index:** [`docs/architecture/prompt-and-agents.md`](docs/architecture/prompt-and-agents.md) (how skills reach the prefix) · [`docs/future-features.md`](docs/future-features.md) § Skills (selector, deferred).
233
+
234
+ ## Agents
235
+
236
+ An agent is a named profile — system prompt, model, tool allowlist, permission overrides — defined in markdown, same shape as skills:
237
+
238
+ ```
239
+ .agents/agents/reviewer.md → id "reviewer"
240
+ .agents/agents/team/audit.md → id "team/audit" (nested path is the id)
241
+ ~/.agents/agents/reviewer.md → user scope; project wins on the same id
242
+ ```
243
+
244
+ ### Built-in agents
245
+
246
+ Three agents are always present, in every install (`build`/`plan` from OpenCode's pair, adapted: no rule engine, no plan files; `general` its default subagent):
247
+
248
+ | Id | Mode | Prompt | Tools | Permissions |
249
+ | --- | --- | --- | --- | --- |
250
+ | `build` | primary | the base system prompt | all | tool defaults |
251
+ | `plan` | primary | "plan mode": explore and design, no file changes | all but `edit`/`write` | `{edit: deny, write: deny}` |
252
+ | `general` | subagent | general-purpose task runner, fresh context | all (spawn drops `subagent` itself) | tool defaults |
253
+
254
+ Built-ins carry display colors (`build` magenta, `plan` yellow, `general` cyan); the TUI context line — fixed above the input — shows a yellow `§ AGENTS.md loaded` when a project `AGENTS.md` was loaded, plus `· agent <id>` in the agent's color.
255
+
256
+ `build` is the startup default, so there is always a way back to a normal session (`/agent build`). `plan` can read, search and run shell commands — shell stays permission-controlled, as in OpenCode — it just cannot modify files. Both load **first**, so a file with the same id (user or project) overrides the built-in without removing it: drop `.agents/agents/plan.md` to give your own plan agent.
257
+
258
+ ```markdown
259
+ ---
260
+ description: Reviews changes read-only
261
+ color: cyan
262
+ model: 9router/glm-5.2
263
+ tools: [read, glob, grep]
264
+ permissions: {shell: ask}
265
+ ---
266
+ You are a code reviewer. Report findings with file and line references.
267
+ ```
268
+
269
+ | Field | Meaning | Default when absent |
270
+ | --- | --- | --- |
271
+ | `description` | shown by `/agent` | none |
272
+ | `color` | TUI display color for the agent: an ink color name (`red`, `cyan`…) or `#rrggbb`; display-only, invalid warns and uses the default | `magenta` (build's) |
273
+ | `model` | `provider/model`, both required — provider must exist in the config | inherit the session's |
274
+ | `tools` | tool ids sent to the model; anything else is invisible to it — a `tools` key that parses to nothing (typo, block-style YAML) fails closed to **no** tools, with a warning | all tools |
275
+ | `permissions` | per-tool `allow` / `ask` / `deny`; `deny` also removes the tool | tool default |
276
+ | `mode` | `primary` \| `subagent` \| `all` — who may launch this agent | primary |
277
+
278
+ ### Subagents
279
+
280
+ A `mode: subagent` (or `all`) agent is not a session you switch into — it is a worker the model launches. During a turn the model can call the `subagent` tool with `{ agent, prompt }`; lugent starts a **child session** (a second session in the same store, same process) that runs with the subagent's own prompt, model and tools, and the child's final message comes back as the tool result.
281
+
282
+ - The model discovers subagents from a `# Subagents` section of the prompt (id + description), rendered only when at least one is launchable — same mechanism as `# Skills`.
283
+ - The child starts with **no** shared history: the prompt must be self-contained.
284
+ - Several `subagent` calls in one tool round run in parallel.
285
+ - **Depth 1**: a child never gets the `subagent` tool or the catalog, so it cannot launch a grandchild.
286
+ - Children are persisted (with a `parent` link) but **hidden** from `/resume` and the TUI picker; only the child's final message returns to the parent.
287
+ - `mode: subagent` agents are not offered in `/agent` — they are launch targets, not session personas.
288
+ - The `general` built-in ships as a subagent, so delegation works with zero files; shadow or repurpose it with `.agents/agents/general.md` like any built-in.
289
+
290
+ ```markdown
291
+ ---
292
+ description: Probes one file and summarizes it
293
+ mode: subagent
294
+ tools: [read]
295
+ ---
296
+ You are a probe. Read exactly one file and summarize it in three lines.
297
+ ```
298
+
299
+ Usage: `/agent` lists loaded agents (`build` and `plan` plus your file agents), `/agent reviewer` switches.
300
+
301
+ The body is the agent's system prompt: it **replaces** the base prompt, and AGENTS.md plus the skills advertisement are still added on top. A permission override replaces the tool's default — a session `always` grant still wins over it, and the outside-project guard still forces an ask.
302
+
303
+ Usage: `/agent` lists loaded agents (`build` and `plan` plus your file agents), `/agent reviewer` switches. Switching works mid-session — history is kept and the next turn runs with the new prompt, model and toolset; the switch line confirms provider/model/toolset, and the `# Tools` section of the prompt is filtered to match. An agent's `model` applies at startup (`defaultAgent`) and on `/resume`, not only when switched to. `/resume` restores the agent together with provider and model; `defaultAgent` picks the startup agent and defaults to `build` — an unknown id warns and falls back to `build`. Because `build` reuses the base prompt, the default system prefix stays byte-identical to a session with no agent (and sessions created before agents existed keep the base prompt). Bad frontmatter never breaks startup: an empty body drops that agent, and an unknown tool id or a `model` without `provider/` warns and falls back.
304
+
305
+ **Index:** [`docs/architecture/prompt-and-agents.md`](docs/architecture/prompt-and-agents.md) (the `# Subagents` catalog) · [`docs/architecture/turn-flow.md`](docs/architecture/turn-flow.md) (the child branch in the loop) · [`docs/decisions/agents.md`](docs/decisions/agents.md) (why one tool, why depth 1) · [`docs/future-features.md`](docs/future-features.md) § Agents (background subagents, deferred).
306
+
307
+ ## Permissions & yolo
308
+
309
+ Every tool has a default (`allow` / `ask`, table above). During a turn the model may trigger an inline dialog:
310
+
311
+ - Answer `y` (once), `n` (deny — the model sees the refusal), or `a` (always this session, for that tool+resource). Anything else — including Enter — denies.
312
+ - Any resource outside the project root (absolute path, `..`, `~`) forces `ask` even for `allow` tools.
313
+
314
+ **Yolo mode** (TUI only — `--plain` REPL has no equivalent): `/permissions` → pick `yolo` and every tool call is auto-approved — no dialogs, **including outside-project paths**. The status bar shows a red `YOLO` while active. The choice is **persisted to the global config** (`permissionMode` in `~/.config/lugent/config.json`) and applied at startup; a `permissionMode` in the project's `lugent.json` overrides it while present (first-file-wins, like `providers`). Yolo does not modify the per-session `a` grants. `/permissions` → `normal` (or edit the config) turns it off.
315
+
316
+ ## Data
317
+
318
+ Sessions, messages and permission grants live in one SQLite file:
319
+
320
+ - Linux: `$XDG_DATA_HOME/lugent/db.sqlite` or `~/.local/share/lugent/db.sqlite`
321
+ - Windows: `%APPDATA%\lugent\db.sqlite`
322
+
323
+ ## Development
324
+
325
+ ```
326
+ bun test
327
+ ```
328
+
329
+ The suite includes a golden snapshot pinning the system prompt byte-for-byte (provider prefix-cache stability) and an end-to-end turn with tool calls.
330
+
331
+ Out-of-scope features and their triggers: [docs/future-features.md](docs/future-features.md) · Architecture: [docs/architecture.md](docs/architecture.md)
package/package.json CHANGED
@@ -1,6 +1,30 @@
1
1
  {
2
2
  "name": "lugent",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "type": "module",
5
+ "bin": {
6
+ "lugent": "./src/main.ts"
7
+ },
8
+ "scripts": {
9
+ "test": "bun test",
10
+ "start": "bun src/main.ts",
11
+ "prepublishOnly": "bun test"
12
+ },
13
+ "files": [
14
+ "src",
15
+ "README.md",
16
+ "LICENSE"
17
+ ],
18
+ "engines": {
19
+ "bun": ">=1.4"
20
+ },
21
+ "dependencies": {
22
+ "ink": "^8.0.0",
23
+ "react": "^19.3.1"
24
+ },
25
+ "devDependencies": {
26
+ "@types/bun": "^1.4.4",
27
+ "@types/react": "^19.3.0",
28
+ "bun-types": "^1.4.4"
29
+ }
30
+ }
@@ -0,0 +1,50 @@
1
+ import { test, expect } from "bun:test"
2
+ import { parseSlash, parseAnswer, mcpActions, formatMcpState } from "./commands"
3
+
4
+ test("plain message is not a command", () => {
5
+ expect(parseSlash("hello world")).toBeNull()
6
+ })
7
+
8
+ test("slash command with args", () => {
9
+ expect(parseSlash("/model local qwen")).toEqual({ cmd: "model", args: ["local", "qwen"] })
10
+ })
11
+
12
+ test("slash command without args", () => {
13
+ expect(parseSlash("/skills")).toEqual({ cmd: "skills", args: [] })
14
+ })
15
+
16
+ test("parseAnswer: only explicit y/a allows (review F3)", () => {
17
+ expect(parseAnswer("y")).toBe("y")
18
+ expect(parseAnswer("Y")).toBe("y")
19
+ expect(parseAnswer("yes")).toBe("y")
20
+ expect(parseAnswer("a")).toBe("a")
21
+ expect(parseAnswer("always")).toBe("a")
22
+ expect(parseAnswer("")).toBe("n") // Enter must NOT default to allow
23
+ expect(parseAnswer("n")).toBe("n")
24
+ expect(parseAnswer("garbage")).toBe("n")
25
+ expect(parseAnswer("\n")).toBe("n")
26
+ })
27
+
28
+ test("mcpActions: one action set per status, and logout only for a remote server", () => {
29
+ expect(mcpActions("connected", "remote")).toEqual(["logout", "disable"])
30
+ expect(mcpActions("connected", "local")).toEqual(["disable"])
31
+ expect(mcpActions("disabled", "remote")).toEqual(["enable"])
32
+ expect(mcpActions("failed", "remote")).toEqual(["reconnect", "disable"])
33
+ expect(mcpActions("auth_required", "remote")).toEqual(["authenticate", "disable"])
34
+ })
35
+
36
+ test("formatMcpState: tool count when connected, error when failed", () => {
37
+ expect(formatMcpState({ name: "a", type: "local", status: "connected", toolCount: 1 }))
38
+ .toBe("a (local) — connected, 1 tool")
39
+ expect(formatMcpState({ name: "b", type: "remote", status: "failed", error: "boom", toolCount: 0 }))
40
+ .toBe("b (remote) — failed: boom")
41
+ expect(formatMcpState({ name: "c", type: "remote", status: "disabled", toolCount: 0 }))
42
+ .toBe("c (remote) — disabled")
43
+ })
44
+
45
+ test("formatMcpState: auth_required reads as needs authentication", () => {
46
+ expect(formatMcpState({ name: "tf", type: "remote", status: "auth_required", toolCount: 0 }))
47
+ .toBe("tf (remote) — auth_required: needs authentication")
48
+ expect(formatMcpState({ name: "tf", type: "remote", status: "auth_required", error: "denied", toolCount: 0 }))
49
+ .toBe("tf (remote) — auth_required: needs authentication — denied")
50
+ })
@@ -0,0 +1,34 @@
1
+ import type { McpServerState, McpServerStatus } from "../core/mcp/manager"
2
+
3
+ export function parseSlash(input: string): { cmd: string; args: string[] } | null {
4
+ if (!input.startsWith("/")) return null
5
+ const [cmd, ...args] = input.slice(1).trim().split(/\s+/)
6
+ return { cmd, args }
7
+ }
8
+
9
+ /** Permission prompt answer: only explicit y/a allows — everything else denies (F3). */
10
+ export function parseAnswer(answer: string): "y" | "n" | "a" {
11
+ const c = answer.trim().toLowerCase()
12
+ if (c === "a" || c === "always") return "a"
13
+ if (c === "y" || c === "yes") return "y"
14
+ return "n"
15
+ }
16
+
17
+ export type McpAction = "authenticate" | "logout" | "reconnect" | "disable" | "enable"
18
+
19
+ /** The actions a row offers, by status — the single source for REPL and TUI. */
20
+ export function mcpActions(status: McpServerStatus, type: "local" | "remote"): McpAction[] {
21
+ // logout is meaningless for a spawned child: only a remote server has a token.
22
+ if (status === "connected") return type === "remote" ? ["logout", "disable"] : ["disable"]
23
+ if (status === "disabled") return ["enable"]
24
+ if (status === "auth_required") return ["authenticate", "disable"]
25
+ return ["reconnect", "disable"] // failed, or mid-connect
26
+ }
27
+
28
+ export function formatMcpState(s: McpServerState): string {
29
+ const base = `${s.name} (${s.type}) — ${s.status}`
30
+ if (s.status === "connected") return `${base}, ${s.toolCount} tool${s.toolCount === 1 ? "" : "s"}`
31
+ if (s.status === "auth_required") return s.error ? `${base}: needs authentication — ${s.error}` : `${base}: needs authentication`
32
+ if (s.error) return `${base}: ${s.error}`
33
+ return base
34
+ }
@@ -0,0 +1,56 @@
1
+ import { test, expect } from "bun:test"
2
+ import { buildPromptInput } from "./context"
3
+ import type { ToolDef } from "../core/tools/registry"
4
+
5
+ const readTool: ToolDef = {
6
+ name: "read", description: "Read a file", schema: { type: "object", properties: {} },
7
+ permission: "allow", execute: async () => ({ output: "" }),
8
+ }
9
+ const shellTool: ToolDef = {
10
+ name: "shell", description: "Run a command", schema: { type: "object", properties: {} },
11
+ permission: "ask", execute: async () => ({ output: "" }),
12
+ }
13
+
14
+ const env = { projectRoot: "/tmp/opencode", skills: [], tools: [readTool, shellTool] } as any
15
+
16
+ test("the agent's toolset filter also applies to the # Tools section", () => {
17
+ const p = buildPromptInput(env, "m", { id: "r", prompt: "p", tools: ["read"] })
18
+ expect(p.tools.map(t => t.name)).toEqual(["read"])
19
+ })
20
+
21
+ test("no agent keeps every tool in the prompt", () => {
22
+ expect(buildPromptInput(env, "m").tools.map(t => t.name)).toEqual(["read", "shell"])
23
+ })
24
+
25
+ test("an agent that cannot use the skill tool gets no skills advertisement", () => {
26
+ const withSkill = { ...env, skills: [{ id: "rel", name: "Rel", description: "Releases", file: "/x" }] }
27
+ const p = buildPromptInput(withSkill, "m", { id: "r", prompt: "p", tools: ["read"] })
28
+ expect(p.skills).toEqual([])
29
+ })
30
+
31
+ test("no agent keeps the skills advertisement", () => {
32
+ const withSkill = { ...env, skills: [{ id: "rel", name: "Rel", description: "Releases", file: "/x" }] }
33
+ expect(buildPromptInput(withSkill, "m").skills.length).toBe(1)
34
+ })
35
+
36
+ const subagentTool: ToolDef = {
37
+ name: "subagent", description: "Launch a subagent", schema: { type: "object", properties: {} },
38
+ permission: "ask", execute: async () => ({ output: "" }),
39
+ }
40
+
41
+ test("the subagent catalog is advertised when the subagent tool is in the toolset", () => {
42
+ const withSub = {
43
+ ...env,
44
+ tools: [readTool, shellTool, subagentTool],
45
+ agents: [
46
+ { id: "build", prompt: "b" },
47
+ { id: "reviewer", prompt: "r", mode: "subagent", description: "Reviews" },
48
+ ],
49
+ }
50
+ expect(buildPromptInput(withSub, "m").subagents).toEqual([{ id: "reviewer", description: "Reviews" }])
51
+ })
52
+
53
+ test("no catalog when the agent withholds the subagent tool", () => {
54
+ const noTool = { ...env, agents: [{ id: "reviewer", prompt: "r", mode: "subagent", description: "Reviews" }] }
55
+ expect(buildPromptInput(noTool, "m").subagents).toEqual([])
56
+ })
@@ -0,0 +1,34 @@
1
+ import { readFileSync } from "node:fs"
2
+ import type { PromptInput } from "../core/prompt"
3
+ import type { Agent } from "../core/types"
4
+ import { toolsForAgent, subagentsFor } from "../core/agents"
5
+ import type { ReplEnv } from "./repl"
6
+
7
+ export function safeBranch(root: string): string | null {
8
+ try {
9
+ return readFileSync(`${root}/.git/HEAD`, "utf8").replace("ref: refs/heads/", "").trim()
10
+ } catch { return null }
11
+ }
12
+
13
+ export function safeAgentsMd(root: string): string | null {
14
+ try { return readFileSync(`${root}/AGENTS.md`, "utf8") } catch { return null }
15
+ }
16
+
17
+ export function buildPromptInput(env: ReplEnv, _model: string, agent?: Agent): PromptInput {
18
+ const tools = toolsForAgent(env.tools, agent)
19
+ return {
20
+ cwd: env.projectRoot,
21
+ os: process.platform,
22
+ gitBranch: safeBranch(env.projectRoot),
23
+ date: new Date().toISOString().slice(0, 10),
24
+ agentsMd: safeAgentsMd(env.projectRoot),
25
+ // An agent that excluded the skill tool must not be told skills exist (no agent → unchanged).
26
+ skills: agent && !tools.some(t => t.name === "skill") ? [] : env.skills,
27
+ tools: tools.map(t => ({ name: t.name, description: t.description, schema: t.schema })),
28
+ agentPrompt: agent?.prompt,
29
+ // Only tell the model subagents exist when it actually has the tool to launch them.
30
+ subagents: tools.some(t => t.name === "subagent")
31
+ ? subagentsFor(env.agents ?? []).map(a => ({ id: a.id, description: a.description }))
32
+ : [],
33
+ }
34
+ }