zjcode 0.0.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.
- deepagents_code/__init__.py +42 -0
- deepagents_code/__main__.py +6 -0
- deepagents_code/_ask_user_types.py +90 -0
- deepagents_code/_cli_context.py +96 -0
- deepagents_code/_constants.py +41 -0
- deepagents_code/_debug.py +148 -0
- deepagents_code/_debug_buffer.py +204 -0
- deepagents_code/_env_vars.py +411 -0
- deepagents_code/_fake_models.py +66 -0
- deepagents_code/_git.py +521 -0
- deepagents_code/_paths.py +69 -0
- deepagents_code/_server_config.py +576 -0
- deepagents_code/_session_stats.py +235 -0
- deepagents_code/_startup_error.py +45 -0
- deepagents_code/_testing_models.py +305 -0
- deepagents_code/_textual_patches.py +420 -0
- deepagents_code/_tool_stream.py +694 -0
- deepagents_code/_version.py +46 -0
- deepagents_code/agent.py +1976 -0
- deepagents_code/app.py +19239 -0
- deepagents_code/app.tcss +438 -0
- deepagents_code/approval_mode.py +131 -0
- deepagents_code/ask_user.py +306 -0
- deepagents_code/auth_display.py +185 -0
- deepagents_code/auth_store.py +545 -0
- deepagents_code/built_in_skills/__init__.py +5 -0
- deepagents_code/built_in_skills/remember/SKILL.md +118 -0
- deepagents_code/built_in_skills/skill-creator/SKILL.md +383 -0
- deepagents_code/built_in_skills/skill-creator/scripts/init_skill.py +366 -0
- deepagents_code/built_in_skills/skill-creator/scripts/quick_validate.py +158 -0
- deepagents_code/client/__init__.py +1 -0
- deepagents_code/client/commands/__init__.py +1 -0
- deepagents_code/client/commands/auth.py +541 -0
- deepagents_code/client/commands/config.py +714 -0
- deepagents_code/client/commands/mcp.py +250 -0
- deepagents_code/client/commands/tools.py +416 -0
- deepagents_code/client/launch/__init__.py +1 -0
- deepagents_code/client/launch/server.py +978 -0
- deepagents_code/client/launch/server_manager.py +540 -0
- deepagents_code/client/non_interactive.py +1758 -0
- deepagents_code/client/remote_client.py +794 -0
- deepagents_code/clipboard.py +217 -0
- deepagents_code/command_registry.py +450 -0
- deepagents_code/config.py +4829 -0
- deepagents_code/config_manifest.py +1451 -0
- deepagents_code/configurable_model.py +577 -0
- deepagents_code/default_agent_prompt.md +12 -0
- deepagents_code/doctor.py +559 -0
- deepagents_code/editor.py +142 -0
- deepagents_code/event_bus.py +411 -0
- deepagents_code/extras_info.py +661 -0
- deepagents_code/file_ops.py +576 -0
- deepagents_code/formatting.py +135 -0
- deepagents_code/goal_rubric.py +497 -0
- deepagents_code/goal_tools.py +459 -0
- deepagents_code/hooks.py +348 -0
- deepagents_code/input.py +1041 -0
- deepagents_code/integrations/__init__.py +1 -0
- deepagents_code/integrations/openai_codex.py +551 -0
- deepagents_code/integrations/sandbox_config.py +198 -0
- deepagents_code/integrations/sandbox_factory.py +1124 -0
- deepagents_code/integrations/sandbox_provider.py +137 -0
- deepagents_code/integrations/sandbox_registry.py +350 -0
- deepagents_code/iterm_cursor_guide.py +176 -0
- deepagents_code/local_context.py +926 -0
- deepagents_code/main.py +3985 -0
- deepagents_code/managed_tools.py +642 -0
- deepagents_code/mcp_auth.py +1950 -0
- deepagents_code/mcp_config.py +176 -0
- deepagents_code/mcp_disabled.py +212 -0
- deepagents_code/mcp_login_service.py +281 -0
- deepagents_code/mcp_oauth_ui.py +199 -0
- deepagents_code/mcp_providers/__init__.py +23 -0
- deepagents_code/mcp_providers/_registry.py +39 -0
- deepagents_code/mcp_providers/base.py +133 -0
- deepagents_code/mcp_providers/github.py +102 -0
- deepagents_code/mcp_providers/slack.py +175 -0
- deepagents_code/mcp_tools.py +2427 -0
- deepagents_code/mcp_trust.py +207 -0
- deepagents_code/media_utils.py +826 -0
- deepagents_code/memory_guard.py +474 -0
- deepagents_code/model_config.py +4156 -0
- deepagents_code/notifications.py +247 -0
- deepagents_code/offload.py +402 -0
- deepagents_code/onboarding.py +223 -0
- deepagents_code/output.py +69 -0
- deepagents_code/paste_collapse.py +103 -0
- deepagents_code/project_utils.py +231 -0
- deepagents_code/py.typed +0 -0
- deepagents_code/reasoning_effort.py +641 -0
- deepagents_code/reliable_rubric.py +97 -0
- deepagents_code/resume_state.py +237 -0
- deepagents_code/server_graph.py +310 -0
- deepagents_code/session_end_summary.py +329 -0
- deepagents_code/sessions.py +1716 -0
- deepagents_code/skills/__init__.py +18 -0
- deepagents_code/skills/commands.py +1252 -0
- deepagents_code/skills/invocation.py +112 -0
- deepagents_code/skills/load.py +196 -0
- deepagents_code/skills/trust.py +547 -0
- deepagents_code/state_migration.py +136 -0
- deepagents_code/subagents.py +278 -0
- deepagents_code/system_prompt.md +204 -0
- deepagents_code/terminal_capabilities.py +115 -0
- deepagents_code/terminal_escape.py +287 -0
- deepagents_code/theme.py +891 -0
- deepagents_code/todo_list_prompt.md +12 -0
- deepagents_code/tool_catalog.py +509 -0
- deepagents_code/tool_display.py +367 -0
- deepagents_code/tools.py +516 -0
- deepagents_code/tui/__init__.py +1 -0
- deepagents_code/tui/textual_adapter.py +2553 -0
- deepagents_code/tui/widgets/__init__.py +9 -0
- deepagents_code/tui/widgets/_js_eval_display.py +139 -0
- deepagents_code/tui/widgets/_links.py +261 -0
- deepagents_code/tui/widgets/agent_selector.py +420 -0
- deepagents_code/tui/widgets/approval.py +602 -0
- deepagents_code/tui/widgets/ask_user.py +515 -0
- deepagents_code/tui/widgets/auth.py +1997 -0
- deepagents_code/tui/widgets/autocomplete.py +890 -0
- deepagents_code/tui/widgets/chat_input.py +3181 -0
- deepagents_code/tui/widgets/codex_auth.py +452 -0
- deepagents_code/tui/widgets/cwd_switch.py +242 -0
- deepagents_code/tui/widgets/debug_console.py +868 -0
- deepagents_code/tui/widgets/diff.py +252 -0
- deepagents_code/tui/widgets/effort_selector.py +189 -0
- deepagents_code/tui/widgets/goal_review.py +390 -0
- deepagents_code/tui/widgets/goal_status.py +50 -0
- deepagents_code/tui/widgets/history.py +194 -0
- deepagents_code/tui/widgets/install_confirm.py +248 -0
- deepagents_code/tui/widgets/launch_init.py +482 -0
- deepagents_code/tui/widgets/loading.py +227 -0
- deepagents_code/tui/widgets/mcp_login.py +539 -0
- deepagents_code/tui/widgets/mcp_reconnect.py +212 -0
- deepagents_code/tui/widgets/mcp_viewer.py +1634 -0
- deepagents_code/tui/widgets/message_store.py +999 -0
- deepagents_code/tui/widgets/messages.py +3846 -0
- deepagents_code/tui/widgets/model_selector.py +2343 -0
- deepagents_code/tui/widgets/notification_center.py +456 -0
- deepagents_code/tui/widgets/notification_detail.py +257 -0
- deepagents_code/tui/widgets/notification_settings.py +170 -0
- deepagents_code/tui/widgets/restart_prompt.py +158 -0
- deepagents_code/tui/widgets/skill_trust.py +131 -0
- deepagents_code/tui/widgets/startup_tip.py +91 -0
- deepagents_code/tui/widgets/status.py +781 -0
- deepagents_code/tui/widgets/subagent_panel.py +969 -0
- deepagents_code/tui/widgets/theme_selector.py +401 -0
- deepagents_code/tui/widgets/thread_selector.py +2564 -0
- deepagents_code/tui/widgets/tool_renderers.py +190 -0
- deepagents_code/tui/widgets/tool_widgets.py +304 -0
- deepagents_code/tui/widgets/update_available.py +311 -0
- deepagents_code/tui/widgets/update_confirm.py +184 -0
- deepagents_code/tui/widgets/update_progress.py +327 -0
- deepagents_code/tui/widgets/welcome.py +508 -0
- deepagents_code/turn_end_summary.py +522 -0
- deepagents_code/ui.py +858 -0
- deepagents_code/unicode_security.py +563 -0
- deepagents_code/update_check.py +3124 -0
- zjcode-0.0.1.data/data/deepagents_code/default_agent_prompt.md +12 -0
- zjcode-0.0.1.dist-info/METADATA +220 -0
- zjcode-0.0.1.dist-info/RECORD +163 -0
- zjcode-0.0.1.dist-info/WHEEL +4 -0
- zjcode-0.0.1.dist-info/entry_points.txt +2 -0
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
"""User-level credential storage for model providers.
|
|
2
|
+
|
|
3
|
+
Persists API keys (and, in the future, OAuth tokens) under
|
|
4
|
+
`~/.deepagents/.state/auth.json` (file mode 0600, parent 0700) so users can
|
|
5
|
+
enter credentials directly in the TUI rather than exporting environment
|
|
6
|
+
variables before launch.
|
|
7
|
+
|
|
8
|
+
Security notes:
|
|
9
|
+
|
|
10
|
+
- The stored value (`ApiKeyCredential.key`) must never be logged, formatted
|
|
11
|
+
via `%r`/`!r`, or interpolated into exception messages — every helper here
|
|
12
|
+
reports only structural facts ("set credential for provider X").
|
|
13
|
+
- The file is written via `O_EXCL | 0o600` to a temp path, then atomically
|
|
14
|
+
replaced. A second `chmod 0600` runs on the final path so filesystems that
|
|
15
|
+
ignore the create-mode argument still end up with private perms. Permission
|
|
16
|
+
failures are reported back to the caller in `WriteOutcome.warnings` so the
|
|
17
|
+
UI can surface them to the user — `logger.warning` alone is invisible
|
|
18
|
+
inside a Textual TUI session.
|
|
19
|
+
- On Windows, POSIX mode bits don't apply; the chmod calls are best-effort
|
|
20
|
+
and skipped silently.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import contextlib
|
|
26
|
+
import json
|
|
27
|
+
import logging
|
|
28
|
+
import os
|
|
29
|
+
import stat
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from datetime import UTC, datetime
|
|
32
|
+
from typing import TYPE_CHECKING, Any, Literal, NotRequired, TypedDict
|
|
33
|
+
|
|
34
|
+
if TYPE_CHECKING:
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
logger = logging.getLogger(__name__)
|
|
38
|
+
|
|
39
|
+
_STORAGE_VERSION = 1
|
|
40
|
+
"""Schema version stamped into `auth.json`; bump on incompatible shape changes."""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ApiKeyCredential(TypedDict):
|
|
44
|
+
"""A persisted API key credential.
|
|
45
|
+
|
|
46
|
+
The `type` field is the discriminator that lets `OAuthCredential` (added
|
|
47
|
+
later) coexist in the same file without migration.
|
|
48
|
+
"""
|
|
49
|
+
|
|
50
|
+
type: Literal["api_key"]
|
|
51
|
+
"""Credential kind discriminator."""
|
|
52
|
+
|
|
53
|
+
key: str
|
|
54
|
+
"""The API key value as entered by the user. Never log this field."""
|
|
55
|
+
|
|
56
|
+
added_at: str
|
|
57
|
+
"""ISO-8601 UTC timestamp recording when the credential was stored."""
|
|
58
|
+
|
|
59
|
+
base_url: NotRequired[str]
|
|
60
|
+
"""Optional provider endpoint paired with this key.
|
|
61
|
+
|
|
62
|
+
Stored only when the user supplied one in `/auth`. A key and its endpoint
|
|
63
|
+
form a coherent pair — applying the key also applies (or, when this is
|
|
64
|
+
absent, resets to the provider default) the base URL, so a personal key is
|
|
65
|
+
never sent to a gateway it doesn't belong to. Not treated as a secret (it is
|
|
66
|
+
logged when malformed and surfaced in hints); avoid embedding credentials in
|
|
67
|
+
the URL.
|
|
68
|
+
"""
|
|
69
|
+
|
|
70
|
+
project: NotRequired[str]
|
|
71
|
+
"""Optional LangSmith project name paired with this credential.
|
|
72
|
+
|
|
73
|
+
Set only for the `langsmith` tracing service when the user supplies a
|
|
74
|
+
custom project in `/auth`; absent means traces fall back to the default
|
|
75
|
+
(`deepagents-code`). Not a secret — it is shown in the `/auth` advanced
|
|
76
|
+
panel and applied to `LANGSMITH_PROJECT` at startup.
|
|
77
|
+
"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class OAuthCredential(TypedDict):
|
|
81
|
+
"""A persisted OAuth subscription credential.
|
|
82
|
+
|
|
83
|
+
Stub kept here so the `StoredCredential` discriminated union narrows
|
|
84
|
+
correctly today and the OAuth implementation lands as a pure addition.
|
|
85
|
+
No code path produces or consumes this shape yet.
|
|
86
|
+
"""
|
|
87
|
+
|
|
88
|
+
type: Literal["oauth"]
|
|
89
|
+
"""Credential kind discriminator."""
|
|
90
|
+
|
|
91
|
+
access_token: str
|
|
92
|
+
"""OAuth access token. Never log."""
|
|
93
|
+
|
|
94
|
+
refresh_token: str
|
|
95
|
+
"""OAuth refresh token. Never log."""
|
|
96
|
+
|
|
97
|
+
expires_at: str
|
|
98
|
+
"""ISO-8601 UTC expiry timestamp."""
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
StoredCredential = ApiKeyCredential | OAuthCredential
|
|
102
|
+
"""Tagged union of every persisted credential shape, narrowed by `type`."""
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
@dataclass(frozen=True, slots=True)
|
|
106
|
+
class WriteOutcome:
|
|
107
|
+
"""Result of a credential write that may have warnings to surface."""
|
|
108
|
+
|
|
109
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
110
|
+
"""User-visible warning strings (e.g., chmod failures). Empty on success."""
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@dataclass(frozen=True, slots=True)
|
|
114
|
+
class DeleteOutcome:
|
|
115
|
+
"""Result of a credential delete that may have warnings to surface.
|
|
116
|
+
|
|
117
|
+
A delete rewrites the whole store, so it can hit the same chmod failures as
|
|
118
|
+
a write. `warnings` carries them symmetrically with `WriteOutcome` so a
|
|
119
|
+
caller's "removed" confirmation doesn't paper over a store the delete
|
|
120
|
+
failed to lock down to owner-only.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
removed: bool
|
|
124
|
+
"""`True` if a credential was removed, `False` if none was stored."""
|
|
125
|
+
|
|
126
|
+
warnings: tuple[str, ...] = field(default_factory=tuple)
|
|
127
|
+
"""chmod-failure warnings from the rewrite. Always empty for a no-op delete
|
|
128
|
+
(`removed=False`), which performs no write."""
|
|
129
|
+
|
|
130
|
+
def __post_init__(self) -> None:
|
|
131
|
+
"""Reject the one field combination the docstring forbids.
|
|
132
|
+
|
|
133
|
+
A no-op delete performs no write, so it can raise no chmod warnings.
|
|
134
|
+
Enforcing it here makes "no warnings when nothing was removed" a
|
|
135
|
+
construction-time guarantee rather than a producer-side convention a
|
|
136
|
+
future edit (or a second producer) could quietly break.
|
|
137
|
+
|
|
138
|
+
Raises:
|
|
139
|
+
ValueError: If `warnings` is non-empty while `removed` is `False`.
|
|
140
|
+
"""
|
|
141
|
+
if self.warnings and not self.removed:
|
|
142
|
+
msg = "DeleteOutcome cannot carry warnings when removed=False"
|
|
143
|
+
raise ValueError(msg)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def auth_path() -> Path:
|
|
147
|
+
"""Return the resolved path to the credential store (`auth.json`).
|
|
148
|
+
|
|
149
|
+
Resolved at call time (not import time) so tests can redirect storage by
|
|
150
|
+
monkeypatching `deepagents_code.model_config.DEFAULT_STATE_DIR` — same
|
|
151
|
+
pattern `mcp_auth._tokens_dir` uses.
|
|
152
|
+
"""
|
|
153
|
+
from deepagents_code.model_config import DEFAULT_STATE_DIR
|
|
154
|
+
|
|
155
|
+
return DEFAULT_STATE_DIR / "auth.json"
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _read_raw() -> dict | None:
|
|
159
|
+
"""Read and validate the on-disk auth file.
|
|
160
|
+
|
|
161
|
+
Returns:
|
|
162
|
+
The decoded JSON object, or `None` when the file is missing.
|
|
163
|
+
|
|
164
|
+
Raises:
|
|
165
|
+
RuntimeError: If the file exists but cannot be parsed or has an
|
|
166
|
+
unsupported schema version.
|
|
167
|
+
"""
|
|
168
|
+
path = auth_path()
|
|
169
|
+
try:
|
|
170
|
+
raw = path.read_text(encoding="utf-8")
|
|
171
|
+
data = json.loads(raw)
|
|
172
|
+
except FileNotFoundError:
|
|
173
|
+
return None
|
|
174
|
+
except OSError as exc:
|
|
175
|
+
msg = (
|
|
176
|
+
f"Failed to read credential file {path}: {exc}. "
|
|
177
|
+
"Check the file permissions on the parent directory."
|
|
178
|
+
)
|
|
179
|
+
raise RuntimeError(msg) from exc
|
|
180
|
+
except (UnicodeDecodeError, json.JSONDecodeError) as exc:
|
|
181
|
+
# `UnicodeDecodeError` (a `ValueError`, not an `OSError`) escapes the
|
|
182
|
+
# handler above when the file holds non-UTF-8 bytes; treat a decode
|
|
183
|
+
# failure as corruption so callers get the same `RuntimeError` hint
|
|
184
|
+
# instead of an unhandled traceback.
|
|
185
|
+
msg = (
|
|
186
|
+
f"Failed to parse credential file {path}: {exc}. "
|
|
187
|
+
"Delete the file and re-add credentials via /auth if it is corrupt."
|
|
188
|
+
)
|
|
189
|
+
raise RuntimeError(msg) from exc
|
|
190
|
+
if not isinstance(data, dict):
|
|
191
|
+
msg = (
|
|
192
|
+
f"Credential file {path} is not a JSON object. "
|
|
193
|
+
"Delete it and re-add credentials via /auth."
|
|
194
|
+
)
|
|
195
|
+
# `RuntimeError` (not `TypeError`) is intentional: every corruption
|
|
196
|
+
# path here surfaces the same error class so callers can render one
|
|
197
|
+
# remediation hint regardless of the specific shape problem.
|
|
198
|
+
raise RuntimeError(msg) # noqa: TRY004
|
|
199
|
+
version = data.get("version")
|
|
200
|
+
if version != _STORAGE_VERSION:
|
|
201
|
+
msg = (
|
|
202
|
+
f"Credential file {path} has unsupported version {version!r} "
|
|
203
|
+
f"(expected {_STORAGE_VERSION}). Delete it and re-add credentials via "
|
|
204
|
+
"/auth."
|
|
205
|
+
)
|
|
206
|
+
raise RuntimeError(msg)
|
|
207
|
+
return data
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _write_raw(data: dict) -> tuple[str, ...]:
|
|
211
|
+
"""Atomically write `data` as the new auth file with 0600 perms.
|
|
212
|
+
|
|
213
|
+
Mirrors `mcp_auth.FileTokenStorage._write` so the security posture is
|
|
214
|
+
consistent across both stores. If you change this, update
|
|
215
|
+
`mcp_auth.FileTokenStorage._write` too — they share threat model.
|
|
216
|
+
|
|
217
|
+
Returns:
|
|
218
|
+
Tuple of warning strings for chmod failures the caller should
|
|
219
|
+
surface to the user. Empty when permissions were locked down
|
|
220
|
+
successfully (or on Windows where POSIX modes don't apply).
|
|
221
|
+
"""
|
|
222
|
+
path = auth_path()
|
|
223
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
224
|
+
warnings: list[str] = []
|
|
225
|
+
if hasattr(os, "chmod"):
|
|
226
|
+
try:
|
|
227
|
+
path.parent.chmod(stat.S_IRWXU)
|
|
228
|
+
except OSError as exc:
|
|
229
|
+
warnings.append(
|
|
230
|
+
f"Could not set mode 0700 on {path.parent}: {exc}. "
|
|
231
|
+
"Stored API keys may be readable by other local users."
|
|
232
|
+
)
|
|
233
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
234
|
+
payload = json.dumps(data, separators=(",", ":")).encode("utf-8")
|
|
235
|
+
flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL
|
|
236
|
+
if tmp.exists():
|
|
237
|
+
# A leftover `.tmp` from a prior crashed write is the only path
|
|
238
|
+
# `os.open(O_EXCL)` can fail without an actual conflict. Log so the
|
|
239
|
+
# operator knows a stale write was cleaned up — silent suppression
|
|
240
|
+
# masked recovery from a previous interrupted save.
|
|
241
|
+
logger.warning(
|
|
242
|
+
"Removing stale credential temp file %s left over from a prior write",
|
|
243
|
+
tmp,
|
|
244
|
+
)
|
|
245
|
+
with contextlib.suppress(OSError):
|
|
246
|
+
tmp.unlink()
|
|
247
|
+
fd = os.open(str(tmp), flags, 0o600)
|
|
248
|
+
try:
|
|
249
|
+
with os.fdopen(fd, "wb") as fh:
|
|
250
|
+
fh.write(payload)
|
|
251
|
+
except Exception:
|
|
252
|
+
with contextlib.suppress(OSError):
|
|
253
|
+
tmp.unlink()
|
|
254
|
+
raise
|
|
255
|
+
try:
|
|
256
|
+
tmp.replace(path)
|
|
257
|
+
except Exception:
|
|
258
|
+
with contextlib.suppress(OSError):
|
|
259
|
+
tmp.unlink()
|
|
260
|
+
raise
|
|
261
|
+
if hasattr(os, "chmod"):
|
|
262
|
+
try:
|
|
263
|
+
path.chmod(stat.S_IRUSR | stat.S_IWUSR)
|
|
264
|
+
except OSError as exc:
|
|
265
|
+
warnings.append(
|
|
266
|
+
f"Could not set mode 0600 on {path}: {exc}. "
|
|
267
|
+
"Stored API keys may be world-readable."
|
|
268
|
+
)
|
|
269
|
+
for warning in warnings:
|
|
270
|
+
logger.warning("%s", warning)
|
|
271
|
+
return tuple(warnings)
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _write_raw_or_raise(data: dict) -> tuple[str, ...]:
|
|
275
|
+
"""Write `data` via `_write_raw`, converting write failures to `RuntimeError`.
|
|
276
|
+
|
|
277
|
+
`_write_raw` lets `OSError` from the atomic write (no disk space, an
|
|
278
|
+
unwritable state directory, a cross-device rename of the temp file)
|
|
279
|
+
propagate. The public writers document `RuntimeError` for unrecoverable
|
|
280
|
+
store failures, so translate here with a remediation hint instead of
|
|
281
|
+
leaking a raw traceback to the caller (CLI or TUI). The message never
|
|
282
|
+
includes the credential value.
|
|
283
|
+
|
|
284
|
+
Returns:
|
|
285
|
+
The chmod-warning tuple from `_write_raw` on success.
|
|
286
|
+
|
|
287
|
+
Raises:
|
|
288
|
+
RuntimeError: If the underlying write fails with an `OSError`.
|
|
289
|
+
"""
|
|
290
|
+
try:
|
|
291
|
+
return _write_raw(data)
|
|
292
|
+
except OSError as exc:
|
|
293
|
+
msg = (
|
|
294
|
+
f"Failed to write credential file {auth_path()}: {exc}. "
|
|
295
|
+
"Check available disk space and the permissions on the parent "
|
|
296
|
+
"directory."
|
|
297
|
+
)
|
|
298
|
+
raise RuntimeError(msg) from exc
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def load_credentials() -> dict[str, StoredCredential]:
|
|
302
|
+
"""Return all stored credentials keyed by provider name.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Mapping of provider name to its stored credential. Empty when no
|
|
306
|
+
credentials are persisted yet.
|
|
307
|
+
|
|
308
|
+
Raises:
|
|
309
|
+
RuntimeError: If the file exists but is corrupt or has an unsupported
|
|
310
|
+
schema version. Caller is expected to surface a remediation hint.
|
|
311
|
+
""" # noqa: DOC502 - re-raised from `_read_raw`
|
|
312
|
+
data = _read_raw()
|
|
313
|
+
if data is None:
|
|
314
|
+
return {}
|
|
315
|
+
creds_raw = data.get("credentials")
|
|
316
|
+
if not isinstance(creds_raw, dict):
|
|
317
|
+
return {}
|
|
318
|
+
result: dict[str, StoredCredential] = {}
|
|
319
|
+
for provider, entry in creds_raw.items():
|
|
320
|
+
coerced = _coerce_credential(entry)
|
|
321
|
+
if coerced is not None:
|
|
322
|
+
result[provider] = coerced
|
|
323
|
+
return result
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _coerce_credential(raw: Any) -> StoredCredential | None: # noqa: ANN401
|
|
327
|
+
# `raw: Any` because entries come from `json.loads`; the body is
|
|
328
|
+
# what enforces the `StoredCredential` contract.
|
|
329
|
+
"""Validate one raw credential entry, returning `None` on shape mismatch.
|
|
330
|
+
|
|
331
|
+
Centralizes the runtime check against the `StoredCredential` union so
|
|
332
|
+
`load_credentials` doesn't repeat the per-field guard logic and so a
|
|
333
|
+
single helper can grow as new variants are added.
|
|
334
|
+
|
|
335
|
+
Returns:
|
|
336
|
+
The coerced `StoredCredential`, or `None` when the entry doesn't
|
|
337
|
+
match any known variant's shape.
|
|
338
|
+
"""
|
|
339
|
+
if not isinstance(raw, dict):
|
|
340
|
+
return None
|
|
341
|
+
cred_type = raw.get("type")
|
|
342
|
+
if cred_type == "api_key":
|
|
343
|
+
key = raw.get("key")
|
|
344
|
+
if not isinstance(key, str) or not key:
|
|
345
|
+
return None
|
|
346
|
+
added_at = raw.get("added_at")
|
|
347
|
+
if not isinstance(added_at, str):
|
|
348
|
+
added_at = ""
|
|
349
|
+
credential = ApiKeyCredential(type="api_key", key=key, added_at=added_at)
|
|
350
|
+
base_url = raw.get("base_url")
|
|
351
|
+
if isinstance(base_url, str) and base_url:
|
|
352
|
+
credential["base_url"] = base_url
|
|
353
|
+
elif base_url is not None:
|
|
354
|
+
# Present but not a usable string (e.g. a hand-edit left an int or
|
|
355
|
+
# an empty value). Dropping it silently would pair the key with the
|
|
356
|
+
# provider default — possibly the wrong endpoint — with no trace, so
|
|
357
|
+
# log it. `base_url` is non-secret, so logging the value is safe.
|
|
358
|
+
logger.warning(
|
|
359
|
+
"Ignoring malformed base_url for a stored credential: %r", base_url
|
|
360
|
+
)
|
|
361
|
+
project = raw.get("project")
|
|
362
|
+
if isinstance(project, str) and project:
|
|
363
|
+
credential["project"] = project
|
|
364
|
+
elif project is not None:
|
|
365
|
+
# Same rationale as `base_url`: a hand-edited non-string value is
|
|
366
|
+
# dropped with a trace. `project` is non-secret, so logging is safe.
|
|
367
|
+
logger.warning(
|
|
368
|
+
"Ignoring malformed project for a stored credential: %r", project
|
|
369
|
+
)
|
|
370
|
+
return credential
|
|
371
|
+
# OAuth is reserved for a future PR — silently skip until the producer
|
|
372
|
+
# path lands. `cred_type in {"oauth"}` falls through to None here.
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def get_stored_key(provider: str) -> str | None:
|
|
377
|
+
"""Return the stored API key for `provider`, or `None` if unset.
|
|
378
|
+
|
|
379
|
+
Returns `None` for stored OAuth credentials too — callers that need
|
|
380
|
+
OAuth tokens should read `load_credentials()` directly and narrow on
|
|
381
|
+
`type`.
|
|
382
|
+
|
|
383
|
+
Raises:
|
|
384
|
+
RuntimeError: If the credential file is corrupt.
|
|
385
|
+
""" # noqa: DOC502 - re-raised from `_read_raw` via `load_credentials`
|
|
386
|
+
creds = load_credentials()
|
|
387
|
+
entry = creds.get(provider)
|
|
388
|
+
if entry is None or entry["type"] != "api_key":
|
|
389
|
+
return None
|
|
390
|
+
return entry["key"] or None
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def get_stored_base_url(provider: str) -> str | None:
|
|
394
|
+
"""Return the base URL paired with `provider`'s stored key, or `None`.
|
|
395
|
+
|
|
396
|
+
Returns `None` both when no key is stored and when a key is stored without
|
|
397
|
+
an accompanying base URL (the user left the field blank, meaning "use the
|
|
398
|
+
provider default"). Callers distinguish the two via `get_stored_key`.
|
|
399
|
+
|
|
400
|
+
Raises:
|
|
401
|
+
RuntimeError: If the credential file is corrupt.
|
|
402
|
+
""" # noqa: DOC502 - re-raised from `_read_raw` via `load_credentials`
|
|
403
|
+
creds = load_credentials()
|
|
404
|
+
entry = creds.get(provider)
|
|
405
|
+
if entry is None or entry["type"] != "api_key":
|
|
406
|
+
return None
|
|
407
|
+
return entry.get("base_url") or None
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
def get_stored_project(provider: str) -> str | None:
|
|
411
|
+
"""Return the LangSmith project paired with `provider`'s stored key, or `None`.
|
|
412
|
+
|
|
413
|
+
Returns `None` when no key is stored and when a key is stored without a
|
|
414
|
+
custom project (the user left the field blank, meaning "use the default").
|
|
415
|
+
|
|
416
|
+
Raises:
|
|
417
|
+
RuntimeError: If the credential file is corrupt.
|
|
418
|
+
""" # noqa: DOC502 - re-raised from `_read_raw` via `load_credentials`
|
|
419
|
+
creds = load_credentials()
|
|
420
|
+
entry = creds.get(provider)
|
|
421
|
+
if entry is None or entry["type"] != "api_key":
|
|
422
|
+
return None
|
|
423
|
+
return entry.get("project") or None
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def set_stored_key(
|
|
427
|
+
provider: str,
|
|
428
|
+
key: str,
|
|
429
|
+
*,
|
|
430
|
+
base_url: str | None = None,
|
|
431
|
+
project: str | None = None,
|
|
432
|
+
) -> WriteOutcome:
|
|
433
|
+
"""Persist an API key for `provider`.
|
|
434
|
+
|
|
435
|
+
Empty / whitespace-only keys are rejected so callers don't accidentally
|
|
436
|
+
write a sentinel that masks a working environment variable (see
|
|
437
|
+
`apply_stored_credentials` in `model_config` — a stored empty would
|
|
438
|
+
unconditionally overwrite the env var).
|
|
439
|
+
|
|
440
|
+
This rewrites the whole credential record: `base_url` and `project` are
|
|
441
|
+
*not* merged with any previously stored values. Passing blank/`None` for
|
|
442
|
+
either clears it, so a caller rotating a key while wanting to keep the
|
|
443
|
+
existing endpoint/project must read it back (e.g. via `get_stored_base_url`
|
|
444
|
+
/ `get_stored_project`) and pass it in again.
|
|
445
|
+
|
|
446
|
+
Args:
|
|
447
|
+
provider: Provider identifier (e.g., `"anthropic"`).
|
|
448
|
+
key: The API key value. Whitespace is stripped before storage.
|
|
449
|
+
base_url: Optional provider endpoint to pair with the key. Whitespace
|
|
450
|
+
is stripped; blank/`None` stores no endpoint, meaning the key uses
|
|
451
|
+
the provider default rather than any inherited (e.g. gateway) URL.
|
|
452
|
+
project: Optional LangSmith project name to pair with the key. Valid
|
|
453
|
+
only for the `langsmith` tracing service. Whitespace is stripped;
|
|
454
|
+
blank/`None` stores no project, meaning traces use the default
|
|
455
|
+
project.
|
|
456
|
+
|
|
457
|
+
Returns:
|
|
458
|
+
A `WriteOutcome` whose `warnings` tuple lists chmod failures the
|
|
459
|
+
caller should surface to the user. Empty on a clean save.
|
|
460
|
+
|
|
461
|
+
Raises:
|
|
462
|
+
ValueError: If `provider` or the stripped `key` is empty, or a non-empty
|
|
463
|
+
`project` is paired with a provider other than the `langsmith`
|
|
464
|
+
service.
|
|
465
|
+
RuntimeError: If the credential file is corrupt and cannot be read, or
|
|
466
|
+
the new file cannot be written (e.g. no disk space or an
|
|
467
|
+
unwritable state directory).
|
|
468
|
+
""" # noqa: DOC502 - `RuntimeError` re-raised from `_read_raw`/`_write_raw_or_raise`
|
|
469
|
+
if not provider:
|
|
470
|
+
msg = "Provider name cannot be empty"
|
|
471
|
+
raise ValueError(msg)
|
|
472
|
+
cleaned = key.strip()
|
|
473
|
+
if not cleaned:
|
|
474
|
+
msg = "API key cannot be empty"
|
|
475
|
+
raise ValueError(msg)
|
|
476
|
+
data = _read_raw() or {}
|
|
477
|
+
creds = data.get("credentials")
|
|
478
|
+
if not isinstance(creds, dict):
|
|
479
|
+
creds = {}
|
|
480
|
+
entry: dict[str, str] = {
|
|
481
|
+
"type": "api_key",
|
|
482
|
+
"key": cleaned,
|
|
483
|
+
"added_at": datetime.now(tz=UTC).isoformat(timespec="seconds"),
|
|
484
|
+
}
|
|
485
|
+
cleaned_base_url = base_url.strip() if base_url else ""
|
|
486
|
+
if cleaned_base_url:
|
|
487
|
+
entry["base_url"] = cleaned_base_url
|
|
488
|
+
cleaned_project = project.strip() if project else ""
|
|
489
|
+
if cleaned_project:
|
|
490
|
+
# A project name is meaningful only for the LangSmith tracing service;
|
|
491
|
+
# enforce the invariant at the write boundary so a stray project can
|
|
492
|
+
# never be persisted onto an unrelated provider, regardless of caller.
|
|
493
|
+
# Lazy import avoids a circular dependency (model_config imports this
|
|
494
|
+
# module), matching the pattern in `auth_path`.
|
|
495
|
+
from deepagents_code.model_config import is_langsmith
|
|
496
|
+
|
|
497
|
+
if not is_langsmith(provider):
|
|
498
|
+
msg = f"project is only valid for the langsmith service, not {provider!r}"
|
|
499
|
+
raise ValueError(msg)
|
|
500
|
+
entry["project"] = cleaned_project
|
|
501
|
+
creds[provider] = entry
|
|
502
|
+
data["version"] = _STORAGE_VERSION
|
|
503
|
+
data["credentials"] = creds
|
|
504
|
+
warnings = _write_raw_or_raise(data)
|
|
505
|
+
logger.debug("Stored credential for provider %s", provider)
|
|
506
|
+
return WriteOutcome(warnings=warnings)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def delete_stored_key(provider: str) -> DeleteOutcome:
|
|
510
|
+
"""Remove a stored credential for `provider`.
|
|
511
|
+
|
|
512
|
+
Args:
|
|
513
|
+
provider: Provider identifier.
|
|
514
|
+
|
|
515
|
+
Returns:
|
|
516
|
+
A `DeleteOutcome` whose `removed` flag reports whether a credential was
|
|
517
|
+
present, and whose `warnings` tuple lists chmod failures from the
|
|
518
|
+
rewrite (empty for a no-op delete, which performs no write).
|
|
519
|
+
|
|
520
|
+
Raises:
|
|
521
|
+
RuntimeError: If the credential file is corrupt and cannot be read, or
|
|
522
|
+
the rewrite cannot be written (e.g. no disk space or an unwritable
|
|
523
|
+
state directory).
|
|
524
|
+
""" # noqa: DOC502 - re-raised from `_read_raw`/`_write_raw_or_raise`
|
|
525
|
+
data = _read_raw()
|
|
526
|
+
if data is None:
|
|
527
|
+
return DeleteOutcome(removed=False)
|
|
528
|
+
creds = data.get("credentials")
|
|
529
|
+
if not isinstance(creds, dict) or provider not in creds:
|
|
530
|
+
return DeleteOutcome(removed=False)
|
|
531
|
+
del creds[provider]
|
|
532
|
+
data["version"] = _STORAGE_VERSION
|
|
533
|
+
data["credentials"] = creds
|
|
534
|
+
warnings = _write_raw_or_raise(data)
|
|
535
|
+
logger.debug("Deleted credential for provider %s", provider)
|
|
536
|
+
return DeleteOutcome(removed=True, warnings=warnings)
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def list_configured_providers() -> list[str]:
|
|
540
|
+
"""Return providers that currently have a stored credential, sorted.
|
|
541
|
+
|
|
542
|
+
Raises:
|
|
543
|
+
RuntimeError: If the credential file is corrupt.
|
|
544
|
+
""" # noqa: DOC502 - re-raised from `_read_raw` via `load_credentials`
|
|
545
|
+
return sorted(load_credentials().keys())
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: remember
|
|
3
|
+
description: "Review the current conversation and capture valuable knowledge — best practices, coding conventions, architecture decisions, workflows, and user feedback — into persistent memory (AGENTS.md) or reusable skills. Use when the user says: (1) remember this, (2) save what we learned, (3) update memory, (4) capture learnings."
|
|
4
|
+
license: MIT
|
|
5
|
+
compatibility: designed for deepagents-code
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
Review our conversation and capture valuable knowledge. Focus especially on **best practices** we discussed or discovered—these are the most important things to preserve.
|
|
9
|
+
|
|
10
|
+
## Step 1: Identify Best Practices and Key Learnings
|
|
11
|
+
|
|
12
|
+
Scan the conversation for:
|
|
13
|
+
|
|
14
|
+
### Best Practices (highest priority)
|
|
15
|
+
- **Patterns that worked well** - approaches, techniques, or solutions we found effective
|
|
16
|
+
- **Anti-patterns to avoid** - mistakes, gotchas, or approaches that caused problems
|
|
17
|
+
- **Quality standards** - criteria we established for good code, documentation, or processes
|
|
18
|
+
- **Decision rationale** - why we chose one approach over another
|
|
19
|
+
|
|
20
|
+
### Other Valuable Knowledge
|
|
21
|
+
- Coding conventions and style preferences
|
|
22
|
+
- Project architecture decisions
|
|
23
|
+
- Workflows and processes we developed
|
|
24
|
+
- Tools, libraries, or techniques worth remembering
|
|
25
|
+
- Feedback I gave about your behavior or outputs
|
|
26
|
+
|
|
27
|
+
## Step 2: Decide Where to Store Each Learning
|
|
28
|
+
|
|
29
|
+
For each best practice or learning, choose the right destination:
|
|
30
|
+
|
|
31
|
+
### -> Memory (AGENTS.md) for preferences and guidelines
|
|
32
|
+
Use memory when the knowledge is:
|
|
33
|
+
- A preference or guideline (not a multi-step process)
|
|
34
|
+
- Something to always keep in mind
|
|
35
|
+
- A simple rule or pattern
|
|
36
|
+
|
|
37
|
+
**Global** (`~/.deepagents/agent/AGENTS.md`): Universal preferences across all projects
|
|
38
|
+
**Project** (`.deepagents/AGENTS.md`): Project-specific conventions and decisions
|
|
39
|
+
|
|
40
|
+
### -> Skill for reusable workflows and methodologies
|
|
41
|
+
**Create a skill when** we developed:
|
|
42
|
+
- A multi-step process worth reusing
|
|
43
|
+
- A methodology for a specific type of task
|
|
44
|
+
- A workflow with best practices baked in
|
|
45
|
+
- A procedure that should be followed consistently
|
|
46
|
+
|
|
47
|
+
Skills are more powerful than memory entries because they can encode **how** to do something well, not just **what** to remember.
|
|
48
|
+
|
|
49
|
+
## Step 3: Create Skills for Significant Best Practices
|
|
50
|
+
|
|
51
|
+
If we established best practices around a workflow or process, capture them in a skill.
|
|
52
|
+
|
|
53
|
+
**Example:** If we discussed best practices for code review, create a `code-review` skill that encodes those practices into a reusable workflow.
|
|
54
|
+
|
|
55
|
+
### Skill Location
|
|
56
|
+
`~/.deepagents/agent/skills/<skill-name>/SKILL.md`
|
|
57
|
+
|
|
58
|
+
### Skill Structure
|
|
59
|
+
```
|
|
60
|
+
skill-name/
|
|
61
|
+
├── SKILL.md (required - main instructions with best practices)
|
|
62
|
+
├── scripts/ (optional - executable code)
|
|
63
|
+
├── references/ (optional - detailed documentation)
|
|
64
|
+
└── assets/ (optional - templates, examples)
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### SKILL.md Format
|
|
68
|
+
```markdown
|
|
69
|
+
---
|
|
70
|
+
name: skill-name
|
|
71
|
+
description: "What this skill does AND when to use it. Include triggers like 'when the user asks to X' or 'when working with Y'. This description determines when the skill activates."
|
|
72
|
+
---
|
|
73
|
+
|
|
74
|
+
# Skill Name
|
|
75
|
+
|
|
76
|
+
## Overview
|
|
77
|
+
Brief explanation of what this skill accomplishes.
|
|
78
|
+
|
|
79
|
+
## Best Practices
|
|
80
|
+
Capture the key best practices upfront:
|
|
81
|
+
- Best practice 1: explanation
|
|
82
|
+
- Best practice 2: explanation
|
|
83
|
+
|
|
84
|
+
## Process
|
|
85
|
+
Step-by-step instructions (imperative form):
|
|
86
|
+
1. First, do X
|
|
87
|
+
2. Then, do Y
|
|
88
|
+
3. Finally, do Z
|
|
89
|
+
|
|
90
|
+
## Common Pitfalls
|
|
91
|
+
- Pitfall to avoid and why
|
|
92
|
+
- Another anti-pattern we discovered
|
|
93
|
+
```
|
|
94
|
+
|
|
95
|
+
### Key Principles
|
|
96
|
+
1. **Encode best practices prominently** - Put them near the top so they guide the entire workflow
|
|
97
|
+
2. **Concise is key** - Only include non-obvious knowledge. Every paragraph should justify its token cost.
|
|
98
|
+
3. **Clear triggers** - The description determines when the skill activates. Be specific.
|
|
99
|
+
4. **Imperative form** - Write as commands: "Create a file" not "You should create a file"
|
|
100
|
+
5. **Include anti-patterns** - What NOT to do is often as valuable as what to do
|
|
101
|
+
|
|
102
|
+
## Step 4: Update Memory for Simpler Learnings
|
|
103
|
+
|
|
104
|
+
For preferences, guidelines, and simple rules that don't warrant a full skill:
|
|
105
|
+
|
|
106
|
+
```markdown
|
|
107
|
+
## Best Practices
|
|
108
|
+
- When doing X, always Y because Z
|
|
109
|
+
- Avoid A because it leads to B
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Use `edit_file` to update existing files or `write_file` to create new ones.
|
|
113
|
+
|
|
114
|
+
## Step 5: Summarize Changes
|
|
115
|
+
|
|
116
|
+
List what you captured and where you stored it:
|
|
117
|
+
- Skills created (with key best practices encoded)
|
|
118
|
+
- Memory entries added (with location)
|