noah-code 0.1.0__py3-none-any.whl
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.
- noah_code/__init__.py +3 -0
- noah_code/__main__.py +6 -0
- noah_code/agent.py +378 -0
- noah_code/approvals.py +105 -0
- noah_code/cli.py +422 -0
- noah_code/commands.py +70 -0
- noah_code/config.py +279 -0
- noah_code/custom_commands.py +103 -0
- noah_code/event_bridge.py +132 -0
- noah_code/events.py +27 -0
- noah_code/host.py +662 -0
- noah_code/macos_sandbox.py +142 -0
- noah_code/mcp_setup.py +91 -0
- noah_code/permissions.py +400 -0
- noah_code/sessions.py +157 -0
- noah_code/skills_setup.py +51 -0
- noah_code/snapshots.py +313 -0
- noah_code/tools/__init__.py +6 -0
- noah_code/tools/git_tools.py +44 -0
- noah_code/tools/workspace_tools.py +269 -0
- noah_code/ui/__init__.py +6 -0
- noah_code/ui/console.py +88 -0
- noah_code/ui/protocol.py +33 -0
- noah_code/ui/textual.css +9 -0
- noah_code/ui/textual_app.py +435 -0
- noah_code/updates.py +184 -0
- noah_code/workspace.py +49 -0
- noah_code-0.1.0.dist-info/METADATA +173 -0
- noah_code-0.1.0.dist-info/RECORD +31 -0
- noah_code-0.1.0.dist-info/WHEEL +4 -0
- noah_code-0.1.0.dist-info/entry_points.txt +4 -0
noah_code/cli.py
ADDED
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
"""CLI entry point for Noah Code."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import sys
|
|
7
|
+
from typing import Any, Literal
|
|
8
|
+
|
|
9
|
+
import click
|
|
10
|
+
|
|
11
|
+
from noah_code import __version__
|
|
12
|
+
from noah_code.config import config_sources, load_config
|
|
13
|
+
from noah_code.host import AgentHost
|
|
14
|
+
from noah_code.sessions import SessionError, SessionStore
|
|
15
|
+
from noah_code.ui.console import ConsoleUI
|
|
16
|
+
from noah_code.updates import UpdateError, check_for_update, maybe_auto_update, upgrade
|
|
17
|
+
from noah_code.workspace import WorkspaceError, open_workspace
|
|
18
|
+
|
|
19
|
+
EXIT_OK = 0
|
|
20
|
+
EXIT_AGENT = 1
|
|
21
|
+
EXIT_CONFIG = 2
|
|
22
|
+
EXIT_DENIED = 3
|
|
23
|
+
EXIT_SIGINT = 130
|
|
24
|
+
|
|
25
|
+
SUBCOMMANDS = frozenset({"run", "sessions", "doctor", "config", "update"})
|
|
26
|
+
|
|
27
|
+
_AUTO_UPDATE_CHECKED = False
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_async(coro): # noqa: ANN001
|
|
31
|
+
return asyncio.run(coro)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _common_options(fn): # noqa: ANN001
|
|
35
|
+
fn = click.option(
|
|
36
|
+
"--unsafe-inprocess-code-execution",
|
|
37
|
+
is_flag=True,
|
|
38
|
+
help="Disable the OS sandbox (unsafe; intended only for trusted development tests)",
|
|
39
|
+
)(fn)
|
|
40
|
+
fn = click.option("--mode", type=click.Choice(["build", "plan"]), default=None)(fn)
|
|
41
|
+
fn = click.option(
|
|
42
|
+
"--auto", is_flag=True, help="Auto-approve ask decisions (never overrides deny)"
|
|
43
|
+
)(fn)
|
|
44
|
+
fn = click.option("--model", "model", default=None, help="Override model alias")(fn)
|
|
45
|
+
return fn
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@click.command("noah-code")
|
|
49
|
+
@click.version_option(__version__, prog_name="noah-code")
|
|
50
|
+
@click.argument("path", required=False, type=click.Path())
|
|
51
|
+
@click.option("--continue", "continue_session", is_flag=True, help="Resume latest session")
|
|
52
|
+
@click.option("--session", "session_id", default=None, help="Resume a specific session id")
|
|
53
|
+
@click.option(
|
|
54
|
+
"--console",
|
|
55
|
+
"use_console",
|
|
56
|
+
is_flag=True,
|
|
57
|
+
help="Use line-oriented console UI instead of the Textual TUI",
|
|
58
|
+
)
|
|
59
|
+
@_common_options
|
|
60
|
+
def interactive_cmd(
|
|
61
|
+
path: str | None,
|
|
62
|
+
model: str | None,
|
|
63
|
+
auto: bool,
|
|
64
|
+
mode: str | None,
|
|
65
|
+
continue_session: bool,
|
|
66
|
+
session_id: str | None,
|
|
67
|
+
use_console: bool,
|
|
68
|
+
unsafe_inprocess_code_execution: bool,
|
|
69
|
+
) -> None:
|
|
70
|
+
"""Start an interactive coding session (default PATH = cwd).
|
|
71
|
+
|
|
72
|
+
Default UI is the Textual TUI. Pass --console for the classic line UI.
|
|
73
|
+
"""
|
|
74
|
+
code = _run_async(
|
|
75
|
+
_interactive(
|
|
76
|
+
path=path,
|
|
77
|
+
model=model,
|
|
78
|
+
auto=auto,
|
|
79
|
+
continue_session=continue_session,
|
|
80
|
+
session_id=session_id,
|
|
81
|
+
mode=mode,
|
|
82
|
+
use_console=use_console,
|
|
83
|
+
unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
raise SystemExit(code)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@click.group(context_settings={"help_option_names": ["-h", "--help"]})
|
|
90
|
+
@click.version_option(__version__, prog_name="noah-code")
|
|
91
|
+
def cli_group() -> None:
|
|
92
|
+
"""noah-code - terminal coding harness on NVIDIA OO Agents."""
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
@cli_group.command("run")
|
|
96
|
+
@click.argument("prompt")
|
|
97
|
+
@click.argument("path", required=False, type=click.Path())
|
|
98
|
+
@_common_options
|
|
99
|
+
@click.option("--session", "session_id", default=None)
|
|
100
|
+
def run_cmd(
|
|
101
|
+
prompt: str,
|
|
102
|
+
path: str | None,
|
|
103
|
+
model: str | None,
|
|
104
|
+
auto: bool,
|
|
105
|
+
mode: str | None,
|
|
106
|
+
session_id: str | None,
|
|
107
|
+
unsafe_inprocess_code_execution: bool,
|
|
108
|
+
) -> None:
|
|
109
|
+
"""Non-interactive one-shot execution."""
|
|
110
|
+
code = _run_async(
|
|
111
|
+
_run_once(
|
|
112
|
+
prompt=prompt,
|
|
113
|
+
path=path,
|
|
114
|
+
model=model,
|
|
115
|
+
auto=auto,
|
|
116
|
+
mode=mode,
|
|
117
|
+
session_id=session_id,
|
|
118
|
+
unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
|
|
119
|
+
)
|
|
120
|
+
)
|
|
121
|
+
raise SystemExit(code)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
@cli_group.group("sessions")
|
|
125
|
+
def sessions_group() -> None:
|
|
126
|
+
"""Manage persisted sessions."""
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@sessions_group.command("list")
|
|
130
|
+
@click.argument("path", required=False, type=click.Path())
|
|
131
|
+
def sessions_list(path: str | None) -> None:
|
|
132
|
+
try:
|
|
133
|
+
workspace = open_workspace(path)
|
|
134
|
+
config = load_config(workspace.root)
|
|
135
|
+
store = SessionStore(config.session_dir)
|
|
136
|
+
for s in store.list_sessions(workspace):
|
|
137
|
+
click.echo(f"{s.session_id}\t{s.mode}\t{s.model}\t{s.title}\t{s.workspace_path}")
|
|
138
|
+
except (WorkspaceError, SessionError) as exc:
|
|
139
|
+
click.echo(f"error: {exc}", err=True)
|
|
140
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@sessions_group.command("show")
|
|
144
|
+
@click.argument("session_id")
|
|
145
|
+
def sessions_show(session_id: str) -> None:
|
|
146
|
+
try:
|
|
147
|
+
workspace = open_workspace(".")
|
|
148
|
+
config = load_config(workspace.root)
|
|
149
|
+
store = SessionStore(config.session_dir)
|
|
150
|
+
meta = store.load_meta(session_id)
|
|
151
|
+
click.echo(meta.to_json())
|
|
152
|
+
except (WorkspaceError, SessionError) as exc:
|
|
153
|
+
click.echo(f"error: {exc}", err=True)
|
|
154
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
@sessions_group.command("delete")
|
|
158
|
+
@click.argument("session_id")
|
|
159
|
+
def sessions_delete(session_id: str) -> None:
|
|
160
|
+
try:
|
|
161
|
+
workspace = open_workspace(".")
|
|
162
|
+
config = load_config(workspace.root)
|
|
163
|
+
store = SessionStore(config.session_dir)
|
|
164
|
+
store.delete(session_id)
|
|
165
|
+
click.echo(f"deleted {session_id}")
|
|
166
|
+
except (WorkspaceError, SessionError) as exc:
|
|
167
|
+
click.echo(f"error: {exc}", err=True)
|
|
168
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
@cli_group.command("doctor")
|
|
172
|
+
@click.argument("path", required=False, type=click.Path())
|
|
173
|
+
def doctor(path: str | None) -> None:
|
|
174
|
+
"""Diagnostics for workspace, config, and model resolution."""
|
|
175
|
+
try:
|
|
176
|
+
workspace = open_workspace(path)
|
|
177
|
+
except WorkspaceError as exc:
|
|
178
|
+
click.echo(f"workspace: FAIL ({exc})", err=True)
|
|
179
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
180
|
+
click.echo(f"workspace: ok ({workspace.root})")
|
|
181
|
+
config = load_config(workspace.root)
|
|
182
|
+
sources = config_sources(workspace.root)
|
|
183
|
+
click.echo(f"user config: {sources['user'] or '(none)'}")
|
|
184
|
+
click.echo(f"project config: {sources['project'] or '(none)'}")
|
|
185
|
+
click.echo(f"model: {config.model}")
|
|
186
|
+
click.echo(f"session_dir: {config.session_dir}")
|
|
187
|
+
click.echo(f"ui frontend: {config.ui.frontend}")
|
|
188
|
+
try:
|
|
189
|
+
from nooa.unifiedllm import get_llm_client
|
|
190
|
+
|
|
191
|
+
client = get_llm_client(config.model)
|
|
192
|
+
click.echo(f"llm client: {type(client).__name__} model={getattr(client, 'model', '?')}")
|
|
193
|
+
except Exception as exc: # noqa: BLE001
|
|
194
|
+
click.echo(f"llm client: FAIL ({exc})", err=True)
|
|
195
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
196
|
+
try:
|
|
197
|
+
import textual # noqa: F401
|
|
198
|
+
|
|
199
|
+
click.echo("textual: ok")
|
|
200
|
+
except ImportError as exc:
|
|
201
|
+
click.echo(f"textual: FAIL ({exc})", err=True)
|
|
202
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
203
|
+
click.echo("doctor: ok")
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
@cli_group.command("config")
|
|
207
|
+
@click.argument("action", type=click.Choice(["show"]))
|
|
208
|
+
@click.argument("path", required=False, type=click.Path())
|
|
209
|
+
@click.option("--model", default=None)
|
|
210
|
+
@click.option("--auto", is_flag=True)
|
|
211
|
+
def config_cmd(action: str, path: str | None, model: str | None, auto: bool) -> None:
|
|
212
|
+
"""Show resolved configuration."""
|
|
213
|
+
try:
|
|
214
|
+
workspace = open_workspace(path)
|
|
215
|
+
except WorkspaceError as exc:
|
|
216
|
+
click.echo(f"error: {exc}", err=True)
|
|
217
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
218
|
+
overrides: dict[str, Any] = {}
|
|
219
|
+
if model:
|
|
220
|
+
overrides["model"] = model
|
|
221
|
+
if auto:
|
|
222
|
+
overrides["auto_approve"] = True
|
|
223
|
+
config = load_config(workspace.root, cli_overrides=overrides)
|
|
224
|
+
click.echo(config.model_dump_json(indent=2))
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
@cli_group.command("update")
|
|
228
|
+
@click.option("--check", "check_only", is_flag=True, help="Check without installing")
|
|
229
|
+
@click.option("--force", is_flag=True, help="Run the package upgrade even if already current")
|
|
230
|
+
def update_cmd(check_only: bool, force: bool) -> None:
|
|
231
|
+
"""Check for and install the latest noah-code release."""
|
|
232
|
+
try:
|
|
233
|
+
status = check_for_update()
|
|
234
|
+
if not status.available and not force:
|
|
235
|
+
click.echo(f"noah-code {status.current} is up to date")
|
|
236
|
+
return
|
|
237
|
+
if check_only:
|
|
238
|
+
if status.available:
|
|
239
|
+
click.echo(f"update available: {status.current} -> {status.latest}")
|
|
240
|
+
else:
|
|
241
|
+
click.echo(f"noah-code {status.current} is up to date")
|
|
242
|
+
return
|
|
243
|
+
click.echo(upgrade())
|
|
244
|
+
click.echo(f"noah-code update complete; latest release is {status.latest}")
|
|
245
|
+
except UpdateError as exc:
|
|
246
|
+
click.echo(f"update failed: {exc}", err=True)
|
|
247
|
+
raise SystemExit(EXIT_CONFIG) from exc
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
async def _maybe_auto_update(config: Any) -> bool:
|
|
251
|
+
global _AUTO_UPDATE_CHECKED
|
|
252
|
+
if _AUTO_UPDATE_CHECKED or not config.updates.auto_install:
|
|
253
|
+
return False
|
|
254
|
+
_AUTO_UPDATE_CHECKED = True
|
|
255
|
+
message = await asyncio.to_thread(
|
|
256
|
+
maybe_auto_update,
|
|
257
|
+
interval_hours=config.updates.interval_hours,
|
|
258
|
+
timeout=config.updates.check_timeout_seconds,
|
|
259
|
+
)
|
|
260
|
+
if message:
|
|
261
|
+
click.echo(message, err=True)
|
|
262
|
+
return True
|
|
263
|
+
return False
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
async def _prepare(
|
|
267
|
+
*,
|
|
268
|
+
path: str | None,
|
|
269
|
+
model: str | None,
|
|
270
|
+
auto: bool,
|
|
271
|
+
mode: str | None,
|
|
272
|
+
continue_session: bool = False,
|
|
273
|
+
session_id: str | None = None,
|
|
274
|
+
frontend: Literal["tui", "console"] | None = None,
|
|
275
|
+
unsafe_inprocess_code_execution: bool = False,
|
|
276
|
+
):
|
|
277
|
+
try:
|
|
278
|
+
workspace = open_workspace(path)
|
|
279
|
+
except WorkspaceError as exc:
|
|
280
|
+
click.echo(f"error: {exc}", err=True)
|
|
281
|
+
return None, EXIT_CONFIG
|
|
282
|
+
|
|
283
|
+
overrides: dict[str, Any] = {}
|
|
284
|
+
if model:
|
|
285
|
+
overrides["model"] = model
|
|
286
|
+
if auto:
|
|
287
|
+
overrides["auto_approve"] = True
|
|
288
|
+
if mode:
|
|
289
|
+
overrides["mode"] = mode
|
|
290
|
+
if frontend is not None:
|
|
291
|
+
overrides["ui"] = {"frontend": frontend}
|
|
292
|
+
if unsafe_inprocess_code_execution:
|
|
293
|
+
overrides["unsafe_inprocess_code_execution"] = True
|
|
294
|
+
config = load_config(workspace.root, cli_overrides=overrides)
|
|
295
|
+
if await _maybe_auto_update(config):
|
|
296
|
+
return None, EXIT_OK
|
|
297
|
+
store = SessionStore(config.session_dir)
|
|
298
|
+
|
|
299
|
+
meta = None
|
|
300
|
+
try:
|
|
301
|
+
if session_id:
|
|
302
|
+
meta = store.load_meta(session_id)
|
|
303
|
+
store.verify_workspace(meta, workspace)
|
|
304
|
+
elif continue_session:
|
|
305
|
+
meta = store.latest_for_workspace(workspace)
|
|
306
|
+
if meta is None:
|
|
307
|
+
click.echo("error: no prior session for this workspace", err=True)
|
|
308
|
+
return None, EXIT_CONFIG
|
|
309
|
+
if meta is not None and model is not None:
|
|
310
|
+
meta.model = config.model
|
|
311
|
+
store.save_meta(meta)
|
|
312
|
+
except SessionError as exc:
|
|
313
|
+
click.echo(f"error: {exc}", err=True)
|
|
314
|
+
return None, EXIT_CONFIG
|
|
315
|
+
|
|
316
|
+
return (workspace, config, store, meta), EXIT_OK
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
async def _interactive(
|
|
320
|
+
*,
|
|
321
|
+
path: str | None,
|
|
322
|
+
model: str | None,
|
|
323
|
+
auto: bool,
|
|
324
|
+
continue_session: bool,
|
|
325
|
+
session_id: str | None,
|
|
326
|
+
mode: str | None,
|
|
327
|
+
use_console: bool,
|
|
328
|
+
unsafe_inprocess_code_execution: bool,
|
|
329
|
+
) -> int:
|
|
330
|
+
frontend: Literal["tui", "console"] | None = "console" if use_console else None
|
|
331
|
+
prepared, code = await _prepare(
|
|
332
|
+
path=path,
|
|
333
|
+
model=model,
|
|
334
|
+
auto=auto,
|
|
335
|
+
mode=mode,
|
|
336
|
+
continue_session=continue_session,
|
|
337
|
+
session_id=session_id,
|
|
338
|
+
frontend=frontend,
|
|
339
|
+
unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
|
|
340
|
+
)
|
|
341
|
+
if prepared is None:
|
|
342
|
+
return code
|
|
343
|
+
workspace, config, store, meta = prepared
|
|
344
|
+
use_tui = config.ui.frontend == "tui" and not use_console
|
|
345
|
+
if use_tui:
|
|
346
|
+
host = AgentHost(workspace, config, session_meta=meta, store=store)
|
|
347
|
+
try:
|
|
348
|
+
return await host.run_tui()
|
|
349
|
+
except RuntimeError as exc:
|
|
350
|
+
click.echo(f"error: {exc}", err=True)
|
|
351
|
+
return EXIT_CONFIG
|
|
352
|
+
except KeyboardInterrupt:
|
|
353
|
+
return EXIT_SIGINT
|
|
354
|
+
host = AgentHost(
|
|
355
|
+
workspace,
|
|
356
|
+
config,
|
|
357
|
+
session_meta=meta,
|
|
358
|
+
store=store,
|
|
359
|
+
ui=ConsoleUI(markdown=config.ui.markdown),
|
|
360
|
+
)
|
|
361
|
+
try:
|
|
362
|
+
return await host.run_interactive()
|
|
363
|
+
except KeyboardInterrupt:
|
|
364
|
+
return EXIT_SIGINT
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
async def _run_once(
|
|
368
|
+
*,
|
|
369
|
+
prompt: str,
|
|
370
|
+
path: str | None,
|
|
371
|
+
model: str | None,
|
|
372
|
+
auto: bool,
|
|
373
|
+
mode: str | None,
|
|
374
|
+
session_id: str | None,
|
|
375
|
+
unsafe_inprocess_code_execution: bool,
|
|
376
|
+
) -> int:
|
|
377
|
+
prepared, code = await _prepare(
|
|
378
|
+
path=path,
|
|
379
|
+
model=model,
|
|
380
|
+
auto=auto,
|
|
381
|
+
mode=mode,
|
|
382
|
+
session_id=session_id,
|
|
383
|
+
frontend="console",
|
|
384
|
+
unsafe_inprocess_code_execution=unsafe_inprocess_code_execution,
|
|
385
|
+
)
|
|
386
|
+
if prepared is None:
|
|
387
|
+
return code
|
|
388
|
+
workspace, config, store, meta = prepared
|
|
389
|
+
host = AgentHost(
|
|
390
|
+
workspace,
|
|
391
|
+
config,
|
|
392
|
+
session_meta=meta,
|
|
393
|
+
store=store,
|
|
394
|
+
ui=ConsoleUI(markdown=config.ui.markdown),
|
|
395
|
+
)
|
|
396
|
+
result = await host.run_once(prompt)
|
|
397
|
+
return result.exit_code
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
def _launched_as_nc() -> bool:
|
|
401
|
+
"""Detect whether the process was launched as `nc` or `noah`."""
|
|
402
|
+
import os
|
|
403
|
+
|
|
404
|
+
name = os.path.basename(sys.argv[0]) if sys.argv else ""
|
|
405
|
+
return name in {"nc", "nc.exe", "noah", "noah.exe"}
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def main(argv: list[str] | None = None) -> None:
|
|
409
|
+
"""Dispatch: subcommands via group; otherwise interactive with optional PATH."""
|
|
410
|
+
args = list(sys.argv[1:] if argv is None else argv)
|
|
411
|
+
import os
|
|
412
|
+
|
|
413
|
+
base = os.path.basename(sys.argv[0]) if sys.argv else "noah-code"
|
|
414
|
+
prog = base if base in {"nc", "noah", "noah-code"} else "noah-code"
|
|
415
|
+
if args and args[0] in SUBCOMMANDS:
|
|
416
|
+
cli_group.main(args=args, prog_name=prog)
|
|
417
|
+
else:
|
|
418
|
+
interactive_cmd.main(args=args, prog_name=prog)
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
if __name__ == "__main__":
|
|
422
|
+
main()
|
noah_code/commands.py
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"""Slash-command and host command helpers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
from noah_code.custom_commands import CustomCommand
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass
|
|
11
|
+
class CommandSpec:
|
|
12
|
+
name: str
|
|
13
|
+
description: str
|
|
14
|
+
host_only: bool = False
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
BUILTIN_COMMANDS: list[CommandSpec] = [
|
|
18
|
+
CommandSpec("help", "Show available commands", host_only=True),
|
|
19
|
+
CommandSpec("mode", "Switch mode: /mode build|plan", host_only=True),
|
|
20
|
+
CommandSpec("model", "Show or set model: /model [MODEL]", host_only=True),
|
|
21
|
+
CommandSpec("session", "Show current session", host_only=True),
|
|
22
|
+
CommandSpec("sessions", "List or pick sessions", host_only=True),
|
|
23
|
+
CommandSpec("new", "Start a new session", host_only=True),
|
|
24
|
+
CommandSpec("continue", "Resume most recent session", host_only=True),
|
|
25
|
+
CommandSpec("compact", "Trigger history summarization", host_only=True),
|
|
26
|
+
CommandSpec("todos", "Show todo list", host_only=True),
|
|
27
|
+
CommandSpec("status", "Show mode/model/session/context", host_only=True),
|
|
28
|
+
CommandSpec("diff", "Show git diff", host_only=True),
|
|
29
|
+
CommandSpec("undo", "Undo last WorkspaceTools turn", host_only=True),
|
|
30
|
+
CommandSpec("redo", "Redo last undone turn", host_only=True),
|
|
31
|
+
CommandSpec("skills", "Show discovered/activated skills", host_only=True),
|
|
32
|
+
CommandSpec("trace", "Show tracing destination", host_only=True),
|
|
33
|
+
CommandSpec("exit", "Exit Noah Code", host_only=True),
|
|
34
|
+
]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def help_text(custom: dict[str, CustomCommand] | None = None) -> str:
|
|
38
|
+
lines = ["Noah Code commands:", ""]
|
|
39
|
+
for cmd in BUILTIN_COMMANDS:
|
|
40
|
+
lines.append(f" /{cmd.name:<12} {cmd.description}")
|
|
41
|
+
if custom:
|
|
42
|
+
lines.append("")
|
|
43
|
+
lines.append("Custom commands:")
|
|
44
|
+
for name, c in sorted(custom.items()):
|
|
45
|
+
lines.append(f" /{name:<12} {c.description} ({c.source})")
|
|
46
|
+
lines.append("")
|
|
47
|
+
lines.append(
|
|
48
|
+
"File-journal undo only covers WorkspaceTools edits, not arbitrary shell mutations."
|
|
49
|
+
)
|
|
50
|
+
return "\n".join(lines)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def all_command_names(custom: dict[str, CustomCommand] | None = None) -> list[str]:
|
|
54
|
+
names = [f"/{c.name}" for c in BUILTIN_COMMANDS]
|
|
55
|
+
if custom:
|
|
56
|
+
names.extend(f"/{n}" for n in sorted(custom))
|
|
57
|
+
return names
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def parse_slash(text: str) -> tuple[str, str] | None:
|
|
61
|
+
stripped = text.strip()
|
|
62
|
+
if not stripped.startswith("/"):
|
|
63
|
+
return None
|
|
64
|
+
body = stripped[1:]
|
|
65
|
+
if not body:
|
|
66
|
+
return None
|
|
67
|
+
if " " in body:
|
|
68
|
+
name, rest = body.split(" ", 1)
|
|
69
|
+
return name.lower(), rest.strip()
|
|
70
|
+
return body.lower(), ""
|