Repository navigation
0.5.0 (2/5): login for agents, and listen, speak and debug streaming - #161
Closed
dg-coreylweathers wants to merge 18 commits into
Closed
dg-coreylweathers wants to merge 18 commits into
dg-coreylweathers wants to merge 18 commits into
Conversation
dg login no longer checks for an sk-/pk- prefix: Deepgram keys don't have one, and login_with_api_key already verifies the key with the API. A 40-hex key used to stop with "Login cancelled" whenever no one could answer the "Continue anyway?" prompt. The two login confirmations (DEEPGRAM_API_KEY is set; the profile already has a saved login) are asked only of a person at a terminal. In any other run they return confirmation_required, exit 1, with details.flag "--yes" and a next_step that reruns the command with --yes, or null when the command line holds a key. They no longer return "cancelled", which is kept for a person saying no. -y/--yes answers both; -f/--force-write stays as the same flag. BaseCommand gains can_prompt(), the check confirm() already made, so a command can tell "no one can answer" from "the answer was no".
In an AI agent session (or with DG_AGENT=1, --no-browser, or neither
stdin nor stdout a terminal), dg login now asks for a device code,
saves it and returns at once with the link and code (status "pending",
exit 0). dg login --complete checks once: auth_pending
with details.retry_after until the user approves, then saves the key
as before; auth_denied or auth_expired otherwise, and auth_expired with
next_step "dg login" when no login is waiting. --wait keeps 0.4.0's
behavior of polling until done; a person at a terminal still gets it
by default.
The pending code lives in {state_dir}/login-pending.json: written 0600
through O_CREAT|O_EXCL|O_NOFOLLOW and an atomic replace, in a 0700
directory, holding the device code, interval, expiry, login host and
profile, never a key. state_dir() is ~/.deepctl, the one state-dir
helper later changes reuse. In an AI agent session the file must be
owned by this user with no group or other write bit; otherwise it is
ignored with one WARN: line and left in place. A code is only sent back
to the login host that issued it.
Before any request, login checks that it could save the result: an
unwritable state or config directory is state_unwritable
(E-STATE-UNWRITABLE); in an AI agent session, a user config dg won't
save over is state_unwritable with details.reason "untrusted_config".
Both come before the --yes confirmations, and both apply
to --api-key and --complete too. A home dg can't write no longer stops
every command while Config picks its default path.
The token poll is a POST to the RFC 8628 token endpoint, so the device
code never lands in a URL, and a fixed device name replaces this
machine's hostname. Polls are at least 6 s apart whatever the server
asks; slow_down, 429 and server errors back off by 5 s or the server's
Retry-After, and --wait keeps polling through them until the code
expires. A device-code answer that isn't a JSON object is
network_unavailable. The login link must be https (or loopback http).
Status lines go to stderr. In mode A they are dropped, and the link and
code are an INFO: line; a person's terminal shows the same line instead
of the separate code and link lines. --complete refuses --profile for
another profile than the pending login's, and --save-base-url. The
legacy config migration prints to stderr.
Every authed command listed projects before its real request, twice
(once in BaseCommand.execute and again when the SDK client was built),
on a second TCP connection. That cost two round trips per command and
31 s on a stalled network.
- guard() checks only that a key is present. The host check still runs
first, before any key is read, and the missing-key (auth_required) and
E_KEY_UNTRUSTED paths are unchanged. The removed check's error text
("Network error during verification: ...", "Your API key may have
expired or been revoked.") is gone.
- New deepctl_core.transport: one httpx.Client per process, created on
first use and passed to the SDK as httpx_client. It carries no base
URL, no credentials and keeps no cookies; DeepgramClient still takes
the base URL from hosts.check_credential_host(). Its timeout matches
the SDK's own client (60 s). It follows no redirects, as 0.4.1's SDK
client doesn't, and a 3xx keeps 0.4.1's refusal.
- Request failures map to the catalogue's transport_mapping codes: DNS,
connect, TLS, reset, a proxy refusal, a cut-off body (E-NETWORK-CUT)
or a 2xx body that won't parse (E-NETWORK-UNREADABLE) ->
network_unavailable; timeout -> timeout; 401 auth_invalid; 403
auth_forbidden; 404 not_found; 429 rate_limited; 400, 422, other 4xx
invalid_argument; 5xx server_error. The SDK's own retries still run
first. Other failures keep their 0.4.0 wording.
- BaseCommand renders a mapped failure as two stderr lines (ERROR:/HINT:
in mode A, a cross and the hint otherwise) and, under a machine
format, a coded result on stdout with code, hint, next_step,
schema_version 1 and details; exit 1. details.key_source follows dg
whoami's order. The 0.4.1 coded results keep their keys.
- dg speak marks stdout as its payload stream once audio starts, so a
failure mid-stream goes to stderr only, never as JSON after the audio.
- projects --set-default still warns and asks when the key can't see the
project (404/403); other request failures stop it.
The 0.4.1 host tests that used the pre-flight as their probe now send
the command's own request through the shared client.
In mode A every stderr line carries a prefix and status chrome is suppressed. The commands that use the shared client (billing, keys, listen, members, models, projects, read, requests, speak, transcribe, usage) now run their handler with stderr filtered: only ERROR:/WARN:/INFO:/HINT:/OK:/DEPRECATED: lines, and 0.4.0 "Error"/"Warning" lines, get through. Each write is kept or dropped as a whole. --verbose keeps everything, and every other command (login's device code, dg api, debug-probe) is unchanged. Lines in those commands that a caller needs now render as warnings or errors instead of chrome, so they survive the filter: listen's Flux --diarize note, its --encoding assumption and the microphone audio warning; speak's --play note; projects' "Failed to ..." errors and the missing project_id warning. The listen and speak warnings print under --quiet too (print_warning gains ignore_quiet), since --quiet hides only INFO and OK lines.
Live stdin and microphone streams run through one session engine (deepctl_cmd_listen.live) with defined endings: - Ctrl-C (Ctrl-Break on Windows): send CloseStream, keep receiving for at most 1 s, keep the finals and --save-to, exit 2. On --mic, Ctrl-C is how the session ends: the same drain, then exit 0 with the transcript so far, also when it comes before the session has connected. Ctrl-Break stays a hard stop (exit 2) on --mic. A second Ctrl-C exits at once. The handler is signal.signal, not add_signal_handler, so it works on every platform and on Python 3.10. - SIGTERM on POSIX (docker stop, systemd, Kubernetes): the same drain, then the process re-raises SIGTERM with the default action, so the wait status stays "killed by signal 15" (143 in a shell). A second signal ends it at once. - End of input: send CloseStream and wait for the server's close under an idle deadline (10 s since the last server message) and an absolute cap (max(30 s, 2 x the audio still behind for PCM/mulaw/alaw, or 2 x the time spent sending otherwise)). If either expires the run ends with stream_drain_timeout, exit 1. Ctrl-C during the drain switches to the 1 s cap and exit 2. - A signal that arrives while the server is closing still counts: the run ends as stopped by that signal. - The server closes: exit 0, or exit 1 with server_error when it reported an error. - The connection drops: exit 1 with network_unavailable. - Whoever reads stdout goes away (`| head -n 1`, `| true`): the stream ends quietly and exits 0. Captions are flushed line by line, so they also arrive as they are produced. - stdin can't be read (a write-only or broken descriptor, EIO on a hang-up): exit 1 with invalid_argument and a hint to pass a file or URL instead. Each failure is a coded error whose details carry the finals received so far (transcript, transcript_chars, saved_to), so nothing that arrived is lost. Text the server sends back (close reasons, Flux error descriptions) is stripped of control characters and of the API key and any Token/Bearer value before it is shown or saved. Stdin is read with os.read in a daemon thread that hands each buffer to the loop through run_coroutine_threadsafe and waits for it to be taken. The thread checks a closing event before every read and hand-off, shutdown sets that event before it cancels any task, and anything raised in the thread ends it quietly. This removes the interpreter abort (exit 134, "Fatal Python error") that a reader blocked in sys.stdin.buffer caused at shutdown. The reader blocks SIGINT and SIGTERM so they always reach the main thread, and waits on a stdin left non-blocking by its parent instead of failing. Regular files, pipes, /dev/null and TTYs all work. At interpreter exit the remaining objects are frozen out of the garbage collector, so the process ends a few milliseconds after the server's close instead of about 100 ms later. The session opens through deepctl_core.websocket.open_async, the same no-redirect open as connect_async but returning the connection and taking websockets options, so a 3xx on the upgrade is still refused before a second connection (mic and stdin), as in 0.4.1. deepctl-core now declares websockets>=13.1,<18, which that module imports.
- Stdin reads take at most 50 ms of audio, computed from the encoding, sample rate and channels (whole samples; 4 KiB for encodings without a known byte rate). A pipe read returns what is there, so paced input goes up at the writer's pace: 20 ms frames at 16 kHz linear16 and at 8 kHz mulaw, where 4 KiB reads held telephony audio for about 500 ms. - The send queue holds at most 32 buffers. The reader waits for room, so a slow uplink holds back the producer instead of filling memory. - The microphone callback can't wait, so when the queue is full the oldest buffer is dropped and counted; one warning per stream reports how much audio was dropped. Mic blocks are 50 ms. - listen v1 streams send a KeepAlive after 5 s without audio, so a paused producer doesn't time the stream out. - The WebSocket is opened with compression=None: audio doesn't compress, and deflate cost CPU on every frame.
When JSON or YAML was asked for (-o json, -o yaml) or chosen because an AI-agent marker is set, a live stream writes no transcript lines, interim rewrites or live captions to stdout. The final Result object is the one document on stdout, whatever the stream's status, so `dg -o json listen < call.raw | jq` parses. With --verbose, final lines go to stderr as INFO: lines. --save-to still writes the transcript or captions. JSON implied only by the TTY/CI heuristics (cron, CI, a container with no TERM) keeps 0.4.0's live output: lines as they arrive, then the final Result, because those pipelines read stdout as a stream. To tell the cases apart, deepctl_core.output gains format_source(): "explicit", "hard_agent", "soft" or "default". Human output is unchanged. Under a hard AI-agent signal, `dg listen` with no SOURCE and no --mic is refused before authentication with invalid_argument (E-LISTEN-NO-SOURCE, exit 1) instead of streaming a non-TTY stdin until an end of input that may never come. `dg listen -` reads stdin explicitly. CI and people keep auto-detection.
Speak v1 wrote each chunk to stdout without flushing, so a pipe saw nothing until 8 KB had piled up (about 640 ms with mp3-sized chunks), and --play buffered the whole utterance before starting the player. Each chunk is now written and flushed as the SDK yields it, and --play with a stdin player (ffplay, paplay, aplay) starts the player before the request and feeds it chunk by chunk, as Flux already did. afplay keeps the buffered temp-file path. When the reader of stdout or the player goes away (EPIPE), the stream stops, the response is closed and the command ends quietly with exit 0; fd 1 is pointed at the null device so the exit flush can't fail. Any other stdout write error (ENOSPC, EIO, EFBIG, EDQUOT) is the new coded error output_unwritable: exit 1, two stderr lines, no envelope on stdout and no "Exception ignored". The same holds when the result document itself can't be written to stdout: EPIPE exits 0 quietly, any other error is output_unwritable. Closing a stream now waits at most 2 s. A WebSocket closing handshake the server doesn't answer, as on Ctrl-C mid-stream, used to hold the exit for up to 10 s; the close is abandoned after 2 s instead.
--out-file FILE writes the audio to a file without ever replacing one by accident. Before the request, the target is checked with lstat: an existing file or symlink is refused (E-OUT-FILE-EXISTS) unless --force is given, and a directory, a block device, a socket, stderr itself, a --force over a /dev/fd entry, or a missing or unwritable parent is output_unwritable, so nothing is sent or billed. A character device or FIFO is written as it is, with no --force. It is opened before the request without blocking, so a FIFO with no reader is refused rather than waited on, and the opened file must be the one that was checked: a path swapped for a regular file in the meantime is never written. A new file is opened when the first chunk arrives, with O_EXCL as the race guard, so a failed request, a cut stream or an empty one leaves no file; a partial file is removed on any later error, and a failed write is output_unwritable with the envelope on stdout. --force writes a 0600 temp file in the same directory and replaces the target at the end, keeping an existing file's permission bits (never setuid, setgid or sticky), so a symlink is replaced rather than written through. Flux still buffers its WAV so the header carries the exact length. During the request and stream, SIGTERM and SIGHUP remove the partial or temp file (an old file is kept) and then end the process by the signal; Ctrl-C does the same cleanup and cancels as before. If the unwind stalls, as in a WebSocket close the server doesn't answer, the signal is delivered again after 4 s and ends the run. A signal that was ignored when dg started (nohup, a background job, trap '') stays ignored and the file is completed. Signals that arrive while the file is created, committed or removed are held until that step is done; the most severe of them is then delivered. On Windows, dg never calls os.kill on its own process. When --out-file is stdout itself (/dev/stdout), the audio is the only thing written there (fd 1 is checked again, not reopened) and the summary goes to stderr; an explicit -o json or -o yaml is refused before the request (E-STDOUT-BYTES). -o/--output FILE keeps working with one DEPRECATED line per process, printed even under -q, and now replaces the file through a temp file, so a failed run leaves the old file intact. A bare format value (-o json) is refused with a runnable next_step, and -o together with --out-file is refused. Help, examples and the legacy hints now name --out-file. Changes to -o FILE for the changelog: a symlink at FILE is replaced, not written through; the directory holding FILE must be writable; and FILE becomes a new file with the same permission bits (setuid, setgid and sticky are dropped), so hard links to it keep the old bytes.
Under a hard AI-agent signal, dg speak with no text argument and no --file read stdin until it closed, which can be never, and audio bound for a pipe was written into whatever an agent's tool call was capturing. With a hard signal, stdin is now read only when the text is - ; anything else fails at once with E-SPEAK-NO-TEXT. Audio that would go to a stdout that isn't a terminal, a regular file or /dev/null, with no output file, --play or --list-voices, fails with E-SPEAK-NO-OUTPUT. Its next_step reruns the same command with --out-file and a new file name, and is null (with the matching hint) when the text came from stdin or the command line holds a secret. Both refusals come before the request. - as the text reads stdin in every mode. CI and people keep 0.4.0's behavior otherwise. The speak tests' person fixture lives in the root conftest, scoped to speak's tests: a second tests/unit/conftest.py would clash with dg mcp's when CI collects both.
When stdout couldn't be written during a live stream (dg listen - > /dev/full), the receive loop took the ENOSPC for a dropped connection: the run printed a network hint, then failed again flushing stdout at exit and ended with "Exception ignored" and exit 120. A stdout write error other than EPIPE, in the receive loop, while flushing Flux's last turns, or while writing the final result, is now output_unwritable: fd 1 is pointed at the null device, the error and hint go to stderr, the finals received so far travel in details, and the run exits 1. EPIPE still ends quietly. A failed write of a coded error also silences stdout, so the exit flush can't fail. The stdin catch-all now takes only read errors; an error of any other kind is reported without the stdin hint. Live listen now takes die_by_signal from deepctl_core.signals, the one copy speak also uses.
A Ctrl-C in the first few hundred milliseconds, while main() imports the CLI, reached the top level as KeyboardInterrupt and printed a traceback with exit 1. It is now a cancelled run, like a Ctrl-C once run() has started: the same "Operation cancelled by user" line on stderr, exit 2, and no traceback. main() also replaces Python's default SIGINT handler with one that holds a Ctrl-C back for a moment (at most 200 ms) when it lands in importlib's own code. A KeyboardInterrupt raised there can be swallowed and leave the import lock held, so the run then hangs on its next import.
dg listen <file> read the whole file into memory before sending it, so a 500 MB file peaked at 1.6 GB RSS. The file now goes to the SDK as a FileBody (deepctl_core/upload.py) with an explicit Content-Length, read in 1 MiB chunks as httpx sends them. httpx sends no chunked encoding. - FileBody opens the file once and keeps the descriptor. Each pass reads from offset 0 of that descriptor, so the SDK's retries (connect errors, a connection dropped before the response, 408/409/429/5xx) resend the whole file, and a file swapped in at the same path between retries is never sent. - The file validated is the file uploaded: listen takes the file's (st_dev, st_ino) before validating it, and FileBody compares the opened descriptor against it. A path swapped in between stops with E-UPLOAD-FILE-CHANGED before anything is sent. - Before each chunk, and after the last, the descriptor's size and mtime are compared with the values at open. A change stops the upload with invalid_argument E-UPLOAD-FILE-CHANGED; a failed read with E-UPLOAD-FILE-UNREADABLE. Both print the two stderr lines and the coded result a transport error does, with next_step null and details.path. - A pipe or device (dg listen <(...) --no-validate) is read once at open, as before, so a retry can resend it. - The descriptor closes when the request finishes.
A prerecorded request used the shared client's 60 s timeout, so a large file, or a server that takes longer than a minute to answer, failed with a timeout even though the upload was fine. - dg listen <file|URL> --timeout SECONDS (at least 1, default 300) sets the request's timeout through the SDK's request_options. Every other command keeps 60 s. - E-TIMEOUT's details.timeout_s, and the "didn't respond within" text, report the effective value: transport.map_exception and raise_for take a keyword-only timeout_s. - --timeout with --mic, - or piped stdin is a usage error; live streams have no request timeout.
Before every URL transcription, dg sent its own HEAD request (and a ranged GET when HEAD failed) to the URL, then printed the whole URL, query string included, on stderr and in the result. A presigned URL carries its token in the query string. - A URL source is checked by format only: an http or https scheme and a host, with no network access. --check-url opts in to the HEAD check. It is a usage error with a file or live source, or with --no-validate. - The --check-url request carries no API key and keeps no cookies, including across its own redirects. Its GET fallback is streamed and reads no body, so a server that ignores Range can't fill memory. - The "Transcribing" line, the result table and URL errors show scheme://host/.../<last path segment>, without user info, query string or fragment (validation.display_url): some hosts put a token in the path. The result's source_path keeps the path and drops the rest (validation.output_url). The request to Deepgram still gets the full URL. A URL passed where a file path was expected is shown the same way in "File not found" errors. - A URL whose user info can't be told apart safely (a password with an unescaped /, ? or #, an empty host, a bad port, or an @ outside the parsed host) is shown as scheme://<redacted>/... everywhere, the result's source_path included. Error texts drop the user info and query of the URL they quote. - The scheme match is case-insensitive: HTTPS://... is a URL source. - httpx loads only when --check-url runs. - The listen README describes URL sources, --check-url and what source_path keeps. Changelog: **`dg listen <URL>`**: in `-o json`/`yaml` output, `source_path` is the URL without its user info, query string or fragment; the request to Deepgram still uses the full URL. URL sources are checked by format only; pass `--check-url` to check that the URL responds (a HEAD request that carries no API key or cookies).
debug network now makes its diagnostic requests with httpx instead of
requests and urllib3, one short-lived client per request.
- The probe no longer attaches credentials from ~/.netrc. requests added
them for a matching (or default) netrc entry, including on the request
made with certificate verification disabled; httpx does not read
netrc. No Deepgram key was ever sent by these requests, and none is
now.
- CA bundles from the environment keep working: SSL_CERT_FILE or
SSL_CERT_DIR first (read by httpx), then REQUESTS_CA_BUNDLE, then
CURL_CA_BUNDLE, as requests resolved them. verify=False ignores all of
them. HTTP(S)_PROXY and NO_PROXY apply as before.
- The JSON payload keeps every key and adds two:
environment_info.httpx_version, and library ("httpx") on each entry of
python_requests_tests. requests_version and urllib3_version are now
read from package metadata.
Live listen captures the microphone as raw int16 with sounddevice alone, without numpy. debug audio now runs ffprobe itself, with the argv ffmpeg-python built (-show_format -show_streams -of json), instead of through ffmpeg-python. Both the standard probe and --ffprobe-args run from an argv list with no shell, stdin closed and a 30 s timeout. The input is opened through the file protocol only (-protocol_whitelist file), and a name that starts with "-" is passed as "./-...". The JSON output and the failure messages are unchanged. The listen, debug-audio and debug-network packages no longer declare numpy, ffmpeg-python (and with it future) or requests; the root package keeps requests until 1.0, and a plugin that relied on numpy or ffmpeg-python arriving with deepctl must now declare it itself.
debug browser: Serve the page and the /ws socket from websockets.asyncio.server instead of aiohttp, and declare websockets>=13.1,<18. The server binds 127.0.0.1 only and checks every request before the handshake: the Host must be this listener, the page and the socket need the per-run ?t= token, and the socket needs the page's own Origin (without the port on port 80, as browsers send it). Refusals are fixed text. The page is served with no-store, nosniff, frame-deny and no-referrer headers, and no Server header. The listener is bound before the URL is shown, so the URL is the one being served, now http://127.0.0.1:PORT/?t=... A frame that isn't a valid message is dropped instead of ending the connection, binary frames are ignored, and what the page can make the command keep is bounded: messages of up to 64 KiB, at most 1,000 and 8 MiB of them as stored, and only the 12 known capability names; anything else is counted and noted after the results. Text from the page is printed as escaped ASCII, never as markup. Server logs go to a silenced logger. The command's output, including the page link, now goes to stderr; under -o json or yaml, stdout holds only the result. Tests use real loopback servers instead of aiohttp mocks. debug probe: Run the probe proxy on websockets.asyncio (server and client) instead of aiohttp, and declare websockets>=13.1,<18. Frames are relayed as they arrive, so text stays text and binary stays binary, up to 4 MiB each way as before. When either side closes, the other direction is cancelled, the final probe and summary run, and the client is closed with 1000. The proxy accepts connections only from local pages and native clients. Checked before the handshake and before any upstream connection, the Host must name this listener (without the port on port 80) and an Origin, if sent, must be a page on this machine. The target must start with "/". A refused Origin prints one line. The key is only sent in the upstream Authorization header; client headers and subprotocols are not forwarded, and the client and server loggers are silenced. Text from requests is printed as escaped ASCII, and transcripts with their Unicode kept, never as markup. ffprobe snapshots run as asyncio subprocesses, so they no longer hold up other connections or Ctrl+C. On POSIX, Ctrl+C is handled on the event loop: the first stops listening, stops relaying (frames read after it are counted as dropped) and lets open connections print their summaries; a second closes them without waiting for close handshakes, stops a running ffprobe and skips the final probe. A SIGINT that was ignored when the command started stays ignored, and the previous handler is put back on exit. The upstream URL base is an attribute, so tests point it at a loopback server. With websockets 15 or later, the upstream connection follows HTTPS_PROXY and NO_PROXY, as listen and the CLI's HTTP requests do. The upstream opens through deepctl_core.websocket.open_async, so a redirect on the upgrade is still refused before any request to the new location ("Upstream connection refused", no audio sent); the aiohttp trace hook that did this goes with aiohttp. debug browser and debug probe now run on websockets, so neither needs aiohttp. Nothing else did, so it leaves the install with the seven dists only it pulled in: aiohappyeyeballs, aiosignal, async-timeout, frozenlist, multidict, propcache and yarl (about 11 MiB on linux/amd64). uv.lock is regenerated with uv 0.13.6. The eight names leave the dependency allowlist, and aiohttp joins the dists that must not come back.
dg-coreylweathers
requested review from
GregHolmes and
deepgram-kiley
as code owners
November 7, 2026 02:35
Contributor
Author
|
Consolidated into #165, which has all 74 commits of 0.5.0 in one PR. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Part 2 of 5 of the 0.5.0 release, stacked on part 1. Review in order. Merge only after the PR below it has merged, and only after 0.4.1 is released.
dg loginaccepts real API keys and asks for--yesinstead of cancelling, and agents can finish device login with--complete. Livedg listenfinishes cleanly on Ctrl-C and end of input with bounded memory;dg speakstreams audio as it arrives and gains--out-file(-o/--outputfor the audio file is deprecated). Agent mode drops status chrome from stderr.dg debug networksends no stored credentials, debug browser and probe run on websockets, and numpy, ffmpeg-python and requests are dropped.Commits
Notes
🤖 Generated with Claude Code