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/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(), ""