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.
- copper_pilot_cli/__init__.py +37 -0
- copper_pilot_cli/__main__.py +6 -0
- copper_pilot_cli/_upstream/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/PROVENANCE.json +46 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/__init__.py +0 -0
- copper_pilot_cli/_upstream/dcode_0_1_69/diff_utils.py +222 -0
- copper_pilot_cli/_version.py +32 -0
- copper_pilot_cli/clipboard.py +107 -0
- copper_pilot_cli/copper_api.py +24 -0
- copper_pilot_cli/copper_app.py +1071 -0
- copper_pilot_cli/copper_auth.py +232 -0
- copper_pilot_cli/copper_config.py +62 -0
- copper_pilot_cli/copper_features.py +263 -0
- copper_pilot_cli/copper_graph.py +178 -0
- copper_pilot_cli/copper_hooks.py +49 -0
- copper_pilot_cli/copper_main.py +345 -0
- copper_pilot_cli/copper_preferences.py +85 -0
- copper_pilot_cli/copper_presentation.py +325 -0
- copper_pilot_cli/copper_protocol.py +421 -0
- copper_pilot_cli/copper_theme.py +34 -0
- copper_pilot_cli/copper_tools.py +23 -0
- copper_pilot_cli/copper_update.py +32 -0
- copper_pilot_cli/copper_widgets.py +1224 -0
- copper_pilot_cli/copper_workspace.py +151 -0
- copper_pilot_cli/deepagents_tools.py +556 -0
- copper_pilot_cli/diagnostics.py +55 -0
- copper_pilot_cli/langchain.py +215 -0
- copper_pilot_cli/media_utils.py +626 -0
- copper_pilot_cli/py.typed +0 -0
- copper_pilot_cli/sessions.py +1578 -0
- copper_pilot_cli/textual_patches.py +35 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/NOTICE +13 -0
- copper_pilot_cli-0.1.1.data/data/share/doc/copper-pilot-cli/UPSTREAM.md +44 -0
- copper_pilot_cli-0.1.1.dist-info/METADATA +211 -0
- copper_pilot_cli-0.1.1.dist-info/RECORD +39 -0
- copper_pilot_cli-0.1.1.dist-info/WHEEL +4 -0
- copper_pilot_cli-0.1.1.dist-info/entry_points.txt +3 -0
- copper_pilot_cli-0.1.1.dist-info/licenses/LICENSE +22 -0
- 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)
|