agentkernel-cli 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.
Files changed (74) hide show
  1. agentkernel/__init__.py +7 -0
  2. agentkernel/__main__.py +5 -0
  3. agentkernel/agent.py +311 -0
  4. agentkernel/approval/__init__.py +23 -0
  5. agentkernel/approval/base.py +34 -0
  6. agentkernel/approval/cli.py +129 -0
  7. agentkernel/approval/policy.py +58 -0
  8. agentkernel/approval/risk.py +91 -0
  9. agentkernel/approval/sandbox.py +201 -0
  10. agentkernel/budget.py +64 -0
  11. agentkernel/checkpoint.py +50 -0
  12. agentkernel/cli.py +1482 -0
  13. agentkernel/config.py +224 -0
  14. agentkernel/context/__init__.py +17 -0
  15. agentkernel/context/manager.py +216 -0
  16. agentkernel/context/truncate.py +35 -0
  17. agentkernel/cron.py +146 -0
  18. agentkernel/curation.py +183 -0
  19. agentkernel/doctor.py +141 -0
  20. agentkernel/embeddings.py +132 -0
  21. agentkernel/evaluation.py +186 -0
  22. agentkernel/improvement.py +133 -0
  23. agentkernel/insights.py +141 -0
  24. agentkernel/kanban.py +114 -0
  25. agentkernel/knowledge.py +383 -0
  26. agentkernel/loops.py +145 -0
  27. agentkernel/mcp/__init__.py +23 -0
  28. agentkernel/mcp/client.py +181 -0
  29. agentkernel/mcp/config.py +59 -0
  30. agentkernel/mcp/tools.py +96 -0
  31. agentkernel/memory.py +1208 -0
  32. agentkernel/paths.py +73 -0
  33. agentkernel/plugins.py +76 -0
  34. agentkernel/profiles.py +70 -0
  35. agentkernel/progress.py +89 -0
  36. agentkernel/providers/__init__.py +35 -0
  37. agentkernel/providers/_http.py +157 -0
  38. agentkernel/providers/anthropic.py +282 -0
  39. agentkernel/providers/base.py +38 -0
  40. agentkernel/providers/credentials.py +65 -0
  41. agentkernel/providers/local.py +34 -0
  42. agentkernel/providers/openai.py +260 -0
  43. agentkernel/redaction.py +77 -0
  44. agentkernel/semantic_index.py +139 -0
  45. agentkernel/semantic_memory.py +253 -0
  46. agentkernel/skills.py +268 -0
  47. agentkernel/subagent.py +161 -0
  48. agentkernel/telemetry.py +199 -0
  49. agentkernel/templates/README.md +35 -0
  50. agentkernel/templates/SKILL.md +28 -0
  51. agentkernel/templates/eval-suite.toml +22 -0
  52. agentkernel/templates/loop.toml +29 -0
  53. agentkernel/templates/mcp-servers.toml +22 -0
  54. agentkernel/templates/profile.toml +29 -0
  55. agentkernel/templates/tool_module.py +64 -0
  56. agentkernel/tools/__init__.py +5 -0
  57. agentkernel/tools/base.py +100 -0
  58. agentkernel/tools/builtin/__init__.py +37 -0
  59. agentkernel/tools/builtin/checkpoint_tool.py +33 -0
  60. agentkernel/tools/builtin/clarify.py +60 -0
  61. agentkernel/tools/builtin/files.py +221 -0
  62. agentkernel/tools/builtin/kanban_tool.py +100 -0
  63. agentkernel/tools/builtin/search.py +225 -0
  64. agentkernel/tools/builtin/shell.py +67 -0
  65. agentkernel/tools/builtin/todo.py +106 -0
  66. agentkernel/tui/__init__.py +50 -0
  67. agentkernel/tui/app.py +594 -0
  68. agentkernel/types.py +127 -0
  69. agentkernel/worktree.py +64 -0
  70. agentkernel_cli-0.1.0.dist-info/METADATA +426 -0
  71. agentkernel_cli-0.1.0.dist-info/RECORD +74 -0
  72. agentkernel_cli-0.1.0.dist-info/WHEEL +4 -0
  73. agentkernel_cli-0.1.0.dist-info/entry_points.txt +2 -0
  74. agentkernel_cli-0.1.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,100 @@
1
+ """The `kanban` tool (design §18.3).
2
+
3
+ Exposes a shared work-queue board to the model so a long mission — or several
4
+ cooperating sub-agents — can file, claim, and complete tasks. The board is the
5
+ durable JSON store in ``kanban.py``; the tool is a thin, single-entry dispatcher
6
+ over it, bound to one board (and an optional worker identity) by the factory.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from agentkernel.kanban import render_task
14
+ from agentkernel.tools.base import ToolSpec
15
+ from agentkernel.types import ToolResult
16
+
17
+ if TYPE_CHECKING:
18
+ from agentkernel.kanban import Board
19
+
20
+
21
+ def _board_view(board: Board) -> str:
22
+ tasks = board.list()
23
+ if not tasks:
24
+ return "(board is empty)"
25
+ open_n = sum(1 for t in tasks if t.status in ("todo", "in_progress"))
26
+ lines = [f"Board ({open_n} open / {len(tasks)} total):"]
27
+ lines += [f" {render_task(t)}" for t in tasks]
28
+ return "\n".join(lines)
29
+
30
+
31
+ def kanban_tool(board: Board, *, worker: str = "agent") -> ToolSpec:
32
+ """Build the `kanban` tool over ``board``. ``worker`` labels who claims tasks."""
33
+
34
+ def kanban(args: dict) -> ToolResult:
35
+ action = args["action"]
36
+
37
+ if action == "list":
38
+ return ToolResult("", _board_view(board))
39
+ if action == "add":
40
+ title = (args.get("title") or "").strip()
41
+ if not title:
42
+ return ToolResult("", "add requires `title`.", is_error=True)
43
+ task = board.add(title)
44
+ return ToolResult("", f"Added {task.id}: {task.title}\n\n{_board_view(board)}")
45
+ if action == "next":
46
+ task = board.next_todo()
47
+ if task is None:
48
+ return ToolResult("", "No unclaimed tasks on the board.")
49
+ board.claim(task.id, worker)
50
+ return ToolResult("", f"Claimed {task.id}: {task.title}")
51
+
52
+ # The remaining actions target a specific task id.
53
+ task_id = args.get("id")
54
+ if not task_id:
55
+ return ToolResult("", f"{action} requires `id`.", is_error=True)
56
+ if action == "claim":
57
+ result = board.claim(task_id, args.get("assignee") or worker)
58
+ elif action == "complete":
59
+ result = board.complete(task_id)
60
+ elif action == "block":
61
+ result = board.block(task_id, args.get("reason") or "")
62
+ elif action == "comment":
63
+ text = (args.get("text") or "").strip()
64
+ if not text:
65
+ return ToolResult("", "comment requires `text`.", is_error=True)
66
+ result = board.comment(task_id, text)
67
+ else: # pragma: no cover - schema restricts the enum
68
+ return ToolResult("", f"unknown action {action!r}", is_error=True)
69
+
70
+ if result is None:
71
+ return ToolResult("", f"No task with id={task_id}.", is_error=True)
72
+ return ToolResult("", f"{render_task(result)}")
73
+
74
+ return ToolSpec(
75
+ name="kanban",
76
+ description=(
77
+ "Coordinate work on a shared task board. Use it to break a large job "
78
+ "into tasks and track them, or — as a worker — to pull and finish work. "
79
+ "Actions: list; add (title); next (claim the next todo); claim/complete/"
80
+ "block/comment (id, plus reason/text where relevant)."
81
+ ),
82
+ parameters={
83
+ "type": "object",
84
+ "properties": {
85
+ "action": {
86
+ "type": "string",
87
+ "enum": ["list", "add", "next", "claim", "complete", "block", "comment"],
88
+ },
89
+ "title": {"type": "string", "description": "Task title (for add)."},
90
+ "id": {"type": "string", "description": "Task id (claim/complete/block/comment)."},
91
+ "assignee": {"type": "string", "description": "Who claims it (default: you)."},
92
+ "reason": {"type": "string", "description": "Why it's blocked (for block)."},
93
+ "text": {"type": "string", "description": "Comment text (for comment)."},
94
+ },
95
+ "required": ["action"],
96
+ "additionalProperties": False,
97
+ },
98
+ handler=kanban,
99
+ category="coordination",
100
+ )
@@ -0,0 +1,225 @@
1
+ """Read-only discovery tools: find_files, search_text, file_info (design §6.3).
2
+
3
+ These let the model locate and inspect code without shelling out to `bash`, so
4
+ they work the same on every platform and inside the no-network Docker sandbox.
5
+ Like the file tools, every path is confined to the working directory and every
6
+ failure is returned as an error result, never raised (AGENT.md, design §8.3).
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+ from datetime import UTC, datetime
13
+ from pathlib import Path
14
+
15
+ from agentkernel.context.truncate import truncate_text
16
+ from agentkernel.tools.base import ToolSpec
17
+ from agentkernel.tools.builtin.files import resolve_within
18
+ from agentkernel.types import ToolResult
19
+
20
+ # Directories that are never worth walking for code search.
21
+ _SKIP_DIRS = {".git", ".venv", "__pycache__", "node_modules", ".agentkernel", ".pytest_cache"}
22
+
23
+
24
+ def _is_probably_binary(data: bytes) -> bool:
25
+ """A cheap heuristic: a NUL byte in the first chunk means "not text"."""
26
+ return b"\x00" in data[:1024]
27
+
28
+
29
+ def _iter_files(base: Path, pattern: str):
30
+ """Yield files under ``base`` matching ``pattern``, skipping noise dirs."""
31
+ for p in sorted(base.glob(pattern)):
32
+ if not p.is_file():
33
+ continue
34
+ if any(part in _SKIP_DIRS for part in p.parts):
35
+ continue
36
+ yield p
37
+
38
+
39
+ def search_tools(working_dir: str = ".", *, max_result_tokens: int = 4096) -> list[ToolSpec]:
40
+ """Build the read-only discovery toolset bound to ``working_dir``."""
41
+ root = Path(working_dir).resolve()
42
+
43
+ def _rel(p: Path) -> str:
44
+ return p.relative_to(root).as_posix()
45
+
46
+ def find_files(args: dict) -> ToolResult:
47
+ pattern = args["pattern"]
48
+ sub = args.get("path", ".")
49
+ try:
50
+ base = resolve_within(root, sub)
51
+ except ValueError as exc:
52
+ return ToolResult("", str(exc), is_error=True)
53
+ if not base.is_dir():
54
+ return ToolResult("", f"Not a directory: {sub!r}", is_error=True)
55
+ matches = []
56
+ for p in sorted(base.glob(pattern)):
57
+ if any(part in _SKIP_DIRS for part in p.relative_to(root).parts):
58
+ continue
59
+ matches.append(_rel(p) + ("/" if p.is_dir() else ""))
60
+ if not matches:
61
+ return ToolResult("", f"(no files match {pattern!r})")
62
+ return ToolResult("", truncate_text("\n".join(matches), max_result_tokens))
63
+
64
+ def search_text(args: dict) -> ToolResult:
65
+ pattern = args["pattern"]
66
+ glob = args.get("glob", "**/*")
67
+ sub = args.get("path", ".")
68
+ max_results = int(args.get("max_results", 100))
69
+ flags = re.IGNORECASE if args.get("ignore_case") else 0
70
+ try:
71
+ regex = re.compile(pattern, flags)
72
+ except re.error as exc:
73
+ return ToolResult("", f"Invalid regex {pattern!r}: {exc}", is_error=True)
74
+ try:
75
+ base = resolve_within(root, sub)
76
+ except ValueError as exc:
77
+ return ToolResult("", str(exc), is_error=True)
78
+ if not base.is_dir():
79
+ return ToolResult("", f"Not a directory: {sub!r}", is_error=True)
80
+
81
+ hits: list[str] = []
82
+ truncated = False
83
+ for f in _iter_files(base, glob):
84
+ try:
85
+ raw = f.read_bytes()
86
+ except OSError:
87
+ continue
88
+ if _is_probably_binary(raw):
89
+ continue
90
+ rel = _rel(f)
91
+ for lineno, line in enumerate(raw.decode("utf-8", "replace").splitlines(), 1):
92
+ if regex.search(line):
93
+ hits.append(f"{rel}:{lineno}: {line.strip()}")
94
+ if len(hits) >= max_results:
95
+ truncated = True
96
+ break
97
+ if truncated:
98
+ break
99
+ if not hits:
100
+ return ToolResult("", f"(no matches for {pattern!r})")
101
+ body = "\n".join(hits)
102
+ if truncated:
103
+ body += f"\n... (stopped at {max_results} matches)"
104
+ return ToolResult("", truncate_text(body, max_result_tokens))
105
+
106
+ def file_info(args: dict) -> ToolResult:
107
+ path = args["path"]
108
+ try:
109
+ target = resolve_within(root, path)
110
+ except ValueError as exc:
111
+ return ToolResult("", str(exc), is_error=True)
112
+ if not target.exists():
113
+ return ToolResult("", f"No such path: {path!r}", is_error=True)
114
+ st = target.stat()
115
+ modified = datetime.fromtimestamp(st.st_mtime, tz=UTC).isoformat(timespec="seconds")
116
+ kind = "directory" if target.is_dir() else "file"
117
+ lines = [
118
+ f"path: {_rel(target)}",
119
+ f"type: {kind}",
120
+ f"size: {st.st_size} bytes",
121
+ f"modified: {modified}",
122
+ ]
123
+ if target.is_dir():
124
+ lines.append(f"entries: {sum(1 for _ in target.iterdir())}")
125
+ elif not _is_probably_binary(target.read_bytes()[:1024]):
126
+ line_count = target.read_text(encoding="utf-8", errors="replace").count("\n") + 1
127
+ lines.append(f"lines: {line_count}")
128
+ else:
129
+ lines.append("content: binary")
130
+ return ToolResult("", "\n".join(lines))
131
+
132
+ return [
133
+ ToolSpec(
134
+ name="find_files",
135
+ description=(
136
+ "Find files and directories by glob pattern within the working "
137
+ "directory — your first move when you know roughly what a file is "
138
+ "named but not where it lives. Supports `**` for recursive match "
139
+ "(e.g. '**/*.py', 'src/**/test_*.py'). Returns matching paths; "
140
+ "noise dirs like .git and __pycache__ are skipped."
141
+ ),
142
+ parameters={
143
+ "type": "object",
144
+ "properties": {
145
+ "pattern": {
146
+ "type": "string",
147
+ "description": "Glob pattern, e.g. '**/*.py' or 'docs/*.md'.",
148
+ },
149
+ "path": {
150
+ "type": "string",
151
+ "description": (
152
+ "Subdirectory to search under (default: the working dir root)."
153
+ ),
154
+ },
155
+ },
156
+ "required": ["pattern"],
157
+ "additionalProperties": False,
158
+ },
159
+ handler=find_files,
160
+ category="search",
161
+ ),
162
+ ToolSpec(
163
+ name="search_text",
164
+ description=(
165
+ "Search file contents by regular expression within the working "
166
+ "directory (a built-in grep) — use it to locate where a symbol, "
167
+ "string, or pattern is defined or used. Returns 'path:line: text' "
168
+ "for each match. Filter the files searched with `glob` and narrow "
169
+ "with `path`; binary files and noise dirs are skipped."
170
+ ),
171
+ parameters={
172
+ "type": "object",
173
+ "properties": {
174
+ "pattern": {
175
+ "type": "string",
176
+ "description": "Python regular expression to match against each line.",
177
+ },
178
+ "glob": {
179
+ "type": "string",
180
+ "description": "Restrict to files matching this glob (default '**/*').",
181
+ },
182
+ "path": {
183
+ "type": "string",
184
+ "description": (
185
+ "Subdirectory to search under (default: the working dir root)."
186
+ ),
187
+ },
188
+ "ignore_case": {
189
+ "type": "boolean",
190
+ "description": "Case-insensitive match.",
191
+ },
192
+ "max_results": {
193
+ "type": "integer",
194
+ "description": "Stop after this many matches (default 100).",
195
+ },
196
+ },
197
+ "required": ["pattern"],
198
+ "additionalProperties": False,
199
+ },
200
+ handler=search_text,
201
+ category="search",
202
+ ),
203
+ ToolSpec(
204
+ name="file_info",
205
+ description=(
206
+ "Report metadata for a path within the working directory: whether "
207
+ "it is a file or directory, its size, last-modified time, and line "
208
+ "count (for text). Cheaper than reading a whole file when you only "
209
+ "need to know it exists or how big it is."
210
+ ),
211
+ parameters={
212
+ "type": "object",
213
+ "properties": {
214
+ "path": {
215
+ "type": "string",
216
+ "description": "Path relative to the working directory.",
217
+ }
218
+ },
219
+ "required": ["path"],
220
+ "additionalProperties": False,
221
+ },
222
+ handler=file_info,
223
+ category="search",
224
+ ),
225
+ ]
@@ -0,0 +1,67 @@
1
+ """The bash tool (design §6.3, §10.3).
2
+
3
+ Flagged ``runs_code`` + ``mutates`` + ``requires_approval``, so the loop gates
4
+ it through the approver and it executes only inside the injected ``Sandbox``,
5
+ confined to the working directory. Output is assembled here; the loop applies
6
+ the shared §8.4 truncation before it enters context.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from typing import TYPE_CHECKING
12
+
13
+ from agentkernel.tools.base import ToolSpec
14
+ from agentkernel.types import ToolResult
15
+
16
+ if TYPE_CHECKING:
17
+ from agentkernel.approval import Sandbox
18
+
19
+ _BASH_SCHEMA = {
20
+ "type": "object",
21
+ "properties": {
22
+ "command": {
23
+ "type": "string",
24
+ "description": "The shell command to run in the working directory.",
25
+ }
26
+ },
27
+ "required": ["command"],
28
+ "additionalProperties": False,
29
+ }
30
+
31
+
32
+ def bash_tool(sandbox: Sandbox, working_dir: str = ".", *, timeout: int = 60) -> ToolSpec:
33
+ """Build the bash tool bound to ``sandbox`` and ``working_dir``."""
34
+
35
+ def bash(args: dict) -> ToolResult:
36
+ command = args["command"]
37
+ exit_code, stdout, stderr = sandbox.run(
38
+ command, cwd=working_dir, timeout=timeout
39
+ )
40
+ sections = []
41
+ if stdout:
42
+ sections.append(stdout.rstrip("\n"))
43
+ if stderr:
44
+ sections.append(f"[stderr]\n{stderr.rstrip(chr(10))}")
45
+ if exit_code != 0:
46
+ sections.append(f"[exit code {exit_code}]")
47
+ content = "\n".join(sections) if sections else "(no output)"
48
+ return ToolResult(
49
+ "",
50
+ content,
51
+ is_error=exit_code != 0,
52
+ data={"exit_code": exit_code},
53
+ )
54
+
55
+ return ToolSpec(
56
+ name="bash",
57
+ description=(
58
+ "Run a shell command in the working directory and return its output. "
59
+ "Use for builds, tests, git, and filesystem operations."
60
+ ),
61
+ parameters=_BASH_SCHEMA,
62
+ handler=bash,
63
+ requires_approval=True,
64
+ mutates=True,
65
+ runs_code=True,
66
+ category="shell",
67
+ )
@@ -0,0 +1,106 @@
1
+ """In-session todo tool (design §18.4).
2
+
3
+ A lightweight task list the model maintains while working through a multi-step
4
+ job, so its plan stays legible to both the model and the user. State is held in
5
+ memory for the life of one runtime (a session), bound into the handler by the
6
+ factory — no global state, no persistence.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass, field
12
+
13
+ from agentkernel.tools.base import ToolSpec
14
+ from agentkernel.types import ToolResult
15
+
16
+ _STATUS_MARK = {"pending": "[ ]", "in_progress": "[~]", "done": "[x]"}
17
+
18
+
19
+ @dataclass
20
+ class TodoItem:
21
+ id: int
22
+ text: str
23
+ status: str = "pending" # pending | in_progress | done
24
+
25
+
26
+ @dataclass
27
+ class TodoList:
28
+ """The session's task list. Bound into the todo tool's handler."""
29
+
30
+ _items: list[TodoItem] = field(default_factory=list)
31
+ _next_id: int = 1
32
+
33
+ def add(self, text: str) -> TodoItem:
34
+ item = TodoItem(self._next_id, text.strip())
35
+ self._items.append(item)
36
+ self._next_id += 1
37
+ return item
38
+
39
+ def set_status(self, item_id: int, status: str) -> TodoItem | None:
40
+ for item in self._items:
41
+ if item.id == item_id:
42
+ item.status = status
43
+ return item
44
+ return None
45
+
46
+ def clear(self) -> None:
47
+ self._items.clear()
48
+
49
+ def render(self) -> str:
50
+ if not self._items:
51
+ return "(todo list is empty)"
52
+ done = sum(1 for i in self._items if i.status == "done")
53
+ lines = [f"Todos ({done}/{len(self._items)} done):"]
54
+ lines += [f" {_STATUS_MARK[i.status]} {i.id}. {i.text}" for i in self._items]
55
+ return "\n".join(lines)
56
+
57
+
58
+ def todo_tool(todo_list: TodoList) -> ToolSpec:
59
+ """Build the `todo` tool over a session task list."""
60
+
61
+ def todo(args: dict) -> ToolResult:
62
+ action = args["action"]
63
+ if action == "add":
64
+ text = (args.get("text") or "").strip()
65
+ if not text:
66
+ return ToolResult("", "add requires non-empty `text`.", is_error=True)
67
+ todo_list.add(text)
68
+ return ToolResult("", todo_list.render())
69
+ if action in ("start", "complete"):
70
+ if "id" not in args:
71
+ return ToolResult("", f"{action} requires `id`.", is_error=True)
72
+ status = "in_progress" if action == "start" else "done"
73
+ if todo_list.set_status(int(args["id"]), status) is None:
74
+ return ToolResult("", f"No todo with id={args['id']}.", is_error=True)
75
+ return ToolResult("", todo_list.render())
76
+ if action == "clear":
77
+ todo_list.clear()
78
+ return ToolResult("", "Cleared the todo list.")
79
+ # action == "list"
80
+ return ToolResult("", todo_list.render())
81
+
82
+ return ToolSpec(
83
+ name="todo",
84
+ description=(
85
+ "Maintain a short task list for the current job so your plan stays "
86
+ "visible. Use it for multi-step work: add the steps, mark one `start` "
87
+ "as you begin it and `complete` when done, and `list` to review. "
88
+ "Actions: add (needs text), start/complete (need id), list, clear."
89
+ ),
90
+ parameters={
91
+ "type": "object",
92
+ "properties": {
93
+ "action": {
94
+ "type": "string",
95
+ "enum": ["add", "start", "complete", "list", "clear"],
96
+ "description": "The operation to perform.",
97
+ },
98
+ "text": {"type": "string", "description": "Task text (for add)."},
99
+ "id": {"type": "integer", "description": "Task id (for start/complete)."},
100
+ },
101
+ "required": ["action"],
102
+ "additionalProperties": False,
103
+ },
104
+ handler=todo,
105
+ category="planning",
106
+ )
@@ -0,0 +1,50 @@
1
+ """Terminal UI for agentkernel — a curses-based interactive chat interface.
2
+
3
+ Provides a split-pane terminal UI with:
4
+ - Scrollable chat history (color-coded by role)
5
+ - Multi-line input area
6
+ - Status bar (model, tokens, cost)
7
+ - Background agent execution with live status indicator
8
+
9
+ Usage:
10
+ from agentkernel.tui import run_tui
11
+ run_tui(config) # or: uv run agentkernel tui
12
+
13
+ On Windows, ``pip install windows-curses`` first.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import sys
19
+ from typing import TYPE_CHECKING
20
+
21
+ if TYPE_CHECKING:
22
+ from agentkernel.config import Config
23
+
24
+
25
+ def run_tui(config: Config) -> int:
26
+ """Entry point: initialise curses and run the TUI event loop.
27
+
28
+ Returns 0 on clean exit, 1 on error.
29
+ """
30
+ try:
31
+ import curses
32
+ except ImportError:
33
+ print(
34
+ "The TUI requires the `curses` module, which is not available.\n"
35
+ "On Windows, install it with: pip install windows-curses\n"
36
+ "On Unix, it should be included with your Python installation.",
37
+ file=sys.stderr,
38
+ )
39
+ return 1
40
+
41
+ from agentkernel.tui.app import TuiApp
42
+
43
+ try:
44
+ app = TuiApp(config)
45
+ return curses.wrapper(app.run)
46
+ except KeyboardInterrupt:
47
+ return 0
48
+ except Exception as exc:
49
+ print(f"TUI error: {exc}", file=sys.stderr)
50
+ return 1