seedagent 0.7.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. coding_agent/__init__.py +169 -0
  2. coding_agent/__main__.py +5 -0
  3. coding_agent/backups.py +89 -0
  4. coding_agent/certificates.py +55 -0
  5. coding_agent/cleanup.py +128 -0
  6. coding_agent/cli.py +201 -0
  7. coding_agent/common.py +190 -0
  8. coding_agent/config.py +375 -0
  9. coding_agent/context.py +280 -0
  10. coding_agent/conversation.py +51 -0
  11. coding_agent/credentials.py +56 -0
  12. coding_agent/diagnose.py +81 -0
  13. coding_agent/errors.py +106 -0
  14. coding_agent/guard.py +97 -0
  15. coding_agent/history.py +68 -0
  16. coding_agent/index.py +390 -0
  17. coding_agent/logs.py +133 -0
  18. coding_agent/loop.py +301 -0
  19. coding_agent/memories.py +98 -0
  20. coding_agent/memory.py +210 -0
  21. coding_agent/ocr.py +78 -0
  22. coding_agent/prompts.py +128 -0
  23. coding_agent/schemas.py +887 -0
  24. coding_agent/session.py +121 -0
  25. coding_agent/signin.py +85 -0
  26. coding_agent/skills/ai-engineer/SKILL.md +283 -0
  27. coding_agent/skills/azure-cloud-architect/SKILL.md +112 -0
  28. coding_agent/skills/backend-engineer/SKILL.md +98 -0
  29. coding_agent/skills/database-architect/SKILL.md +85 -0
  30. coding_agent/skills/frontend-engineer/SKILL.md +113 -0
  31. coding_agent/skills/web-browsing/SKILL.md +62 -0
  32. coding_agent/skills.py +137 -0
  33. coding_agent/state.py +94 -0
  34. coding_agent/tools/__init__.py +181 -0
  35. coding_agent/tools/browser.py +238 -0
  36. coding_agent/tools/documents.py +814 -0
  37. coding_agent/tools/excel_add.py +309 -0
  38. coding_agent/tools/excel_look.py +364 -0
  39. coding_agent/tools/excel_script.py +236 -0
  40. coding_agent/tools/excel_xl.py +370 -0
  41. coding_agent/tools/files.py +391 -0
  42. coding_agent/tools/git.py +83 -0
  43. coding_agent/tools/interaction.py +17 -0
  44. coding_agent/tools/mail.py +263 -0
  45. coding_agent/tools/network.py +190 -0
  46. coding_agent/tools/office.py +151 -0
  47. coding_agent/tools/python_runner.py +87 -0
  48. coding_agent/tools/sms.py +86 -0
  49. coding_agent/tools/web.py +619 -0
  50. coding_agent/ui.py +323 -0
  51. coding_agent/usage.py +82 -0
  52. coding_agent/websessions.py +122 -0
  53. seedagent-0.7.0.dist-info/METADATA +355 -0
  54. seedagent-0.7.0.dist-info/RECORD +57 -0
  55. seedagent-0.7.0.dist-info/WHEEL +4 -0
  56. seedagent-0.7.0.dist-info/entry_points.txt +3 -0
  57. seedagent-0.7.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,169 @@
1
+ """Personal coding agent: Claude on Azure (Microsoft Foundry) with a manual tool-use loop.
2
+
3
+ Tools:
4
+ - list_directory : list the folders and files in a directory
5
+ - change_directory : move the agent's current directory (never outside the workspace)
6
+ - grep : regex search across files in the workspace
7
+ - read_file : read a file (optionally a line range)
8
+ - edit_file : replace an exact snippet in a file -- shows a diff and asks permission first
9
+ - write_file : create/overwrite a file -- shows a diff and asks permission first
10
+ - download_file : download a URL (http/https) into the workspace -- always asks, even in
11
+ autonomous mode; size-capped (AGENT_DOWNLOAD_MAX_MB, default 50); cloud metadata
12
+ and link-local addresses are refused (they can leak Azure managed-identity tokens)
13
+ - clone_repo : git clone an https or ssh repository into a new folder of the workspace -- always
14
+ asks, even in autonomous mode; shallow by default; hooks and non-network
15
+ protocols (file://, ext::) are disabled
16
+ - screenshot_page : open a page in headless Chromium (your dev server, a local HTML file, or a
17
+ public site) and show Claude the screenshot, plus console errors and failed
18
+ requests -- Claude sees the image itself (text, layout, colors), no OCR needed.
19
+ Needs Playwright: `pip install playwright` (or `uv add --dev playwright`), then
20
+ `playwright install chromium` -- or it uses the installed Edge / Chrome when that
21
+ download is blocked. `coding-agent --check-browser` tests it. localhost / private addresses and workspace files
22
+ open without asking; public sites always ask. AGENT_BROWSER_PATH picks a specific
23
+ Chromium/Chrome/Edge executable.
24
+ - web_open, web_click, web_type, web_back, web_page, web_look, web_close : browse a site step by step in a
25
+ hidden browser that stays open -- the page's text and its links and fields with numbers; the
26
+ user approves each new site and every form that sends data; never passwords or payments
27
+ (same Playwright setup as screenshot_page)
28
+ - view_image : show Claude an image from the workspace (a mockup, a design export, a screenshot)
29
+ - read_pdf : give Claude a PDF's pages as a document it reads itself -- text, tables, layout and
30
+ scanned pages -- or just the extracted text for long documents
31
+ - search_pdf : find a word or phrase in a PDF's text: the page numbers and a short snippet of every match
32
+ - search_library : find the pages of all the PDFs of the library that have a query's words (full-text index, ranked; scans through OCR)
33
+ (the text is extracted once and cached), then open just those pages with read_pdf
34
+ - read_excel : list a workbook's sheets and show cells (values and formulas) of a sheet or range
35
+ - edit_excel : set cell values or formulas in an .xlsx/.xlsm (or create a new workbook) -- shows a
36
+ cell-by-cell diff and asks first; a copy of the previous file is kept in
37
+ $HOME/.coding-agent/backups/; warns (and always asks) when the workbook has charts,
38
+ images or pivot tables, which openpyxl cannot keep (with Excel doing the saving --
39
+ AGENT_EXCEL_BACKEND=xlwings, the default when Excel and xlwings are there -- nothing is lost)
40
+ - view_excel : see a sheet or range as the person sees it (Excel's rendering with charts, or a drawing)
41
+ - format_excel: bold, fills, borders, wrap, widths, number formats, frozen header, filters
42
+ - add_chart, add_table, add_pivot_table : add a chart, an Excel table or a pivot table (pivot
43
+ tables need Excel) -- shows what will be added and asks first; a backup is kept
44
+ - run_python_excel : a Python script run with Excel (xlwings) on a workbook --
45
+ macros, programs and other files refused before it runs; a backup is kept first
46
+ - copy_path : copy a file or a folder inside the workspace -- a text file shows a diff, a
47
+ binary file or folder shows what will be created; asks permission first
48
+ - delete_file : delete a file -- always asks for human validation, even in autonomous mode
49
+ - delete_folder : delete a folder and everything in it -- shows what it contains and always
50
+ asks for human validation, even in autonomous mode; never the workspace root,
51
+ a .git folder, or a folder holding the agent's own files
52
+ (edit_file, write_file, delete_file and delete_folder never touch the agent's own source)
53
+ - ask_human : lets the model ask you a question mid-task
54
+ - git : read-only git -- status, diff and log of the workspace (never commits, checks
55
+ out or changes anything; external diff tools, textconv filters, pagers and
56
+ fsmonitor hooks are disabled so a repository's config cannot run commands)
57
+ - run_python : run a Python snippet, script or module (e.g. pytest) -- asks permission first
58
+ (uses `uv run --frozen/--no-sync` when uv is installed: the project's own
59
+ environment, never rewriting uv.lock; the code cannot start, replace or kill
60
+ processes or modify files, including through ctypes -- see GUARD_SOURCE)
61
+ - load_skill : load a skill's full instructions when a task matches it
62
+ - web_search : Anthropic's server-side web search (runs on Anthropic's side; nothing executes
63
+ locally). AGENT_WEB_SEARCH=20250305 (default; the only version on Foundry
64
+ deployments hosted on Azure), 20260209 (better filtering; Anthropic-hosted
65
+ deployments), or off.
66
+
67
+ Skills are folders with a SKILL.md (a `name` / `description` header, then instructions), found in:
68
+ coding_agent/skills/ -- shipped with the agent
69
+ $HOME/.coding-agent/skills/ -- personal, every project (override with AGENT_SKILLS_DIR)
70
+ <project>/.agent/skills/ -- per project (can be committed)
71
+ A later location overrides an earlier one with the same skill name. Only names and descriptions
72
+ are sent up front; Claude loads a skill's instructions when it needs them.
73
+
74
+ Memory: notes about each project, kept between runs in
75
+ $HOME/.coding-agent/memory/<project>/memories/notes.md (override the root with AGENT_MEMORY_DIR).
76
+ They are given to Claude at the start of each session. Updating them never slows the agent down:
77
+ after each instruction a background thread sends a summary of what happened to a separate
78
+ "memory curator" call, which rewrites notes.md only when something durable was learned. You get
79
+ the next prompt right away; "[memory] ..." shows when the update finishes. AGENT_MEMORY_MODEL
80
+ picks the deployment it uses (default: the main one -- a smaller, cheaper one works well);
81
+ AGENT_MEMORY=off disables updates. On exit the agent waits (up to 60 s) for a pending update.
82
+
83
+ Configuration (environment variables or a .env file):
84
+ ANTHROPIC_FOUNDRY_ENDPOINT https://<resource>.services.ai.azure.com/anthropic
85
+ ANTHROPIC_FOUNDRY_API_KEY API key; leave unset to sign in with a Microsoft work account: the
86
+ Windows session's account, `az login`, ... or else the Microsoft
87
+ sign-in page, once (see coding_agent/signin.py)
88
+ ANTHROPIC_FOUNDRY_DEPLOYMENT your Claude deployment name
89
+ AZURE_TENANT_ID / AZURE_CLIENT_ID tenant and app registration for the sign-in page (optional)
90
+ ANTHROPIC_FOUNDRY_BROWSER_SIGN_IN=0 never open the sign-in page (servers, CI)
91
+
92
+ Or, without Azure: Anthropic's own API (used only when ANTHROPIC_FOUNDRY_ENDPOINT is not set)
93
+ ANTHROPIC_API_KEY an API key from console.anthropic.com
94
+ ANTHROPIC_MODEL a model ID (default claude-sonnet-5)
95
+ CODEAGENT_THINKING off (default), adaptive, between_tools or disabled
96
+ CODEAGENT_EFFORT medium (default), low, high, xhigh, max or default (see example.env)
97
+
98
+ Usage:
99
+ uv sync # once, in the agent's folder (add --extra browser for screenshots)
100
+ uv run coding-agent -d path/to/project "Add input validation to the CLI"
101
+ python agent.py -d path/to/project "..." # same thing, without installing
102
+ coding-agent -d path/to/project "..." -i # keep chatting after the task
103
+ coding-agent -d path/to/project # interactive mode only
104
+ coding-agent -d path/to/project -r # resume the last conversation in this project
105
+ coding-agent -d path/to/project "..." --auto # autonomous mode (see below)
106
+ coding-agent -d path/to/project --where # show where memory and skills are read from
107
+
108
+ As a library (other front ends, e.g. a desktop app), see coding_agent.session:
109
+ from coding_agent import session
110
+ session.open_project(path, ui=MyUI(), tools=[...], system_prompt="...")
111
+ session.send("instruction")
112
+ coding_agent.register_tool(schema, handler) # the application's own tool (see the README)
113
+ The package layout: config (settings), state (the session), ui (the UI interface and the
114
+ terminal), loop (the agent loop), tools/ (one module per tool family), guard (the run_python
115
+ sandbox), skills, memory, context (context window management), cli (the terminal front end).
116
+ (in interactive mode, /skills re-scans the skill folders and shows them)
117
+
118
+ $HOME is used when set; otherwise your user folder (on Windows, %USERPROFILE%).
119
+
120
+ Autonomous mode (--auto, or /auto in interactive mode to toggle, /mode to show): edits,
121
+ new files and run_python are applied without asking (diffs are still printed), and ask_human
122
+ does not wait -- Claude decides and states its assumptions. Deleting a file or a folder always
123
+ waits for your approval, even in autonomous mode. Workspace confinement,
124
+ self-protection and the subprocess block still apply; Ctrl+C stops it. AGENT_MAX_STEPS (default
125
+ 100) caps the model calls per instruction in every mode.
126
+
127
+ Token usage: coding_agent.usage adds up what the API reports for every model call (agent, compact, memory) and
128
+ estimates what each tool's results added: mark = usage.snapshot(); session.send(...); usage.since(mark).
129
+ After each instruction the agent shows "[tokens: ... in, ... out]".
130
+
131
+ Logs: everything the agent shows (tool calls and results, changes, warnings, errors, questions, token usage) is also written to
132
+ MEMORY_HOME/<project>/agent.log (about 2 MB at most) through the logger coding_agent.ui. AGENT_LOG_LEVEL=DEBUG adds the model's text;
133
+ AGENT_LOG=off writes no file. File contents and typed answers are never logged.
134
+
135
+ Context management (long sessions): the agent tracks how much of the model's context window
136
+ the conversation uses and prints it after each instruction ("[context] 84k / 200k tokens").
137
+ Past 50% it replaces old tool outputs with a short note (Claude re-reads files when needed);
138
+ past 70% it compacts: a summary call replaces the earlier conversation with a brief (goal,
139
+ decisions, files changed, state, next steps). If the API still says the prompt is too long, it
140
+ compacts and retries once. AGENT_CONTEXT_WINDOW (default 200000) is your deployment's window;
141
+ AGENT_COMPACT_MODEL picks the deployment that writes summaries (default: the main one).
142
+ Pasting: multi-line text pasted at the "You:" prompt (a traceback, a code snippet) is sent as
143
+ one instruction. You can also type three double quotes on a line of their own, then paste or
144
+ type anything, and end with three double quotes on their own line again. Approval prompts ignore anything typed or pasted before they appear, so
145
+ leftover pasted lines can never answer "Apply this change?".
146
+
147
+ Interactive commands: /context shows usage, /compact compacts now, /clear starts a fresh
148
+ conversation (memory notes are kept).
149
+
150
+ `path:line` references in the output are clickable links that open the file at that line.
151
+ Set AGENT_EDITOR to vscode (default), cursor, file, or none.
152
+ """
153
+
154
+ from .certificates import use_system_certificates as _use_system_certificates
155
+
156
+ _use_system_certificates() # HTTPS behind a company proxy: trust what the operating system trusts
157
+
158
+
159
+
160
+ def __getattr__(name: str):
161
+ """configure, active_provider and make_anthropic_client (runtime settings for a host application),
162
+ loaded on first use so that importing the package does not read the configuration."""
163
+ if name in ("configure", "active_provider", "make_anthropic_client"):
164
+ from . import config
165
+ return getattr(config, name)
166
+ if name == "register_tool": # a host application's own tools (loads the tool modules on first use)
167
+ from .tools import register_tool
168
+ return register_tool
169
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,5 @@
1
+ """`python -m coding_agent`: the terminal front end."""
2
+
3
+ from .cli import main
4
+
5
+ main()
@@ -0,0 +1,89 @@
1
+ """Previous versions of the workbooks the agent changed, and putting one back.
2
+
3
+ Before each change it saves to a workbook, the agent copies the workbook to
4
+ BACKUP_HOME/<folder>-<code>/<YYYYmmdd-HHMMSS>-<file name>: <folder>-<code> identifies the project
5
+ folder (its name and a hash of its full path), and the time is when the change was saved -- the
6
+ copy is the workbook as it was just before. Backups are kept a few days (cleanup.py).
7
+
8
+ Restoring a version first backs up the current one the same way, so a restore can be undone too.
9
+ Used by the restore_backup tool and by applications (e.g. a "previous versions" list in a window).
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import datetime
15
+ import hashlib
16
+ import os
17
+ import re
18
+ import shutil
19
+ import time
20
+ from pathlib import Path
21
+
22
+ from . import state
23
+ from .config import BACKUP_HOME
24
+
25
+ NAME = re.compile(r"^(\d{8})-(\d{6})-(.+)$")
26
+
27
+
28
+ def folder(workspace: Path | None = None) -> Path:
29
+ """The backups of a project folder (default: the current workspace)."""
30
+ ws = workspace or state.workspace
31
+ return BACKUP_HOME / f"{ws.name}-{hashlib.sha256(str(ws).encode()).hexdigest()[:8]}"
32
+
33
+
34
+ def save(path: Path, content: bytes, workspace: Path | None = None) -> Path:
35
+ """Keep `content` (a file's current bytes) as a previous version of `path`. Returns the copy."""
36
+ target = folder(workspace)
37
+ target.mkdir(parents=True, exist_ok=True)
38
+ backup = target / f"{time.strftime('%Y%m%d-%H%M%S')}-{path.name}"
39
+ while backup.exists(): # two saves within the same second
40
+ backup = backup.with_name(f"{time.strftime('%Y%m%d-%H%M%S', time.localtime(time.time() + 1))}-{path.name}")
41
+ time.sleep(0.01)
42
+ backup.write_bytes(content)
43
+ return backup
44
+
45
+
46
+ def moment(version: str) -> datetime.datetime:
47
+ match = NAME.match(version)
48
+ if not match:
49
+ raise ValueError(f"not a backup name: {version}")
50
+ return datetime.datetime.strptime(match.group(1) + match.group(2), "%Y%m%d%H%M%S")
51
+
52
+
53
+ def versions(name: str, workspace: Path | None = None) -> list[dict]:
54
+ """The previous versions of the file called `name` in a project folder, newest first: id (the
55
+ backup's file name), time (ISO, local time of the change it preceded) and size in bytes."""
56
+ found = []
57
+ backups = folder(workspace)
58
+ for file in backups.iterdir() if backups.is_dir() else []:
59
+ match = NAME.match(file.name)
60
+ if not (file.is_file() and match and match.group(3) == name):
61
+ continue
62
+ try:
63
+ when = moment(file.name)
64
+ except ValueError:
65
+ continue
66
+ found.append({"id": file.name, "time": when.isoformat(timespec="seconds"), "size": file.stat().st_size})
67
+ return sorted(found, key=lambda v: v["id"], reverse=True)
68
+
69
+
70
+ def restore(path: Path, version: str, workspace: Path | None = None) -> str:
71
+ """Put a previous version back in place of `path`, after backing up the current one. Returns a
72
+ sentence for the person. Raises ValueError (no such version) or PermissionError (the file is
73
+ open, e.g. in Excel)."""
74
+ if version not in {v["id"] for v in versions(path.name, workspace)}:
75
+ raise ValueError(f"No previous version {version} of {path.name}.")
76
+ source = folder(workspace) / version
77
+ if path.exists(): # so that the restore can be undone
78
+ save(path, path.read_bytes(), workspace)
79
+ tmp = path.with_name(f".{path.name}.restore-tmp")
80
+ try:
81
+ shutil.copyfile(source, tmp)
82
+ os.replace(tmp, path)
83
+ except PermissionError:
84
+ raise PermissionError(f"{path.name} could not be replaced: it is open (in Excel?). Close it and try again.")
85
+ finally:
86
+ if tmp.exists():
87
+ tmp.unlink()
88
+ return (f"{path.name} is back to its version from before the change of {moment(version):%d %b %Y at %H:%M}. "
89
+ "The version it replaced is kept among the previous versions.")
@@ -0,0 +1,55 @@
1
+ """HTTPS through a company proxy: trust the certificates the operating system trusts.
2
+
3
+ Many company networks inspect HTTPS: a proxy re-signs every site with the company's own root
4
+ certificate, installed in the Windows (or macOS) certificate store -- which is why the browser works.
5
+ Python brings its own list of certificates (certifi) and does not know that root, so every HTTPS call
6
+ fails with CERTIFICATE_VERIFY_FAILED. (uv's --system-certs / UV_SYSTEM_CERTS only covers uv's own
7
+ downloads: the packages it installs, not the program it runs.)
8
+
9
+ With truststore, Python checks certificates with the operating system instead, like the browser:
10
+ every HTTPS connection of the agent -- Claude, download_file, the gateway -- trusts what Windows
11
+ trusts. It is turned on when the package is imported; AGENT_SYSTEM_CERTS=0 turns it off.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import os
17
+ import ssl
18
+
19
+ _enabled = False
20
+
21
+
22
+ def use_system_certificates() -> bool:
23
+ """Make Python's ssl use the operating system's certificate store (once). True if it is on."""
24
+ global _enabled
25
+ if _enabled:
26
+ return True
27
+ if (os.environ.get("AGENT_SYSTEM_CERTS") or "1").strip().lower() in ("0", "false", "no", "off"):
28
+ return False
29
+ try:
30
+ import truststore
31
+ except ImportError: # not installed: Python's own certificates, as before
32
+ return False
33
+ truststore.inject_into_ssl()
34
+ _enabled = True
35
+ return True
36
+
37
+
38
+ def ssl_context() -> ssl.SSLContext:
39
+ """A client context that checks certificates with the operating system when it can."""
40
+ try:
41
+ import truststore
42
+ return truststore.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
43
+ except ImportError:
44
+ return ssl.create_default_context()
45
+
46
+
47
+ def explain(error: BaseException) -> str | None:
48
+ """A plain explanation for a certificate failure, None for any other error."""
49
+ if "CERTIFICATE_VERIFY_FAILED" not in str(error) and "certificate verify failed" not in str(error).lower():
50
+ return None
51
+ return ("the site's HTTPS certificate is not trusted on this machine. On a company network this "
52
+ "usually means a proxy inspects HTTPS with the company's own certificate: this is not a "
53
+ "temporary error, do not retry. The agent trusts the operating system's certificates when "
54
+ "truststore is installed (uv sync) and AGENT_SYSTEM_CERTS is not 0; otherwise ask IT for the "
55
+ "company root certificate and set SSL_CERT_FILE to it.")
@@ -0,0 +1,128 @@
1
+ """Clean-up of what the agent keeps on the machine, so nothing grows forever.
2
+
3
+ Runs by itself, once per process: at the start of the coding-agent command, and the first time an
4
+ application opens a project (session.open_project). AGENT_CLEANUP=off switches this automatic run
5
+ off; an application that prefers to decide when (a server running for weeks might call it daily)
6
+ calls cleanup.run() itself.
7
+
8
+ - Backups (BACKUP_HOME, ~/.coding-agent/backups): the copy of a workbook taken before each change
9
+ is deleted after AGENT_BACKUP_DAYS days (default 3): long enough to undo a change noticed when
10
+ the workbook is checked, without keeping copies of confidential workbooks on the machine.
11
+ - Saved conversations (MEMORY_HOME/<project>/conversation.json, used by --resume): deleted after
12
+ AGENT_CONVERSATION_DAYS days (default 30).
13
+ - Memory (MEMORY_HOME/<project>/): the whole memory of a project folder not used for
14
+ AGENT_MEMORY_DAYS days (default 90) is deleted. The notes of a project in use are kept; the
15
+ memory curator already keeps them under a size limit.
16
+ Nothing here ever touches the projects themselves. A file that cannot be deleted (open elsewhere,
17
+ no permission) is left for next time.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import os
23
+ import shutil
24
+ import time
25
+ from pathlib import Path
26
+
27
+ BACKUP_DAYS = 3
28
+ CONVERSATION_DAYS = 30
29
+ MEMORY_DAYS = 90
30
+ DAY = 86_400
31
+ _ran = False # the clean-up has run in this process (by run(), automatically or by the application)
32
+
33
+
34
+ def days(variable: str, default: int) -> int:
35
+ """A number of days from the environment (at least 1), or the default."""
36
+ try:
37
+ return max(1, int(os.environ.get(variable) or default))
38
+ except ValueError:
39
+ return default
40
+
41
+
42
+ def older_than(path: Path, days_old: float, now: float) -> bool:
43
+ return now - path.stat().st_mtime > days_old * DAY
44
+
45
+
46
+ def clean_backups(backups: Path, days_old: float, now: float) -> int:
47
+ """Backups older than `days_old`. Returns how many were deleted."""
48
+ if not backups.is_dir():
49
+ return 0
50
+ deleted = 0
51
+ for file in [f for f in backups.rglob("*") if f.is_file()]:
52
+ if older_than(file, days_old, now):
53
+ try:
54
+ file.unlink()
55
+ deleted += 1
56
+ except OSError:
57
+ pass
58
+ for folder in sorted((p for p in backups.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
59
+ if not any(folder.iterdir()):
60
+ folder.rmdir()
61
+ return deleted
62
+
63
+
64
+ def clean_memory(memory: Path, days_unused: float, now: float) -> int:
65
+ """The whole memory of each project not used for `days_unused` days (its newest file is older).
66
+ Returns how many projects' memories were deleted."""
67
+ if not memory.is_dir():
68
+ return 0
69
+ deleted = 0
70
+ for project in memory.iterdir():
71
+ if not project.is_dir():
72
+ continue
73
+ newest = max((f.stat().st_mtime for f in project.rglob("*") if f.is_file()), default=project.stat().st_mtime)
74
+ if now - newest > days_unused * DAY:
75
+ shutil.rmtree(project, ignore_errors=True)
76
+ deleted += not project.exists()
77
+ return deleted
78
+
79
+
80
+ def clean_conversations(memory: Path, days_old: float, now: float) -> int:
81
+ """Saved conversations older than `days_old`. Returns how many were deleted."""
82
+ deleted = 0
83
+ for file in memory.glob("*/conversation.json") if memory.is_dir() else []:
84
+ if older_than(file, days_old, now):
85
+ try:
86
+ file.unlink()
87
+ deleted += 1
88
+ except OSError:
89
+ pass
90
+ return deleted
91
+
92
+
93
+ def enabled() -> bool:
94
+ """AGENT_CLEANUP=off (or 0, false, no) switches the automatic clean-up off."""
95
+ return (os.environ.get("AGENT_CLEANUP") or "on").strip().lower() not in ("off", "0", "false", "no")
96
+
97
+
98
+ def run_once(backups: Path | None = None, memory: Path | None = None) -> str | None:
99
+ """The clean-up, unless it already ran in this process or AGENT_CLEANUP=off: the summary, or None when it did not run."""
100
+ if _ran or not enabled():
101
+ return None
102
+ return run(backups, memory)
103
+
104
+
105
+ def run(backups: Path | None = None, memory: Path | None = None, now: float | None = None) -> str:
106
+ """The whole clean-up; a one-line summary. Never raises. Given both folders, the agent's settings
107
+ are not loaded (an application may configure them later, before its first call to Claude)."""
108
+ global _ran
109
+ _ran = True
110
+ if backups is None or memory is None:
111
+ from .config import BACKUP_HOME, MEMORY_HOME
112
+
113
+ backups = BACKUP_HOME if backups is None else backups
114
+ memory = MEMORY_HOME if memory is None else memory
115
+ now = time.time() if now is None else now
116
+ done = []
117
+ for label, step in [
118
+ ("old backup(s)", lambda: clean_backups(backups, days("AGENT_BACKUP_DAYS", BACKUP_DAYS), now)),
119
+ ("unused project memories", lambda: clean_memory(memory, days("AGENT_MEMORY_DAYS", MEMORY_DAYS), now)),
120
+ ("old saved conversation(s)", lambda: clean_conversations(memory, days("AGENT_CONVERSATION_DAYS",
121
+ CONVERSATION_DAYS), now)),
122
+ ]:
123
+ try:
124
+ if count := step():
125
+ done.append(f"{count} {label}")
126
+ except OSError as e:
127
+ done.append(f"{label}: not cleaned ({e})")
128
+ return "Clean-up: " + (", ".join(done) if done else "nothing to delete")
coding_agent/cli.py ADDED
@@ -0,0 +1,201 @@
1
+ """The terminal front end: `coding-agent -d <project> [instruction]`."""
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+
7
+ import anthropic
8
+
9
+ from . import history, session, state
10
+ from .config import CLEAR_AT, COMPACT_AT, UV, WEB_SEARCH, _get_client, provider_label, uses_deepseek
11
+ from .errors import connection_summary
12
+ from .context import compact_between_instructions, context_status, reset_usage
13
+ from .conversation import save_conversation
14
+ from .loop import send, set_auto_mode
15
+ from .memory import print_memory_status
16
+ from .skills import discover_skills, locations_report, skills_catalog
17
+ from .tools.browser import check_browser
18
+ from .ui import TerminalUI, read_text
19
+
20
+
21
+ def parse_args() -> argparse.Namespace:
22
+ parser = argparse.ArgumentParser(description="Personal coding agent (Claude on Azure).")
23
+ parser.add_argument("instruction", nargs="?", help="Task for the agent. Omit to start in interactive mode.")
24
+ parser.add_argument("-d", "--dir", default=".", help="Project directory the agent works in (default: current directory).")
25
+ parser.add_argument("-i", "--interactive", action="store_true", help="Keep chatting after the instruction finishes.")
26
+ parser.add_argument("-r", "--resume", action="store_true", help="Continue the last conversation in this project.")
27
+ parser.add_argument("--auto", action="store_true", help="Autonomous mode: apply edits and Python runs without asking.")
28
+ parser.add_argument("--read", action="append", default=[], metavar="DIR",
29
+ help="Another folder the agent may read but never write (repeat for several).")
30
+ parser.add_argument("--where", action="store_true", help="Show where memory and skills are read from, then exit.")
31
+ parser.add_argument("--check", action="store_true", help="Test each step of a call to Claude (sign-in, request, streaming, thinking, tools), then exit.")
32
+ parser.add_argument("--memories", nargs="?", const="", metavar="NAME",
33
+ help="List every project's memory (or show one project's notes), then exit.")
34
+ parser.add_argument("--forget", metavar="NAME",
35
+ help="Delete a project's memory (NAME, 'all', or '.' for the -d project), after confirmation, then exit.")
36
+ parser.add_argument("--forget-logins", nargs="?", const="all", metavar="SITE",
37
+ help="Delete the sign-ins kept for the browsing tools (a site, or all), then exit.")
38
+ parser.add_argument("--forget-secrets", action="store_true", help="Delete the mail and SMS logins saved in ~/.coding-agent/secrets, then exit.")
39
+ parser.add_argument("--ocr", nargs="+", metavar="PDF", help="Read the scanned pages of these PDFs once with OCR (tesseract), keep the text for search_pdf and read_pdf, then exit.")
40
+ parser.add_argument("--index", nargs="+", metavar="FOLDER", help="Index the PDFs of these folders for search_library (scanned pages are read with OCR, which takes a while), then exit.")
41
+ parser.add_argument("--check-browser", action="store_true", help="Test the browser used by screenshot_page, then exit.")
42
+ return parser.parse_args()
43
+
44
+
45
+ def run_ocr(files: list[str]) -> None:
46
+ """`coding-agent --ocr FILE...`: OCR every page without a text layer, with a progress line, and keep the text."""
47
+ from . import ocr
48
+ from .config import OCR_LANGS
49
+ from .tools.documents import pdf_page_texts
50
+ if not ocr.available():
51
+ raise SystemExit(ocr.missing_message())
52
+ for name in files:
53
+ path = Path(name).expanduser().resolve()
54
+ texts = pdf_page_texts(path)
55
+ blank = [n for n, t in enumerate(texts, 1) if not t]
56
+ done = ocr.cached(path)
57
+ todo = [n for n in blank if n not in done]
58
+ print(f"{path.name}: {len(texts)} page(s), {len(blank)} without a text layer, {len(blank) - len(todo)} already read ({OCR_LANGS})")
59
+ if todo:
60
+ ocr.read_pages(path, todo, progress=lambda i, total: print(f"\r OCR {i}/{total}", end="", flush=True))
61
+ print()
62
+
63
+
64
+ def run_index(folders: list[str]) -> None:
65
+ """`coding-agent --index FOLDER...`: (re)index the PDFs of each folder, OCR included; only new or changed files are read."""
66
+ from . import index, ocr
67
+ if not ocr.available():
68
+ print("Warning: " + ocr.missing_message() + " Scanned pages will not be indexed.")
69
+ for name in folders:
70
+ root = Path(name).expanduser().resolve()
71
+ if not root.is_dir():
72
+ raise SystemExit(f"Not a folder: {root}")
73
+ read = index.refresh(root, ocr_scans=ocr.available(), log=print,
74
+ progress=lambda i, total: print(f"\r OCR {i}/{total}", end="", flush=True))
75
+ print(f"{root}: {len(index.documents_under(root))} document(s), {len(read)} read")
76
+
77
+
78
+ def main() -> None:
79
+ args = parse_args()
80
+ if args.memories is not None or args.forget: # developers: no project opened, no call to Claude
81
+ from . import memories
82
+ from .config import MEMORY_HOME
83
+ current = session.project_id(Path(args.dir).expanduser().resolve())
84
+ if args.forget:
85
+ print(memories.forget(MEMORY_HOME, current if args.forget == "." else args.forget))
86
+ elif args.memories:
87
+ print(memories.show(MEMORY_HOME, current if args.memories == "." else args.memories))
88
+ else:
89
+ print(memories.listing(MEMORY_HOME, current))
90
+ return
91
+ if args.forget_logins:
92
+ from . import websessions
93
+ removed = websessions.forget(args.forget_logins)
94
+ print("Removed: " + ", ".join(removed) if removed else "No saved sign-in" + ("" if args.forget_logins == "all" else f" for {args.forget_logins}") + ".")
95
+ return
96
+ if args.forget_secrets:
97
+ from . import credentials
98
+ removed = credentials.forget()
99
+ print("Removed: " + ", ".join(removed) if removed else "No saved login.")
100
+ return
101
+ if args.index:
102
+ return run_index(args.index)
103
+ if args.ocr:
104
+ return run_ocr(args.ocr)
105
+ if args.check_browser:
106
+ print(check_browser())
107
+ return
108
+ try:
109
+ workspace = Path(args.dir).expanduser().resolve()
110
+ if args.check:
111
+ from .diagnose import run_check
112
+ session.open_project(workspace, ui=TerminalUI())
113
+ raise SystemExit(0 if run_check() else 1)
114
+ if args.where: # no API client, no saved conversation needed
115
+ session.open_project(workspace, ui=TerminalUI())
116
+ print(f"Workspace: {state.workspace}")
117
+ print(locations_report(verbose=True))
118
+ return
119
+ client = _get_client()
120
+ from .cleanup import run_once as clean_up
121
+ if (cleaned := clean_up()) and cleaned != "Clean-up: nothing to delete": # old backups, unused memories
122
+ print(cleaned)
123
+ print(f"Workspace: {workspace}")
124
+ print(f"Python runner: {'uv run (' + UV + ')' if UV else sys.executable + ' (uv not found)'}")
125
+ session.open_project(workspace, ui=TerminalUI(), resume=args.resume, auto=args.auto)
126
+ state.ask_read_outside = True # reading outside the project: asks first, once per folder
127
+ for folder in args.read: # read-only folders, e.g. documents kept elsewhere
128
+ print(f"Read-only: {session.add_read_folder(folder)}")
129
+ except NotADirectoryError as e:
130
+ raise SystemExit(str(e))
131
+ print(f"{provider_label()}: {connection_summary()}")
132
+ print(locations_report(verbose=False))
133
+ print(f"Web search: {'off' if WEB_SEARCH == 'off' or uses_deepseek() else 'web_search_' + WEB_SEARCH}")
134
+
135
+ try:
136
+ interact(client, session.messages, args)
137
+ finally:
138
+ session.close()
139
+
140
+
141
+ def interact(client: anthropic.Anthropic, messages: list, args: argparse.Namespace) -> None:
142
+ if args.instruction:
143
+ ok = send(client, messages, args.instruction)
144
+ if not args.interactive:
145
+ if not ok:
146
+ raise SystemExit(1) # main() still waits for the memory update first
147
+ return
148
+
149
+ print("Interactive mode. Type 'exit' to quit. Multi-line pastes are sent as one message (or wrap "
150
+ "text in \"\"\" lines).\nCommands: /auto (toggle autonomous mode), /mode, /skills (re-scan "
151
+ "skills), /context, /compact, /clear.")
152
+ history.enable() # up arrow recalls earlier questions
153
+ while True:
154
+ print_memory_status()
155
+ try:
156
+ print()
157
+ user_input = read_text(history.prompt("\033[1mYou:\033[0m ")).strip()
158
+ except (EOFError, KeyboardInterrupt):
159
+ break
160
+ history.remember(user_input)
161
+ if user_input.lower() in ("exit", "quit"):
162
+ break
163
+ if user_input.lower() == "/auto":
164
+ set_auto_mode(not state.auto_mode)
165
+ continue
166
+ if user_input.lower() == "/mode":
167
+ print(f"Autonomous mode is {'ON' if state.auto_mode else 'OFF'}.")
168
+ continue
169
+ if user_input.lower() == "/context":
170
+ print(f"Context: {context_status(messages)} in {len(messages)} messages "
171
+ f"(window from AGENT_CONTEXT_WINDOW; tool outputs cleared past {CLEAR_AT:.0%}, "
172
+ f"compaction past {COMPACT_AT:.0%}).")
173
+ print(f"This session: {state.context['cleared']} tool output(s) cleared, "
174
+ f"{state.context['compactions']} compaction(s).")
175
+ continue
176
+ if user_input.lower() == "/compact":
177
+ try:
178
+ compact_between_instructions(client, messages)
179
+ except (anthropic.APIError, RuntimeError) as e:
180
+ print(f"[compaction failed] {e}")
181
+ continue
182
+ if user_input.lower() == "/clear":
183
+ messages.clear()
184
+ state.pending_blocks.clear()
185
+ state.memory_sent = False
186
+ state.pdf_read_as_text = False # the conversation is empty: read_pdf's visual mode needs a text read again
187
+ reset_usage()
188
+ save_conversation(messages)
189
+ print("Started a fresh conversation (memory notes are kept).")
190
+ continue
191
+ if user_input.lower() == "/skills":
192
+ state.skills = discover_skills()
193
+ print(locations_report(verbose=True))
194
+ state.skills_note = "Updated skill list (the user re-scanned the skill folders):\n" + skills_catalog()
195
+ continue
196
+ if user_input:
197
+ send(client, messages, user_input)
198
+
199
+
200
+ if __name__ == "__main__":
201
+ main()