copper-pilot-cli 0.1.1__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 (39) hide show
  1. copper_pilot_cli/__init__.py +37 -0
  2. copper_pilot_cli/__main__.py +6 -0
  3. copper_pilot_cli/_upstream/__init__.py +0 -0
  4. copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
  5. copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
  6. copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
  7. copper_pilot_cli/_version.py +32 -0
  8. copper_pilot_cli/clipboard.py +107 -0
  9. copper_pilot_cli/copper_api.py +24 -0
  10. copper_pilot_cli/copper_app.py +1071 -0
  11. copper_pilot_cli/copper_auth.py +232 -0
  12. copper_pilot_cli/copper_config.py +62 -0
  13. copper_pilot_cli/copper_features.py +263 -0
  14. copper_pilot_cli/copper_graph.py +178 -0
  15. copper_pilot_cli/copper_hooks.py +49 -0
  16. copper_pilot_cli/copper_main.py +345 -0
  17. copper_pilot_cli/copper_preferences.py +85 -0
  18. copper_pilot_cli/copper_presentation.py +325 -0
  19. copper_pilot_cli/copper_protocol.py +421 -0
  20. copper_pilot_cli/copper_theme.py +34 -0
  21. copper_pilot_cli/copper_tools.py +23 -0
  22. copper_pilot_cli/copper_update.py +32 -0
  23. copper_pilot_cli/copper_widgets.py +1224 -0
  24. copper_pilot_cli/copper_workspace.py +151 -0
  25. copper_pilot_cli/deepagents_tools.py +556 -0
  26. copper_pilot_cli/diagnostics.py +55 -0
  27. copper_pilot_cli/langchain.py +215 -0
  28. copper_pilot_cli/media_utils.py +626 -0
  29. copper_pilot_cli/py.typed +0 -0
  30. copper_pilot_cli/sessions.py +1578 -0
  31. copper_pilot_cli/textual_patches.py +35 -0
  32. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
  33. copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
  34. copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
  35. copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
  36. copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
  37. copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
  38. copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
  39. copper_pilot_cli-0.1.1.dist-info/licenses/NOTICE +13 -0
@@ -0,0 +1,178 @@
1
+ """Local checkpointed graph that projects the hosted agent into dcode sessions."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import operator
7
+ from contextlib import asynccontextmanager
8
+ from pathlib import Path
9
+ from typing import Annotated, Any, TypedDict
10
+
11
+ from langchain_core.messages import AIMessage, ToolMessage
12
+ from langgraph.config import get_stream_writer
13
+ from langgraph.graph import END, START, StateGraph
14
+ from langgraph.graph.message import add_messages
15
+
16
+ from copper_pilot_cli.copper_protocol import EventKind
17
+ from copper_pilot_cli.langchain import CopperPilotAgent
18
+ from copper_pilot_cli.sessions import get_checkpointer
19
+
20
+
21
+ class CopperState(TypedDict, total=False):
22
+ messages: Annotated[list[Any], add_messages]
23
+ copper_mode: str
24
+ copper_events: Annotated[list[dict[str, Any]], operator.add]
25
+
26
+
27
+ class CopperGraphRuntime:
28
+ """Own one compiled transport graph and its SQLite checkpointer."""
29
+
30
+ def __init__(
31
+ self,
32
+ graph: Any,
33
+ checkpointer_context: Any,
34
+ agent: CopperPilotAgent,
35
+ ) -> None:
36
+ self.graph = graph
37
+ self._checkpointer_context = checkpointer_context
38
+ self.agent = agent
39
+
40
+ async def aclose(self) -> None:
41
+ await self._checkpointer_context.__aexit__(None, None, None)
42
+
43
+ def config(self, thread_id: str, workspace: Path) -> dict[str, Any]:
44
+ return {
45
+ "configurable": {"thread_id": thread_id},
46
+ "metadata": {
47
+ "agent_name": "copper-pilot",
48
+ "cwd": str(workspace.resolve()),
49
+ },
50
+ }
51
+
52
+ async def astream(self, values: dict[str, Any], config: dict[str, Any]) -> Any:
53
+ async for item in self.graph.astream(
54
+ values,
55
+ config=config,
56
+ stream_mode=["messages", "updates", "custom"],
57
+ subgraphs=True,
58
+ ):
59
+ yield item
60
+
61
+ async def aget_state(self, config: dict[str, Any]) -> Any:
62
+ return await self.graph.aget_state(config)
63
+
64
+ async def aupdate_state(
65
+ self,
66
+ config: dict[str, Any],
67
+ values: dict[str, Any] | None,
68
+ *,
69
+ as_node: str | None = None,
70
+ ) -> Any:
71
+ return await self.graph.aupdate_state(config, values, as_node=as_node)
72
+
73
+ async def acancel(self) -> None:
74
+ await self.agent.acancel()
75
+
76
+
77
+ @asynccontextmanager
78
+ async def create_copper_graph(agent: CopperPilotAgent) -> Any:
79
+ """Compile a one-node, no-local-model graph using dcode's checkpointer."""
80
+ checkpointer_context = get_checkpointer()
81
+ checkpointer = await checkpointer_context.__aenter__()
82
+
83
+ async def hosted_node(state: CopperState) -> dict[str, Any]:
84
+ writer = get_stream_writer()
85
+ additions: list[Any] = []
86
+ recorded_events: list[dict[str, Any]] = []
87
+ text: list[str] = []
88
+ pending_tools: dict[str, str] = {}
89
+ async for event in agent.astream_events_raw({"messages": state["messages"]}):
90
+ projected = {
91
+ "type": f"copper.{event.kind.value}",
92
+ "data": event.data.model_dump(mode="json")
93
+ if hasattr(event.data, "model_dump")
94
+ else event.data,
95
+ "title": event.title,
96
+ "turn_id": event.turn_id,
97
+ "sequence": event.sequence,
98
+ "resume_attempt": event.resume_attempt,
99
+ }
100
+ writer(projected)
101
+ recorded_events.append(projected)
102
+ if event.kind is EventKind.TEXT:
103
+ text.append(str(event.data))
104
+ elif event.kind is EventKind.TOOL_REQUEST:
105
+ tool = event.data
106
+ additions.append(
107
+ AIMessage(
108
+ content="".join(text),
109
+ tool_calls=[
110
+ {
111
+ "id": tool.tool_call_id,
112
+ "name": tool.tool_name,
113
+ "args": tool.arguments,
114
+ "type": "tool_call",
115
+ }
116
+ ],
117
+ )
118
+ )
119
+ text.clear()
120
+ pending_tools[tool.tool_call_id] = tool.tool_name
121
+ elif event.kind is EventKind.TOOL_RESULT:
122
+ payload = event.data
123
+ result = payload["result"]
124
+ is_error = isinstance(result, dict) and (
125
+ bool(result.get("error"))
126
+ or (isinstance(result.get("exit_code"), int) and result["exit_code"] != 0)
127
+ )
128
+ rejected = isinstance(result, dict) and result.get("rejected") is True
129
+ if isinstance(result, dict):
130
+ content = result.get("error") or result.get("result", result)
131
+ artifact = {
132
+ key: result[key] for key in ("exit_code", "truncated") if key in result
133
+ }
134
+ else:
135
+ content = result
136
+ artifact = {}
137
+ additions.append(
138
+ ToolMessage(
139
+ content=(
140
+ content
141
+ if isinstance(content, (str, list))
142
+ else json.dumps(content, default=str)
143
+ ),
144
+ tool_call_id=payload["tool_call_id"],
145
+ name=payload["tool_name"],
146
+ status="error" if is_error else "success",
147
+ artifact=artifact or None,
148
+ additional_kwargs={"copper_rejected": rejected},
149
+ )
150
+ )
151
+ pending_tools.pop(payload["tool_call_id"], None)
152
+ for tool_id, tool_name in pending_tools.items():
153
+ additions.append(
154
+ ToolMessage(
155
+ content="Tool stream ended before a result was returned.",
156
+ tool_call_id=tool_id,
157
+ name=tool_name,
158
+ status="error",
159
+ )
160
+ )
161
+ if text or not additions:
162
+ additions.append(AIMessage(content="".join(text)))
163
+ return {
164
+ "messages": additions,
165
+ "copper_mode": state.get("copper_mode", agent.mode.value),
166
+ "copper_events": recorded_events,
167
+ }
168
+
169
+ graph = StateGraph(CopperState) # ty: ignore[invalid-argument-type]
170
+ graph.add_node("copper_pilot", hosted_node)
171
+ graph.add_edge(START, "copper_pilot")
172
+ graph.add_edge("copper_pilot", END)
173
+ compiled = graph.compile(checkpointer=checkpointer)
174
+ runtime = CopperGraphRuntime(compiled, checkpointer_context, agent)
175
+ try:
176
+ yield runtime
177
+ finally:
178
+ await runtime.aclose()
@@ -0,0 +1,49 @@
1
+ """Workspace hook support routed through the normal approval broker."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import uuid
7
+ from pathlib import Path
8
+ from typing import Any, Literal
9
+
10
+ from copper_pilot_cli.copper_protocol import ToolRequest
11
+ from copper_pilot_cli.copper_tools import LocalToolBroker
12
+
13
+ HookEvent = Literal["before_turn", "after_turn"]
14
+
15
+
16
+ class HookRunner:
17
+ """Load bounded project hooks and execute them as approved shell tools."""
18
+
19
+ def __init__(self, workspace: Path, broker: LocalToolBroker) -> None:
20
+ self.workspace = workspace
21
+ self.broker = broker
22
+
23
+ def commands(self, event: HookEvent) -> list[str]:
24
+ path = self.workspace / ".copperpilot" / "hooks.json"
25
+ try:
26
+ value = json.loads(path.read_text(encoding="utf-8"))
27
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
28
+ return []
29
+ raw = value.get(event) if isinstance(value, dict) else None
30
+ if not isinstance(raw, list):
31
+ return []
32
+ return [str(command)[:4_000] for command in raw[:16] if str(command).strip()]
33
+
34
+ async def run(self, event: HookEvent) -> list[dict[str, Any]]:
35
+ results: list[dict[str, Any]] = []
36
+ for command in self.commands(event):
37
+ result = await self.broker.execute(
38
+ ToolRequest(
39
+ tool_call_id=f"hook-{event}-{uuid.uuid4()}",
40
+ tool_name="bash",
41
+ arguments={
42
+ "command": command,
43
+ "description": f"CopperPilot {event.replace('_', ' ')} hook",
44
+ "background": False,
45
+ },
46
+ )
47
+ )
48
+ results.append(result if isinstance(result, dict) else {"result": result})
49
+ return results
@@ -0,0 +1,345 @@
1
+ """CopperPilot CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import json
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Any
11
+
12
+ import httpx
13
+ from langchain_core.messages import HumanMessage
14
+ from rich.console import Console
15
+ from rich.prompt import Confirm
16
+
17
+ from copper_pilot_cli._version import __version__
18
+ from copper_pilot_cli.copper_app import CopperPilotApp
19
+ from copper_pilot_cli.copper_auth import (
20
+ AuthenticationError,
21
+ DeviceLogin,
22
+ clear_credential,
23
+ load_credential,
24
+ validate_credential,
25
+ )
26
+ from copper_pilot_cli.copper_graph import create_copper_graph
27
+ from copper_pilot_cli.copper_preferences import saved_approval_mode, shell_auto_allow_settings
28
+ from copper_pilot_cli.copper_protocol import CopperMode, EventKind
29
+ from copper_pilot_cli.copper_tools import ApprovalMode, LocalToolBroker
30
+ from copper_pilot_cli.copper_workspace import (
31
+ canonical_workspace,
32
+ discover_workspace,
33
+ remember_workspace,
34
+ )
35
+ from copper_pilot_cli.diagnostics import configure_diagnostics
36
+ from copper_pilot_cli.langchain import CopperPilotAgent
37
+ from copper_pilot_cli.sessions import delete_thread, generate_thread_id, list_threads
38
+
39
+ console = Console()
40
+
41
+
42
+ def parser() -> argparse.ArgumentParser:
43
+ result = argparse.ArgumentParser(
44
+ prog="copper-pilot",
45
+ description="CopperPilot hosted agent in the terminal.",
46
+ epilog="Subcommands: copper-pilot auth [login|logout|status]; "
47
+ "copper-pilot threads list|delete.",
48
+ )
49
+ result.add_argument(
50
+ "workspace",
51
+ nargs="?",
52
+ default=".",
53
+ help="Workspace directory (default: current directory)",
54
+ )
55
+ result.add_argument("-m", "--message", help="Message to send")
56
+ result.add_argument(
57
+ "-r",
58
+ "--resume",
59
+ nargs="?",
60
+ const="recent",
61
+ help="Resume a thread; omit the id to continue the most recent one",
62
+ )
63
+ result.add_argument(
64
+ "-n",
65
+ "--non-interactive",
66
+ action="store_true",
67
+ help="Run without the Textual UI (requires --message)",
68
+ )
69
+ result.add_argument(
70
+ "--quiet",
71
+ action="store_true",
72
+ help="Hide status and reasoning lines in headless streaming output",
73
+ )
74
+ result.add_argument(
75
+ "--no-stream",
76
+ action="store_true",
77
+ help="Print the final assistant message instead of streaming",
78
+ )
79
+ result.add_argument("--json", action="store_true", help="Emit JSON events")
80
+ result.add_argument(
81
+ "--mode",
82
+ choices=[item.value for item in CopperMode],
83
+ default="agent",
84
+ help="Hosted chat mode (default: agent)",
85
+ )
86
+ result.add_argument(
87
+ "-y",
88
+ "--auto-approve",
89
+ action="store_true",
90
+ help="Permit routine local writes; still prompt for unknown actions",
91
+ )
92
+ result.add_argument(
93
+ "--yolo",
94
+ action="store_true",
95
+ help="Permit local side effects without prompting",
96
+ )
97
+ result.add_argument("--version", action="version", version=__version__)
98
+ result.set_defaults(command=None)
99
+ return result
100
+
101
+
102
+ def command_parser(command: str) -> argparse.ArgumentParser:
103
+ result = argparse.ArgumentParser(
104
+ prog=f"copper-pilot {command}",
105
+ description=(
106
+ "Device login for the hosted CopperPilot agent."
107
+ if command == "auth"
108
+ else "List or delete CopperPilot chat threads."
109
+ ),
110
+ )
111
+ result.set_defaults(workspace=".", message=None, command=command)
112
+ if command == "auth":
113
+ result.add_argument(
114
+ "action",
115
+ nargs="?",
116
+ choices=["login", "logout", "status"],
117
+ default="status",
118
+ help="login opens a browser; logout clears credentials; status is default",
119
+ )
120
+ return result
121
+ thread_sub = result.add_subparsers(dest="thread_action", required=True)
122
+ listing = thread_sub.add_parser("list", help="List threads for this workspace")
123
+ listing.add_argument(
124
+ "--all",
125
+ action="store_true",
126
+ help="Include threads from other workspaces",
127
+ )
128
+ listing.add_argument(
129
+ "--limit",
130
+ type=int,
131
+ default=20,
132
+ help="Maximum rows to print (default: 20)",
133
+ )
134
+ deleting = thread_sub.add_parser("delete", help="Delete a thread")
135
+ deleting.add_argument("thread_id", help="Thread identifier to delete")
136
+ deleting.add_argument(
137
+ "-y",
138
+ "--yes",
139
+ action="store_true",
140
+ help="Do not prompt for confirmation",
141
+ )
142
+ return result
143
+
144
+
145
+ async def _auth_command(action: str) -> int:
146
+ if action == "logout":
147
+ console.print(
148
+ "Logged out." if clear_credential() else "Already logged out.",
149
+ markup=False,
150
+ )
151
+ return 0
152
+ if action == "status":
153
+ credential = load_credential()
154
+ console.print(
155
+ f"Authenticated to {credential.base_url}."
156
+ if credential
157
+ else "Not authenticated. Run `copper-pilot auth login`.",
158
+ markup=False,
159
+ )
160
+ return 0 if credential else 1
161
+ console.print("Opening CopperPilot login in your browser…", markup=False)
162
+ credential = await DeviceLogin(client_version=__version__).login()
163
+ console.print(f"Authenticated to {credential.base_url}.", markup=False)
164
+ return 0
165
+
166
+
167
+ async def _threads_command(args: argparse.Namespace, workspace: Path) -> int:
168
+ if args.thread_action == "delete":
169
+ if not args.yes:
170
+ if not sys.stdin.isatty():
171
+ console.print(
172
+ "Use --yes to confirm deletion in non-interactive mode.",
173
+ markup=False,
174
+ )
175
+ return 2
176
+ if not Confirm.ask(f"Delete chat {args.thread_id}?"):
177
+ return 0
178
+ deleted = await delete_thread(args.thread_id)
179
+ console.print("Deleted." if deleted else "Thread not found.", markup=False)
180
+ return 0 if deleted else 1
181
+ rows = await list_threads(
182
+ limit=args.limit,
183
+ include_message_count=True,
184
+ cwd=None if args.all else str(workspace),
185
+ )
186
+ for row in rows:
187
+ console.print(
188
+ f"{row['thread_id']} {row.get('updated_at') or ''} "
189
+ f"{row.get('message_count', 0)} messages {row.get('initial_prompt') or ''}",
190
+ markup=False,
191
+ highlight=False,
192
+ )
193
+ return 0
194
+
195
+
196
+ async def _credential() -> Any:
197
+ credential = load_credential()
198
+ if credential is not None:
199
+ try:
200
+ if await validate_credential(credential):
201
+ return credential
202
+ except httpx.HTTPError as exc:
203
+ raise AuthenticationError(f"Could not reach CopperPilot: {exc}") from exc
204
+ clear_credential()
205
+ console.print("Your CopperPilot login expired; signing in again.", markup=False)
206
+ if not sys.stdin.isatty():
207
+ raise AuthenticationError("Login required; run `copper-pilot auth login` interactively.")
208
+ console.print("Opening CopperPilot login in your browser…", markup=False)
209
+ return await DeviceLogin(client_version=__version__).login()
210
+
211
+
212
+ async def _resolve_thread(resume: str | None, workspace: Path) -> str:
213
+ if resume and resume != "recent":
214
+ return resume
215
+ if resume == "recent":
216
+ rows = await list_threads(limit=1, cwd=str(workspace))
217
+ if rows:
218
+ return rows[0]["thread_id"]
219
+ return generate_thread_id()
220
+
221
+
222
+ async def _headless(
223
+ runtime: Any,
224
+ workspace: Path,
225
+ thread_id: str,
226
+ message: str,
227
+ args: argparse.Namespace,
228
+ ) -> int:
229
+ config = runtime.config(thread_id, workspace)
230
+ events: list[dict[str, Any]] = []
231
+ failed = False
232
+ async for item in runtime.astream(
233
+ {"messages": [HumanMessage(content=message)], "copper_mode": args.mode},
234
+ config,
235
+ ):
236
+ _namespace, stream_mode, data = item
237
+ if stream_mode != "custom" or not isinstance(data, dict):
238
+ continue
239
+ events.append(data)
240
+ kind = str(data.get("type") or "").removeprefix("copper.")
241
+ payload = data.get("data")
242
+ if kind == EventKind.ERROR.value:
243
+ failed = True
244
+ elif kind == EventKind.FINAL.value and isinstance(payload, dict):
245
+ failed = failed or payload.get("success") is False or bool(payload.get("error"))
246
+ if args.no_stream:
247
+ continue
248
+ if args.json:
249
+ print(json.dumps({"type": kind, "data": payload, "title": data.get("title")}))
250
+ elif kind == EventKind.TEXT.value:
251
+ print(str(payload), end="", flush=True)
252
+ elif not args.quiet and kind in {
253
+ EventKind.STATUS.value,
254
+ EventKind.REASONING.value,
255
+ }:
256
+ console.print(f"\n{payload}", style="dim", markup=False, highlight=False)
257
+
258
+ state = await runtime.aget_state(config)
259
+ messages = (getattr(state, "values", {}) or {}).get("messages", [])
260
+ assistant_content = str(messages[-1].content) if messages else ""
261
+ if args.no_stream:
262
+ if args.json:
263
+ print(
264
+ json.dumps(
265
+ {
266
+ "messages": [{"role": "assistant", "content": assistant_content}],
267
+ "events": events,
268
+ }
269
+ )
270
+ )
271
+ else:
272
+ console.print(assistant_content, markup=False, highlight=False)
273
+ elif not args.json:
274
+ print()
275
+ return 1 if failed else 0
276
+
277
+
278
+ async def async_main(args: argparse.Namespace) -> int:
279
+ workspace = canonical_workspace(args.workspace)
280
+ if args.command == "auth":
281
+ return await _auth_command(args.action)
282
+ if args.command == "threads":
283
+ return await _threads_command(args, workspace)
284
+ credential = await _credential()
285
+ context = discover_workspace(workspace)
286
+ remember_workspace(context)
287
+ approval = (
288
+ ApprovalMode.YOLO
289
+ if args.yolo
290
+ else ApprovalMode.AUTO
291
+ if args.auto_approve
292
+ else saved_approval_mode()
293
+ )
294
+ normal_shell, dangerous_shell = shell_auto_allow_settings()
295
+ broker = LocalToolBroker(
296
+ workspace,
297
+ mode=approval,
298
+ always_allow_shell_commands=normal_shell,
299
+ always_allow_dangerous_shell_commands=dangerous_shell,
300
+ )
301
+ thread_id = await _resolve_thread(args.resume, workspace)
302
+ agent = CopperPilotAgent(
303
+ workspace,
304
+ credential=credential,
305
+ conversation_id=thread_id,
306
+ mode=CopperMode(args.mode),
307
+ tool_broker=broker,
308
+ )
309
+ if args.non_interactive or (args.message and not sys.stdout.isatty()):
310
+ if not args.message:
311
+ raise ValueError("--message is required in non-interactive mode.")
312
+ async with create_copper_graph(agent) as runtime:
313
+ return await _headless(runtime, workspace, thread_id, args.message, args)
314
+ async with create_copper_graph(agent) as runtime:
315
+ app = CopperPilotApp(
316
+ runtime,
317
+ broker,
318
+ workspace,
319
+ thread_id,
320
+ mode=CopperMode(args.mode),
321
+ initial_message=args.message,
322
+ )
323
+ await app.run_async()
324
+ return 0
325
+
326
+
327
+ def cli_main() -> None:
328
+ """Run the CopperPilot command."""
329
+ configure_diagnostics()
330
+ argv = sys.argv[1:]
331
+ args = (
332
+ command_parser(argv[0]).parse_args(argv[1:])
333
+ if argv and argv[0] in {"auth", "threads"}
334
+ else parser().parse_args(argv)
335
+ )
336
+ try:
337
+ raise SystemExit(asyncio.run(async_main(args)))
338
+ except (AuthenticationError, FileNotFoundError, NotADirectoryError, ValueError) as exc:
339
+ console.print(f"copper-pilot: {exc}", style="red", markup=False, highlight=False)
340
+ raise SystemExit(2) from exc
341
+ except (RuntimeError, httpx.HTTPError) as exc:
342
+ console.print(f"copper-pilot: {exc}", style="red", markup=False, highlight=False)
343
+ raise SystemExit(1) from exc
344
+ except KeyboardInterrupt:
345
+ raise SystemExit(130) from None
@@ -0,0 +1,85 @@
1
+ """Small non-secret CLI preference store."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import stat
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ from copper_pilot_cli.copper_config import paths
11
+ from copper_pilot_cli.copper_tools import ApprovalMode
12
+
13
+ YOLO_ACK_VERSION = 1
14
+ AUTO_NOTICE_VERSION = 1
15
+
16
+
17
+ def _path() -> Path:
18
+ return paths().state / "config.json"
19
+
20
+
21
+ def load_preferences() -> dict[str, Any]:
22
+ try:
23
+ value = json.loads(_path().read_text(encoding="utf-8"))
24
+ except (FileNotFoundError, OSError, json.JSONDecodeError):
25
+ return {}
26
+ return value if isinstance(value, dict) else {}
27
+
28
+
29
+ def save_preferences(value: dict[str, Any]) -> None:
30
+ destination = _path()
31
+ destination.parent.mkdir(parents=True, exist_ok=True)
32
+ destination.parent.chmod(stat.S_IRWXU)
33
+ temporary = destination.with_suffix(".tmp")
34
+ temporary.write_text(json.dumps(value, indent=2), encoding="utf-8")
35
+ temporary.chmod(stat.S_IRUSR | stat.S_IWUSR)
36
+ temporary.replace(destination)
37
+
38
+
39
+ def saved_approval_mode() -> ApprovalMode:
40
+ raw = load_preferences().get("approval_mode")
41
+ return ApprovalMode.AUTO if raw == ApprovalMode.AUTO.value else ApprovalMode.MANUAL
42
+
43
+
44
+ def save_approval_mode(mode: ApprovalMode) -> None:
45
+ value = load_preferences()
46
+ value["approval_mode"] = (
47
+ ApprovalMode.AUTO.value if mode is ApprovalMode.AUTO else ApprovalMode.MANUAL.value
48
+ )
49
+ save_preferences(value)
50
+
51
+
52
+ def shell_auto_allow_settings() -> tuple[bool, bool]:
53
+ """Return CLI-owned normal and dangerous shell Auto settings."""
54
+ value = load_preferences()
55
+ return (
56
+ value.get("always_allow_shell_commands", True) is True,
57
+ value.get("always_allow_dangerous_shell_commands", False) is True,
58
+ )
59
+
60
+
61
+ def save_shell_auto_allow_settings(*, normal: bool, dangerous: bool) -> None:
62
+ value = load_preferences()
63
+ value["always_allow_shell_commands"] = normal
64
+ value["always_allow_dangerous_shell_commands"] = dangerous
65
+ save_preferences(value)
66
+
67
+
68
+ def auto_notice_acknowledged() -> bool:
69
+ return load_preferences().get("auto_notice_version") == AUTO_NOTICE_VERSION
70
+
71
+
72
+ def acknowledge_auto_notice() -> None:
73
+ value = load_preferences()
74
+ value["auto_notice_version"] = AUTO_NOTICE_VERSION
75
+ save_preferences(value)
76
+
77
+
78
+ def yolo_acknowledged() -> bool:
79
+ return load_preferences().get("yolo_ack_version") == YOLO_ACK_VERSION
80
+
81
+
82
+ def acknowledge_yolo() -> None:
83
+ value = load_preferences()
84
+ value["yolo_ack_version"] = YOLO_ACK_VERSION
85
+ save_preferences(value)