stcode 0.0.1a0__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 (111) hide show
  1. stcode/__init__.py +10 -0
  2. stcode/cli/__init__.py +9 -0
  3. stcode/cli/bootstrap.py +70 -0
  4. stcode/cli/labels.py +621 -0
  5. stcode/cli/logic/__init__.py +0 -0
  6. stcode/cli/logic/commands.py +36 -0
  7. stcode/cli/logic/completion.py +43 -0
  8. stcode/cli/logic/connection.py +46 -0
  9. stcode/cli/logic/requests.py +18 -0
  10. stcode/cli/logic/sessions.py +67 -0
  11. stcode/cli/logic/settings.py +35 -0
  12. stcode/cli/logic/state.py +43 -0
  13. stcode/cli/logic/workspace.py +17 -0
  14. stcode/cli/main.py +290 -0
  15. stcode/cli/models.py +61 -0
  16. stcode/cli/services/__init__.py +0 -0
  17. stcode/cli/services/client.py +393 -0
  18. stcode/cli/services/launcher.py +159 -0
  19. stcode/cli/services/paths.py +18 -0
  20. stcode/cli/services/preferences.py +107 -0
  21. stcode/cli/ui/__init__.py +0 -0
  22. stcode/cli/ui/app.py +136 -0
  23. stcode/cli/ui/app.tcss +3 -0
  24. stcode/cli/ui/components/__init__.py +0 -0
  25. stcode/cli/ui/components/banner.py +78 -0
  26. stcode/cli/ui/components/cards.py +229 -0
  27. stcode/cli/ui/components/prompt.py +225 -0
  28. stcode/cli/ui/components/status.py +95 -0
  29. stcode/cli/ui/components/transcript/__init__.py +51 -0
  30. stcode/cli/ui/components/transcript/entries.py +232 -0
  31. stcode/cli/ui/components/transcript/formatting.py +188 -0
  32. stcode/cli/ui/components/transcript/view.py +101 -0
  33. stcode/cli/ui/screens/__init__.py +0 -0
  34. stcode/cli/ui/screens/chat/__init__.py +0 -0
  35. stcode/cli/ui/screens/chat/chat.tcss +12 -0
  36. stcode/cli/ui/screens/chat/presenter.py +187 -0
  37. stcode/cli/ui/screens/chat/screen.py +682 -0
  38. stcode/cli/ui/screens/connect.py +126 -0
  39. stcode/cli/ui/screens/connect.tcss +49 -0
  40. stcode/cli/ui/screens/dialog.tcss +40 -0
  41. stcode/cli/ui/screens/sessions.py +105 -0
  42. stcode/cli/ui/screens/settings.py +137 -0
  43. stcode/cli/ui/screens/settings.tcss +69 -0
  44. stcode/cli/ui/screens/trust.py +50 -0
  45. stcode/cli/ui/theme.py +221 -0
  46. stcode/core/__main__.py +7 -0
  47. stcode/core/agent/__init__.py +41 -0
  48. stcode/core/agent/agent.py +651 -0
  49. stcode/core/agent/events.py +97 -0
  50. stcode/core/agent/supervisor.py +226 -0
  51. stcode/core/agent/task.py +120 -0
  52. stcode/core/common/__init__.py +21 -0
  53. stcode/core/common/compat.py +27 -0
  54. stcode/core/common/ids.py +43 -0
  55. stcode/core/common/tools.py +50 -0
  56. stcode/core/common/trace.py +406 -0
  57. stcode/core/common/truncate.py +76 -0
  58. stcode/core/configs.py +490 -0
  59. stcode/core/daemon/__init__.py +32 -0
  60. stcode/core/daemon/autonomy.py +71 -0
  61. stcode/core/daemon/main.py +126 -0
  62. stcode/core/daemon/protocol.py +499 -0
  63. stcode/core/daemon/runner.py +359 -0
  64. stcode/core/daemon/server.py +682 -0
  65. stcode/core/daemon/workspace.py +88 -0
  66. stcode/core/harness/__init__.py +81 -0
  67. stcode/core/harness/approvals.py +85 -0
  68. stcode/core/harness/context.py +260 -0
  69. stcode/core/harness/errors.py +40 -0
  70. stcode/core/harness/harness.py +417 -0
  71. stcode/core/harness/mcp.py +530 -0
  72. stcode/core/harness/outputs.py +99 -0
  73. stcode/core/harness/prompts/__init__.py +138 -0
  74. stcode/core/harness/prompts/execute.py +51 -0
  75. stcode/core/harness/prompts/plan.py +57 -0
  76. stcode/core/harness/prompts/sections.py +238 -0
  77. stcode/core/harness/skills/__init__.py +30 -0
  78. stcode/core/harness/skills/loader.py +247 -0
  79. stcode/core/harness/tools/__init__.py +111 -0
  80. stcode/core/harness/tools/base.py +640 -0
  81. stcode/core/harness/tools/files.py +276 -0
  82. stcode/core/harness/tools/interact.py +72 -0
  83. stcode/core/harness/tools/registry.py +113 -0
  84. stcode/core/harness/tools/repl.py +97 -0
  85. stcode/core/harness/tools/schema.py +186 -0
  86. stcode/core/harness/tools/search.py +356 -0
  87. stcode/core/harness/tools/shell.py +372 -0
  88. stcode/core/harness/tools/skill.py +66 -0
  89. stcode/core/harness/tools/todo.py +62 -0
  90. stcode/core/harness/tools/web.py +163 -0
  91. stcode/core/providers/__init__.py +31 -0
  92. stcode/core/providers/anthropic_claude.py +243 -0
  93. stcode/core/providers/base.py +73 -0
  94. stcode/core/providers/gateway.py +267 -0
  95. stcode/core/providers/google_gemini.py +225 -0
  96. stcode/core/providers/openai_gpt.py +250 -0
  97. stcode/core/providers/registry.py +46 -0
  98. stcode/core/providers/types.py +148 -0
  99. stcode/core/repl/__init__.py +19 -0
  100. stcode/core/repl/_worker.py +179 -0
  101. stcode/core/repl/client.py +374 -0
  102. stcode/core/session/__init__.py +39 -0
  103. stcode/core/session/session.py +493 -0
  104. stcode/core/team/__init__.py +38 -0
  105. stcode/core/team/mailbox.py +249 -0
  106. stcode/core/team/tools.py +145 -0
  107. stcode-0.0.1a0.dist-info/METADATA +229 -0
  108. stcode-0.0.1a0.dist-info/RECORD +111 -0
  109. stcode-0.0.1a0.dist-info/WHEEL +4 -0
  110. stcode-0.0.1a0.dist-info/entry_points.txt +3 -0
  111. stcode-0.0.1a0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,43 @@
1
+ """Token completion independent of terminal widgets."""
2
+
3
+ from __future__ import annotations
4
+ from typing import Sequence
5
+ from stcode.cli.models import Row
6
+
7
+ TRIGGERS = ("/", "@")
8
+
9
+
10
+ def token_trigger(text: str, cursor: int) -> tuple[str, str] | None:
11
+ """The `/` or `@` token the cursor is inside, as `(symbol, filter)`.
12
+
13
+ A token starts at the beginning of the line or after whitespace, which is the whole
14
+ rule: `src/api` is a path and `me@example.com` is an address, and neither should
15
+ hijack the input. It ends at whitespace, so `/mode plan` is past the command.
16
+ """
17
+ if cursor <= 0 or cursor > len(text):
18
+ return None
19
+ start = cursor
20
+ while start > 0 and not text[start - 1].isspace():
21
+ start -= 1
22
+ token = text[start:cursor]
23
+ if not token or token[0] not in TRIGGERS:
24
+ return None
25
+ return token[0], token[1:]
26
+
27
+
28
+ def filter_rows(rows: Sequence[Row], query: str) -> list[Row]:
29
+ """Narrow on a substring of the **label**, case-insensitively.
30
+
31
+ The label only, and that is the interesting part. Searching the descriptions too
32
+ looks generous and reads as noise: typing `/mod` would offer `/effort`, whose
33
+ description happens to say "how hard the model should think". What you are typing
34
+ is a name, so a name is what it matches — and `?` already lists all twelve with
35
+ their descriptions for the times you do not know the name.
36
+
37
+ Substring, not fuzzy. The fuzzy command palette was removed on purpose: two ways to
38
+ reach the same twelve commands is one way for them to disagree.
39
+ """
40
+ if not query:
41
+ return list(rows)
42
+ needle = query.lower()
43
+ return [row for row in rows if needle in row[0].lower()]
@@ -0,0 +1,46 @@
1
+ """Connection ownership and cleanup; interaction with the user stays in the UI."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import contextlib
6
+
7
+ from stcode.cli.services.client import Address, DaemonClient
8
+ from stcode.cli.services.launcher import Launcher
9
+
10
+
11
+ class Connection:
12
+ def __init__(self, launcher: Launcher) -> None:
13
+ self.launcher = launcher
14
+ self.client: DaemonClient | None = None
15
+
16
+ @property
17
+ def address(self) -> Address:
18
+ return self.launcher.address
19
+
20
+ @property
21
+ def started(self) -> bool:
22
+ return self.launcher.started
23
+
24
+ async def close_client(self) -> None:
25
+ client, self.client = self.client, None
26
+ if client is not None:
27
+ with contextlib.suppress(Exception):
28
+ await client.aclose()
29
+
30
+ async def open(
31
+ self, *, address: Address | None = None, restart: bool = False
32
+ ) -> DaemonClient:
33
+ await self.close_client()
34
+ if address is None:
35
+ client = await self.launcher.connect(restart=restart)
36
+ else:
37
+ client = await DaemonClient.connect(address)
38
+ self.launcher.address = address
39
+ self.client = client
40
+ return client
41
+
42
+ async def aclose(self) -> None:
43
+ try:
44
+ await self.close_client()
45
+ finally:
46
+ await self.launcher.stop()
@@ -0,0 +1,18 @@
1
+ """FIFO requests keep parallel approvals and questions individually answerable."""
2
+
3
+ from collections import deque
4
+ from typing import Any
5
+
6
+
7
+ class RequestQueue:
8
+ def __init__(self) -> None:
9
+ self._pending: deque[dict[str, Any]] = deque()
10
+
11
+ def append(self, frame: dict[str, Any]) -> None:
12
+ self._pending.append(frame)
13
+
14
+ def pop(self) -> dict[str, Any] | None:
15
+ return self._pending.popleft() if self._pending else None
16
+
17
+ def clear(self) -> None:
18
+ self._pending.clear()
@@ -0,0 +1,67 @@
1
+ """Session operations preserve the distinction between local and daemon workspaces."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from stcode.cli.models import StartOptions
8
+ from stcode.cli.services.client import DaemonClient
9
+ from .connection import Connection
10
+ from .state import ClientState
11
+
12
+
13
+ class Sessions:
14
+ def __init__(
15
+ self, connection: Connection, state: ClientState, options: StartOptions
16
+ ) -> None:
17
+ self.connection = connection
18
+ self.state = state
19
+ self.options = options
20
+
21
+ @property
22
+ def client(self) -> DaemonClient:
23
+ client = self.connection.client
24
+ if client is None:
25
+ raise ConnectionError("No daemon is connected")
26
+ return client
27
+
28
+ async def open(self) -> dict[str, Any]:
29
+ if self.options.resume:
30
+ return await self.client.attach(self.options.resume)
31
+ cwd = (
32
+ self.options.cwd
33
+ if self.options.explicit_cwd or not self.options.daemonless
34
+ else None
35
+ )
36
+ return await self.client.create(
37
+ cwd=cwd, role=self.options.role, approval_mode=self.options.mode
38
+ )
39
+
40
+ async def list(self) -> list[dict[str, Any]]:
41
+ return await self.client.sessions(50)
42
+
43
+ async def detach(self) -> None:
44
+ await self.client.detach(self.state.session_id)
45
+
46
+ async def attach(self, session_id: str) -> dict[str, Any]:
47
+ return await self.client.attach(session_id)
48
+
49
+ async def clear(self) -> dict[str, Any]:
50
+ previous = self.state.session_id
51
+ frame = await self.client.create(
52
+ cwd=self.options.cwd,
53
+ role=self.options.role,
54
+ approval_mode=self.options.mode,
55
+ )
56
+ if previous:
57
+ await self.client.detach(previous)
58
+ return frame
59
+
60
+ async def load_info(self) -> None:
61
+ self.state.info = await self.client.info()
62
+
63
+ async def push(self, text: str) -> None:
64
+ await self.client.push(text)
65
+
66
+ async def interrupt(self) -> None:
67
+ await self.client.interrupt()
@@ -0,0 +1,35 @@
1
+ """Write defaults before session overrides; the daemon is authoritative."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any
6
+
7
+ from stcode.cli.models import ApprovalMode
8
+ from stcode.cli.services.client import DaemonClient
9
+ from .state import ClientState
10
+
11
+
12
+ class Settings:
13
+ def __init__(self, state: ClientState) -> None:
14
+ self.state = state
15
+
16
+ async def set_config(self, client: DaemonClient, **fields: Any) -> None:
17
+ self.state.config = await client.set_config(**fields)
18
+
19
+ async def apply(self, client: DaemonClient, patch: dict[str, Any]) -> None:
20
+ await self.set_config(client, **patch)
21
+ if self.state.session_id:
22
+ await client.set_meta(
23
+ provider=str(self.state.config.get("provider", "")),
24
+ model=str(self.state.config.get("model", "")),
25
+ )
26
+
27
+ async def set_mode(self, client: DaemonClient, mode: ApprovalMode) -> None:
28
+ await self.set_config(client, approval_mode=mode)
29
+ if self.state.session_id:
30
+ await client.set_mode(mode)
31
+
32
+ async def set_effort(self, client: DaemonClient, effort: str) -> None:
33
+ await self.set_config(client, reasoning_effort=effort)
34
+ if self.state.session_id:
35
+ await client.set_meta(reasoning_effort=effort)
@@ -0,0 +1,43 @@
1
+ """The last facts reported by the daemon, independent of terminal widgets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+ from typing import Any
7
+
8
+
9
+ @dataclass
10
+ class ClientState:
11
+ session_id: str = ""
12
+ config: dict[str, Any] = field(default_factory=dict)
13
+ effective: dict[str, str] = field(default_factory=dict)
14
+ info: dict[str, Any] = field(default_factory=dict)
15
+
16
+ def adopt_session(self, frame: dict[str, Any]) -> None:
17
+ session_id = str(frame.get("id", ""))
18
+ if session_id:
19
+ self.session_id = session_id
20
+ self.effective = {
21
+ "model": str(frame.get("model", "")),
22
+ "provider": str(frame.get("provider", "")),
23
+ "effort": str(frame.get("reasoning_effort", "")),
24
+ "mode": str(frame.get("approval_mode", "")),
25
+ }
26
+
27
+ @property
28
+ def provider(self) -> str:
29
+ return self.effective.get("provider") or str(self.config.get("provider", ""))
30
+
31
+ @property
32
+ def provider_entry(self) -> dict[str, Any]:
33
+ return dict(self.config.get("providers", {}).get(self.provider, {}))
34
+
35
+ @property
36
+ def effort(self) -> str:
37
+ return self.effective.get("effort") or str(
38
+ self.config.get("reasoning_effort", "")
39
+ )
40
+
41
+ @property
42
+ def has_credential(self) -> bool:
43
+ return bool(self.provider_entry.get("has_key"))
@@ -0,0 +1,17 @@
1
+ """Workspace requests always target the attached daemon session."""
2
+
3
+ from typing import Any
4
+
5
+ from stcode.cli.models import UiPrefs
6
+ from stcode.cli.services.client import DaemonClient
7
+
8
+
9
+ class Workspace:
10
+ def __init__(self, prefs: UiPrefs) -> None:
11
+ self.prefs = prefs
12
+
13
+ async def files(self, client: DaemonClient) -> list[str]:
14
+ return await client.workspace_files()
15
+
16
+ async def shell(self, client: DaemonClient, command: str) -> dict[str, Any]:
17
+ return await client.workspace_shell(command, self.prefs.shell_timeout)
stcode/cli/main.py ADDED
@@ -0,0 +1,290 @@
1
+ """
2
+ CLI entrypoint — one command, three shapes.
3
+
4
+ stcode chat UI + a daemon (started here if none is listening)
5
+ stcode --headless the daemon alone — a container's whole process
6
+ stcode --daemonless the chat UI alone, pointed at a daemon somewhere else
7
+
8
+ The daemon is its own program, `stcode-daemon`. `--headless` replaces this process with
9
+ it; the UI starts it in the background when nothing listens, and stops it on exit.
10
+ `--restart` stops a running local daemon first, for when it is wedged or its code
11
+ changed.
12
+
13
+ `stcode <path>` opens a session in that directory. It is a rewrite to `--cwd` rather
14
+ than an argument on the group, because a `click` group consumes its own arguments before
15
+ it looks for a subcommand — declaring one there makes `stcode sessions` mean "open the
16
+ directory ./sessions". Only the **first** token is read this way, and only when it is
17
+ not a subcommand, so the grammar stays "the path comes first, flags after".
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import asyncio
23
+ import os
24
+ import sys
25
+ from pathlib import Path
26
+ from typing import Annotated, Any, Awaitable, Callable, NoReturn
27
+
28
+ import typer
29
+
30
+ from stcode import __version__
31
+ from stcode.cli.services.client import Address, DaemonClient
32
+ from stcode.cli.models import APPROVAL_MODES, ApprovalMode
33
+ from stcode.cli.logic.commands import parse_approval_mode
34
+ from stcode.cli.services.launcher import DaemonFailed, Launcher, daemon_command
35
+
36
+ cli = typer.Typer(
37
+ name="stcode",
38
+ help="stcode — a coding agent for the terminal: a daemon holding the agent, and a UI attached to it.",
39
+ no_args_is_help=False,
40
+ add_completion=False,
41
+ )
42
+
43
+ ConfigOption = Annotated[
44
+ Path | None,
45
+ typer.Option(
46
+ "--config", "-c", help="Config file to use instead of the default location."
47
+ ),
48
+ ]
49
+
50
+
51
+ @cli.callback(invoke_without_command=True)
52
+ def main(
53
+ ctx: typer.Context,
54
+ config: ConfigOption = None,
55
+ headless: Annotated[
56
+ bool,
57
+ typer.Option(
58
+ "--headless", help="Run the daemon only, with no UI. What a container runs."
59
+ ),
60
+ ] = False,
61
+ daemonless: Annotated[
62
+ bool,
63
+ typer.Option(
64
+ "--daemonless",
65
+ help="Run the UI only, attached to a daemon elsewhere. Asks for the address if none answers.",
66
+ ),
67
+ ] = False,
68
+ restart: Annotated[
69
+ bool,
70
+ typer.Option(
71
+ "--restart", help="Stop the local daemon first and start a fresh one."
72
+ ),
73
+ ] = False,
74
+ cwd: Annotated[
75
+ Path | None,
76
+ typer.Option(
77
+ "--cwd",
78
+ "-C",
79
+ help="Workspace for the session. Defaults to the current directory. "
80
+ "`stcode <path>` is the same thing, spelled shorter.",
81
+ ),
82
+ ] = None,
83
+ mode: Annotated[
84
+ str | None,
85
+ typer.Option(
86
+ "--mode", "-m", help=f"Approval mode: {' | '.join(APPROVAL_MODES)}."
87
+ ),
88
+ ] = None,
89
+ resume: Annotated[
90
+ str | None,
91
+ typer.Option(
92
+ "--resume",
93
+ "-r",
94
+ help="Attach to an existing session id instead of starting one.",
95
+ ),
96
+ ] = None,
97
+ role: Annotated[
98
+ str | None,
99
+ typer.Option("--role", help="Which agent this is, e.g. backend-dev."),
100
+ ] = None,
101
+ team: Annotated[
102
+ bool,
103
+ typer.Option("--team", help="Headless only: turn team mode on."),
104
+ ] = False,
105
+ transport: Annotated[
106
+ str | None, typer.Option("--transport", help="unix | tcp.")
107
+ ] = None,
108
+ socket: Annotated[
109
+ str | None, typer.Option("--socket", help="Unix socket path.")
110
+ ] = None,
111
+ host: Annotated[
112
+ str | None,
113
+ typer.Option("--host", help="TCP host to bind (headless) or connect to."),
114
+ ] = None,
115
+ port: Annotated[
116
+ int | None, typer.Option("--port", help="TCP port. Default 7717.")
117
+ ] = None,
118
+ version: Annotated[
119
+ bool, typer.Option("--version", help="Print the version and exit.")
120
+ ] = False,
121
+ ) -> None:
122
+ """Start stcode. With no subcommand this is the whole program."""
123
+ if version:
124
+ typer.echo(f"stcode {__version__}")
125
+ raise typer.Exit()
126
+ if ctx.invoked_subcommand is not None:
127
+ ctx.obj = _address(transport, socket, host, port), config
128
+ return
129
+ if headless and daemonless:
130
+ _fail(
131
+ "--headless and --daemonless are opposites: one is the daemon without a UI, "
132
+ "the other the UI without a daemon."
133
+ )
134
+ approval_mode = _mode(mode)
135
+
136
+ if headless:
137
+ # Flags pass through by name: `stcode-daemon` is the one that understands them.
138
+ flags = {
139
+ "--config": config,
140
+ "--transport": transport,
141
+ "--socket": socket,
142
+ "--host": host,
143
+ "--port": port,
144
+ "--mode": approval_mode,
145
+ "--role": role,
146
+ }
147
+ command = daemon_command()
148
+ command += [
149
+ str(item)
150
+ for flag, value in flags.items()
151
+ if value is not None
152
+ for item in (flag, value)
153
+ ]
154
+ command += ["--team"] if team else []
155
+ os.execv(command[0], command)
156
+
157
+ # Resolved, not kept as typed: `.` reaches the prefs file as the trusted path, the
158
+ # daemon as the session's cwd and the UI as the root it lists files under, and a
159
+ # relative one means something different in each of them.
160
+ cwd = cwd.expanduser().resolve() if cwd is not None else None
161
+ if cwd is not None and not cwd.is_dir():
162
+ _fail(f"{cwd} is not a directory.")
163
+
164
+ # Imported lazily: `stcode sessions` shouldn't pay for loading textual.
165
+ from stcode.cli.bootstrap import run
166
+
167
+ run(
168
+ Launcher(_address(transport, socket, host, port), config=config),
169
+ daemonless=daemonless,
170
+ restart=restart,
171
+ cwd=cwd,
172
+ resume=resume or "",
173
+ mode=approval_mode,
174
+ role=role or "",
175
+ )
176
+
177
+
178
+ def _address(
179
+ transport: str | None, socket: str | None, host: str | None, port: int | None
180
+ ) -> Address:
181
+ address = Address(transport=transport or "unix")
182
+ if socket:
183
+ address.socket = Path(socket).expanduser()
184
+ address.host = host or address.host
185
+ address.port = port or address.port
186
+ return address
187
+
188
+
189
+ def _fail(message: str) -> NoReturn:
190
+ typer.secho(message, fg=typer.colors.RED, err=True)
191
+ raise typer.Exit(code=2)
192
+
193
+
194
+ def _mode(value: str | None) -> ApprovalMode | None:
195
+ """Resolve `--mode auto` to a mode name, or exit saying what the choices are."""
196
+ if value is None:
197
+ return None
198
+ resolved = parse_approval_mode(value)
199
+ if resolved is None:
200
+ _fail(f"Unknown mode {value!r}. One of: {', '.join(APPROVAL_MODES)}")
201
+ return resolved
202
+
203
+
204
+ def _with_daemon(
205
+ ctx: typer.Context, action: Callable[[DaemonClient], Awaitable[None]]
206
+ ) -> None:
207
+ """Run one request against the daemon, starting it for the duration if needed."""
208
+ address, config = ctx.obj
209
+
210
+ async def go() -> None:
211
+ launcher = Launcher(address, config=config)
212
+ try:
213
+ client = await launcher.connect()
214
+ except (OSError, DaemonFailed) as exc:
215
+ _fail(f"Could not reach a daemon at {address}: {exc}")
216
+ try:
217
+ async with client:
218
+ await action(client)
219
+ finally:
220
+ await launcher.stop()
221
+
222
+ asyncio.run(go())
223
+
224
+
225
+ @cli.command("sessions")
226
+ def list_sessions(
227
+ ctx: typer.Context,
228
+ limit: Annotated[int, typer.Option("--limit", "-n", help="How many to show.")] = 20,
229
+ ) -> None:
230
+ """List recent sessions, newest first. Resume one with `stcode --resume <id>`."""
231
+
232
+ async def show(client: DaemonClient) -> None:
233
+ rows = await client.sessions(limit)
234
+ if not rows:
235
+ typer.echo("No sessions yet.")
236
+ for row in rows:
237
+ typer.echo(
238
+ f"{row.get('id', '?'):<28} {row.get('ts', ''):<26} {row.get('cwd', '')}"
239
+ )
240
+
241
+ _with_daemon(ctx, show)
242
+
243
+
244
+ @cli.command("config")
245
+ def show_config(ctx: typer.Context) -> None:
246
+ """Show the daemon's config file and what it currently selects."""
247
+
248
+ async def show(client: DaemonClient) -> None:
249
+ frame: dict[str, Any] = await client.get_config()
250
+ typer.echo(f"path: {frame.get('path')}")
251
+ typer.echo(f"provider: {frame.get('provider')}")
252
+ typer.echo(f"model: {frame.get('model')}")
253
+ typer.echo(f"effort: {frame.get('reasoning_effort')}")
254
+ typer.echo(f"mode: {frame.get('approval_mode')}")
255
+ typer.echo(f"daemon: {ctx.obj[0]}")
256
+
257
+ _with_daemon(ctx, show)
258
+
259
+
260
+ def workspace_argv(argv: list[str], commands: set[str]) -> list[str]:
261
+ """Rewrite a leading path into `--cwd <path>`. Everything else is untouched.
262
+
263
+ The first token only, and only when it is neither a flag nor a subcommand. A token
264
+ that is neither but also does not look like a path — `stcode sesions` — is left
265
+ alone, so a mistyped command still gets click's "No such command" instead of being
266
+ silently accepted as a directory that does not exist.
267
+ """
268
+ if not argv:
269
+ return argv
270
+ first = argv[0]
271
+ if first.startswith("-") or first in commands:
272
+ return argv
273
+ looks_like_a_path = (
274
+ Path(first).expanduser().is_dir()
275
+ or first.startswith(("~", ".", "/"))
276
+ or "/" in first
277
+ )
278
+ if not looks_like_a_path:
279
+ return argv
280
+ return ["--cwd", first, *argv[1:]]
281
+
282
+
283
+ def run_cli() -> None:
284
+ """The console entry point. → `workspace_argv`."""
285
+ commands = set(typer.main.get_command(cli).commands) # type: ignore[attr-defined]
286
+ cli(args=workspace_argv(sys.argv[1:], commands))
287
+
288
+
289
+ if __name__ == "__main__":
290
+ run_cli()
stcode/cli/models.py ADDED
@@ -0,0 +1,61 @@
1
+ """Client-owned vocabulary and values; never imports the engine or terminal UI."""
2
+
3
+ from __future__ import annotations
4
+ from dataclasses import dataclass
5
+ from pathlib import Path
6
+ from typing import Literal
7
+ from pydantic import BaseModel, Field
8
+
9
+ ApprovalMode = Literal["plan", "suggest", "auto-edit", "full-auto"]
10
+ APPROVAL_MODES: tuple[ApprovalMode, ...] = ("plan", "suggest", "auto-edit", "full-auto")
11
+ REASONING_EFFORTS = ("none", "minimal", "low", "medium", "high", "xhigh", "max")
12
+ Row = tuple[str, str]
13
+ ThemePreference = Literal["auto", "dark", "light"]
14
+ THEME_PREFERENCES: tuple[ThemePreference, ...] = ("auto", "dark", "light")
15
+ DEFAULT_THEME: ThemePreference = "auto"
16
+ DEFAULT_SHELL_TIMEOUT = 10.0
17
+ MAX_SHELL_TIMEOUT = 120.0
18
+
19
+
20
+ @dataclass(frozen=True)
21
+ class StartOptions:
22
+ cwd: Path
23
+ explicit_cwd: bool = False
24
+ daemonless: bool = False
25
+ resume: str = ""
26
+ mode: ApprovalMode | None = None
27
+ role: str = ""
28
+
29
+
30
+ class UiPrefs(BaseModel):
31
+ """What the terminal remembers between runs."""
32
+
33
+ theme: ThemePreference = DEFAULT_THEME
34
+ trusted: list[str] = Field(default_factory=list)
35
+ """Absolute paths the user has approved the agent working in. A folder's children
36
+ are covered by it — a project has subdirectories, and asking again for each one
37
+ trains people to click through the question."""
38
+
39
+ shell_timeout: float = DEFAULT_SHELL_TIMEOUT
40
+ """Seconds a `!` command may run before it is killed. Capped at
41
+ `MAX_SHELL_TIMEOUT`. A client preference sent with each daemon workspace command;
42
+ the command never enters the agent's history."""
43
+
44
+ def is_trusted(self, path: str | Path) -> bool:
45
+ """Whether `path`, or a parent of it, has been trusted.
46
+
47
+ Compared as paths, not as strings: `startswith` would let `/home/me/work`
48
+ trust `/home/me/work-other`, which is a different project.
49
+ """
50
+ target = Path(path).expanduser().resolve()
51
+ for entry in self.trusted:
52
+ root = Path(entry).expanduser()
53
+ if target == root or root in target.parents:
54
+ return True
55
+ return False
56
+
57
+ def trust(self, path: str | Path) -> None:
58
+ """Approve `path`. Idempotent, and stored resolved so `.` never gets in."""
59
+ resolved = str(Path(path).expanduser().resolve())
60
+ if resolved not in self.trusted:
61
+ self.trusted.append(resolved)
File without changes