dataface 0.1.5.dev151__py3-none-any.whl → 0.1.5.dev206__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 (67) hide show
  1. dataface/DATAFACE_SYNTAX.md +6 -5
  2. dataface/agent_api/_paths.py +16 -20
  3. dataface/agent_api/cache.py +24 -0
  4. dataface/agent_api/chat.py +23 -18
  5. dataface/agent_api/dashboards.py +4 -2
  6. dataface/agent_api/describe.py +1 -1
  7. dataface/agent_api/docs/yaml-reference.md +22 -3
  8. dataface/agent_api/files.py +262 -0
  9. dataface/agent_api/project.py +133 -45
  10. dataface/agent_api/render_face.py +100 -0
  11. dataface/agent_api/surface_aliases.yaml +4 -0
  12. dataface/agent_api/validate.py +1 -1
  13. dataface/ai/__init__.py +1 -2
  14. dataface/ai/agent.py +33 -13
  15. dataface/ai/context.py +5 -6
  16. dataface/ai/generate_sql.py +9 -24
  17. dataface/ai/mcp/server.py +26 -22
  18. dataface/ai/prompts/sql-guidance.md +8 -0
  19. dataface/ai/prompts/sql-system.md +13 -0
  20. dataface/ai/prompts/system.md +19 -0
  21. dataface/ai/prompts.py +77 -64
  22. dataface/ai/schema_context.py +39 -14
  23. dataface/ai/skills/dashboard-build/SKILL.md +7 -0
  24. dataface/ai/skills/data-exploration/SKILL.md +75 -0
  25. dataface/ai/tool_schemas.py +28 -0
  26. dataface/ai/tools/__init__.py +56 -15
  27. dataface/cli/commands/chat.py +171 -21
  28. dataface/cli/commands/describe.py +11 -6
  29. dataface/cli/commands/query.py +105 -105
  30. dataface/cli/commands/render.py +42 -27
  31. dataface/cli/commands/validate.py +11 -6
  32. dataface/core/compile/authoring_warnings.py +115 -0
  33. dataface/core/compile/compiler.py +11 -8
  34. dataface/core/compile/config.py +10 -92
  35. dataface/core/compile/models/config.py +2 -17
  36. dataface/core/compile/models/face/authored.py +10 -0
  37. dataface/core/compile/models/face/normalized.py +10 -0
  38. dataface/core/compile/models/face/resolved.py +1 -0
  39. dataface/core/compile/models/style/resolved.py +4 -0
  40. dataface/core/compile/models/style/theme.py +45 -2
  41. dataface/core/compile/models/variable/authored.py +0 -4
  42. dataface/core/compile/normalizer.py +1 -0
  43. dataface/core/compile/sizing.py +10 -12
  44. dataface/core/dashboard.py +7 -14
  45. dataface/core/defaults/default_config.yml +2 -7
  46. dataface/core/defaults/themes/_base.yaml +10 -1
  47. dataface/core/defaults/themes/cream.yaml +3 -1
  48. dataface/core/defaults/themes/editorial.yaml +4 -1
  49. dataface/core/execute/adapters/adapter_registry.py +21 -2
  50. dataface/core/execute/source_registry.py +17 -43
  51. dataface/core/inspect/renderer.py +5 -5
  52. dataface/core/inspect/resolver.py +28 -1
  53. dataface/core/render/face_api.py +28 -129
  54. dataface/core/render/faces.py +17 -14
  55. dataface/core/render/nav.py +181 -0
  56. dataface/core/render/renderer.py +7 -4
  57. dataface/core/render/text/case.py +21 -0
  58. dataface/core/render/variable_controls.py +3 -5
  59. dataface/core/resolve_face.py +7 -2
  60. dataface/core/serve/server.py +261 -37
  61. dataface/core/serve/templates/nav.yml +12 -0
  62. dataface/integrations/markdown.py +77 -68
  63. {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/METADATA +1 -1
  64. {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/RECORD +67 -57
  65. {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/WHEEL +0 -0
  66. {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/entry_points.txt +0 -0
  67. {dataface-0.1.5.dev151.dist-info → dataface-0.1.5.dev206.dist-info}/licenses/LICENSE +0 -0
@@ -597,12 +597,14 @@ support: # Optional support line (same shape: value/label/format/
597
597
  glyph: "▲"
598
598
  tone: positive
599
599
 
600
- # table — renders all query columns unless `style.columns` selects a subset
600
+ # table — renders all query columns unless `style.columns` selects a subset.
601
+ # `columns` is a MAPPING keyed by column name (NOT a list). Omit it to show
602
+ # every query column; include it to choose a subset and/or style columns.
601
603
  type: table
602
604
  style:
603
605
  columns:
604
- - column: order_id
605
- - column: amount
606
+ order_id: {} # include with default styling
607
+ amount: # the key is the column name
606
608
  label: Amount
607
609
  format: currency_whole
608
610
  align: right # left | center | right
@@ -876,7 +878,6 @@ Common variable fields:
876
878
  | `default` | any | Default value when no URL param is set |
877
879
  | `placeholder` | string | Placeholder text |
878
880
  | `required` | bool | Block rendering until a value exists |
879
- | `allow_null` | bool | `null` is a valid selection |
880
881
  | `visible` | bool | Hidden when `false`; still settable via URL param |
881
882
  | `disabled` | bool \| string \| `{query, column}` | Static, Jinja expr, or query-backed disable |
882
883
  | `data_type` | string | Upstream type hint (informational; preserved through migrations) |
@@ -905,7 +906,7 @@ variables:
905
906
 
906
907
  Top-level option-source binding (alternative to `options:`): `column`, `query`, `dimension` (MetricFlow), `measure` (MetricFlow), `model` (dbt).
907
908
 
908
- For `select` and `multiselect`, omitting `default` starts the variable unset. The renderer adds a blank `-- All --` option for that unset state; `{{ filter('column', variable) }}` emits `1=1` unless the SQL call site opts into `none='deny'`.
909
+ Variables are optional by default. For `select` and `multiselect`, omitting `default` starts the variable unset. The renderer adds a blank `-- All --` option for that unset state; `{{ filter('column', variable) }}` emits `1=1` unless the SQL call site opts into `none='deny'`. Add `required: true` only when the dashboard cannot render without a value.
909
910
 
910
911
  Disabled forms:
911
912
 
@@ -5,10 +5,6 @@ from __future__ import annotations
5
5
  from dataclasses import dataclass
6
6
  from pathlib import Path
7
7
 
8
- from dataface.core.execute.adapters import (
9
- AdapterRegistry as AdapterRegistry,
10
- build_adapter_registry as build_adapter_registry,
11
- )
12
8
  from dataface.core.project_roots import (
13
9
  discover_render_context as discover_render_context,
14
10
  discovery_boundary_for_face as discovery_boundary_for_face,
@@ -75,23 +71,26 @@ def no_project_hint(project_dir: Path | None) -> str:
75
71
 
76
72
  @dataclass(frozen=True)
77
73
  class FaceRenderContext:
78
- """Resources resolved from a face path + project root for rendering."""
74
+ """Path resolution result from a face path + project root.
75
+
76
+ Adapter registry is built by `Project.open`, not by the context — call sites
77
+ open a `Project` with `project_root` + `dbt_project_path` and read the
78
+ registry off `project.adapter_registry`.
79
+ """
79
80
 
80
81
  face_file: Path
81
82
  scoped_path: Path | None
82
83
  scoped_base: Path
83
84
  project_root: Path
84
85
  output_dir: Path
85
- adapter_registry: AdapterRegistry
86
+ dbt_project_path: Path | None = None
86
87
 
87
88
 
88
89
  def build_face_render_context(
89
90
  face_path: Path,
90
91
  project_dir: Path | None = None,
91
- *,
92
- read_only: bool = True,
93
92
  ) -> FaceRenderContext:
94
- """Resolve a face path, walk for dbt context, and build the adapter registry.
93
+ """Resolve a face path and walk for dbt context.
95
94
 
96
95
  ``project_dir=None`` means "walk freely from the face's parent" — used by
97
96
  the CLI when ``--project-dir`` is omitted so a face under a dbt sub-project
@@ -127,27 +126,26 @@ def build_face_render_context(
127
126
  scoped_base=project_root,
128
127
  project_root=project_root,
129
128
  output_dir=project_root,
130
- adapter_registry=build_adapter_registry(
131
- project_root, read_only=read_only, dbt_project_path=dbt_project_path
132
- ),
129
+ dbt_project_path=dbt_project_path,
133
130
  )
134
131
 
135
132
 
136
133
  @dataclass(frozen=True)
137
134
  class YamlRenderContext:
138
- """Resources resolved from a project root for rendering inline YAML."""
135
+ """Path resolution result for rendering inline YAML against a project root.
136
+
137
+ Adapter registry is built by `Project.open`, not by the context.
138
+ """
139
139
 
140
140
  project_root: Path
141
141
  output_dir: Path
142
- adapter_registry: AdapterRegistry
142
+ dbt_project_path: Path | None = None
143
143
 
144
144
 
145
145
  def build_yaml_render_context(
146
146
  project_dir: Path | None = None,
147
- *,
148
- read_only: bool = True,
149
147
  ) -> YamlRenderContext:
150
- """Walk for dbt context and build the adapter registry for inline YAML.
148
+ """Walk for dbt context anchored at the given project root.
151
149
 
152
150
  ``project_dir=None`` walks from cwd to discover the project root; a given
153
151
  ``project_dir`` is authoritative.
@@ -160,7 +158,5 @@ def build_yaml_render_context(
160
158
  return YamlRenderContext(
161
159
  project_root=project_root,
162
160
  output_dir=project_root,
163
- adapter_registry=build_adapter_registry(
164
- project_root, read_only=read_only, dbt_project_path=dbt_project_path
165
- ),
161
+ dbt_project_path=dbt_project_path,
166
162
  )
@@ -0,0 +1,24 @@
1
+ """Cache constructors at the agent_api boundary."""
2
+
3
+ from collections.abc import Generator
4
+ from contextlib import contextmanager
5
+
6
+ from dataface.core.execute.duckdb_cache import DuckDBCache, open_cache_from_env
7
+
8
+ __all__ = ["cache_from_env", "open_cache_from_env"]
9
+
10
+
11
+ @contextmanager
12
+ def cache_from_env() -> Generator[DuckDBCache | None]:
13
+ """Open DFT_CACHE_PATH-backed cache (or None) and close it on exit.
14
+
15
+ Use for CLI verbs and composition roots that need a cache scoped to
16
+ a single command invocation. Long-lived processes (server boot, MCP
17
+ server) use ``open_cache_from_env()`` directly and own the lifecycle.
18
+ """
19
+ cache = open_cache_from_env()
20
+ try:
21
+ yield cache
22
+ finally:
23
+ if cache is not None:
24
+ cache.close()
@@ -2,12 +2,18 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
5
6
  from collections.abc import Generator
6
7
  from dataclasses import dataclass, field
7
8
  from datetime import datetime, timezone
8
9
  from pathlib import Path
9
10
  from typing import TYPE_CHECKING, Any
10
11
 
12
+ if sys.version_info >= (3, 11):
13
+ from typing import Self
14
+ else:
15
+ from typing_extensions import Self
16
+
11
17
  from dataface.agent_api._session_store import (
12
18
  SessionIndex,
13
19
  SessionWriter,
@@ -40,6 +46,17 @@ class ChatSession:
40
46
  client: Any = field(repr=False)
41
47
  writer: SessionWriter = field(repr=False)
42
48
 
49
+ def close(self) -> None:
50
+ """Flush the session log and release the project's resources."""
51
+ self.writer.close()
52
+ self.context.project.close()
53
+
54
+ def __enter__(self) -> Self:
55
+ return self
56
+
57
+ def __exit__(self, *exc_info: object) -> None:
58
+ self.close()
59
+
43
60
 
44
61
  @dataclass
45
62
  class ChatSessionSummary:
@@ -64,19 +81,13 @@ def start_session(
64
81
  project_dir: Working directory for the session. Defaults to cwd.
65
82
  server_port: Port of the embedded HTTP preview server, if running.
66
83
  """
67
- from dataface.core.execute.adapters import (
68
- LOCAL_AUTHORING_REGISTRY_KWARGS,
69
- build_adapter_registry,
70
- )
84
+ from dataface.agent_api.project import Project
85
+ from dataface.core.execute.adapters import LOCAL_AUTHORING_REGISTRY_KWARGS
71
86
 
72
87
  cwd = (project_dir or Path.cwd()).resolve()
73
88
  client = create_client(model=model)
74
89
  context = DatafaceAIContext(
75
- adapter_registry=build_adapter_registry(
76
- cwd,
77
- **LOCAL_AUTHORING_REGISTRY_KWARGS,
78
- ),
79
- default_project_dir=cwd,
90
+ project=Project.open(cwd, **LOCAL_AUTHORING_REGISTRY_KWARGS),
80
91
  server_port=server_port,
81
92
  )
82
93
  writer, _ = new_session(cwd, provider=client.provider, model=client.model)
@@ -112,10 +123,8 @@ def resume_session(
112
123
  Raises:
113
124
  ValueError: If session not found, provider mismatch, or context window exceeded.
114
125
  """
115
- from dataface.core.execute.adapters import (
116
- LOCAL_AUTHORING_REGISTRY_KWARGS,
117
- build_adapter_registry,
118
- )
126
+ from dataface.agent_api.project import Project
127
+ from dataface.core.execute.adapters import LOCAL_AUTHORING_REGISTRY_KWARGS
119
128
 
120
129
  cwd = (project_dir or Path.cwd()).resolve()
121
130
  client = create_client(model=model)
@@ -134,11 +143,7 @@ def resume_session(
134
143
  )
135
144
 
136
145
  context = DatafaceAIContext(
137
- adapter_registry=build_adapter_registry(
138
- cwd,
139
- **LOCAL_AUTHORING_REGISTRY_KWARGS,
140
- ),
141
- default_project_dir=cwd,
146
+ project=Project.open(cwd, **LOCAL_AUTHORING_REGISTRY_KWARGS),
142
147
  server_port=server_port,
143
148
  )
144
149
  writer, _ = new_session(cwd, provider=client.provider, model=client.model)
@@ -79,7 +79,7 @@ class RenderDashboardArgs(BaseModel):
79
79
  variables: dict[str, Any] | None = Field(
80
80
  None, description="Variable values to apply to the dashboard"
81
81
  )
82
- format: Literal["json", "text", "yaml", "svg"] | None = Field(
82
+ format: Literal["json", "text", "yaml", "svg", "terminal"] | None = Field(
83
83
  None,
84
84
  description=(
85
85
  "Output format. 'json' (default) returns resolved chart "
@@ -89,7 +89,9 @@ class RenderDashboardArgs(BaseModel):
89
89
  "YAML with inline data — valid input for re-compilation, "
90
90
  "ideal for round-trip editing. 'svg' returns the rendered "
91
91
  "dashboard as inline SVG (under result['data']) for hosts "
92
- "that embed the rendered output directly."
92
+ "that embed the rendered output directly. 'terminal' returns "
93
+ "the charts as ANSI text (under result['data']) for display "
94
+ "in a terminal."
93
95
  ),
94
96
  )
95
97
  as_link: bool = Field(
@@ -87,7 +87,7 @@ class DescribeFaceArgs(BaseModel):
87
87
  default=None,
88
88
  description=(
89
89
  "Project root for resolving relative paths. Optional on the wire; "
90
- "the MCP server injects ctx.default_project_dir or cwd when omitted."
90
+ "the MCP server injects ctx.project.project_root when omitted."
91
91
  ),
92
92
  )
93
93
 
@@ -11,6 +11,7 @@ AuthoredFace (dataface) definition from YAML.
11
11
  | `tags` | list[str] | ✓ | Tags for categorization and search. |
12
12
  | `docs` | str | ✓ | Relative path under the docs site for the canonical doc page that explains this face. Surfaced as a 'Docs →' link in the playground gallery when DFT_DOCS_URL is set. |
13
13
  | `text` | str | ✓ | Markdown text content for text-only sections. |
14
+ | `allow_html` | bool | ✓ | Render the face's body text as raw HTML via foreignObject instead of markdown. TRUSTED-CONTENT ONLY: the HTML (including any Jinja-interpolated values) is rendered as-authored — this is NOT a security sandbox. mdsvg strips <script>/event-handlers as a best-effort guard, not a guarantee. Enable only on first-party faces you fully control. |
14
15
  | `sources` | [SourcesSection](#sourcessection) | ✓ | Database source configuration. Use 'default:' to set the default source for all queries. |
15
16
  | `source` | str | ✓ | Default source name shorthand (equivalent to sources.default). Sets the connection for all queries. |
16
17
  | `variables` | dict[str, [Variable](#variable) \| VariableRef] | ✓ | Variable definitions for dynamic filtering and UI controls. |
@@ -51,7 +52,6 @@ Variable definition from YAML.
51
52
  | `default` | Any | ✓ | Default value used when no URL param is set. |
52
53
  | `placeholder` | str | ✓ | Placeholder text shown in the input when empty. |
53
54
  | `required` | bool | ✓ | When True, a value must be provided before queries execute. |
54
- | `allow_null` | bool | ✓ | When True, 'null' is a valid selection (useful for optional filters). |
55
55
  | `visible` | bool | ✓ | When False, the variable is not rendered in the UI but can still be set via URL params. |
56
56
  | `disabled` | bool \| str \| [SingleRowBoolProbe](#singlerowboolprobe) | ✓ | Disable this control. Accepts: static bool; a variable name or Jinja boolean expression string (no {{ }} required — bare names auto-wrap); or a {query, column} form that reads a single boolean cell from a named query. Absent variable in a string expression raises (use a default). |
57
57
  | `column` | str | ✓ | Column name in the query result to use as option values. |
@@ -491,6 +491,8 @@ Authored overlay for Style — all fields optional. Adds CSS shorthand coercers.
491
491
  | `layout` | [LayoutStyle](#layoutstyle) | ✓ | Layout container styles (rows, cols, grid, tabs, details). |
492
492
  | `variables` | [VariablesStyle](#variablesstyle) | ✓ | Variable controls chrome style. |
493
493
  | `page` | [PageStyle](#pagestyle) | ✓ | Page-level canvas style (behind the board). |
494
+ | `footer` | [FooterStyle](#footerstyle) | ✓ | Page footer chrome visibility. |
495
+ | `timestamp` | [TimestampStyle](#timestampstyle) | ✓ | Render-timestamp chrome: visibility, format, and font. |
494
496
  | `formats` | dict[str, str] | ✓ | Format alias map; None means no aliases at this cascade level. |
495
497
  | `palettes` | dict[str, str] | ✓ | Theme palette role assignments: open dict mapping role name to palette file name. Default seed: chrome, negative, positive, warning, category, sequence, diverge. |
496
498
  | `roles` | dict[str, str] | ✓ | Optional top-level theme role aliases: bare name → role.alias. e.g. ink: chrome.heading |
@@ -1064,8 +1066,7 @@ Authored overlay for BoardStyle. Face-level structural dimensions. Do NOT cascad
1064
1066
  | Field | Type | Optional | Description |
1065
1067
  |-------|------|:--------:|-------------|
1066
1068
  | `width` | float | ✓ | Board width in pixels. |
1067
- | `default_height` | float | ✓ | Default row height in pixels. |
1068
- | `min_height` | float | ✓ | Minimum row height in pixels. |
1069
+ | `min_height` | float | ✓ | Minimum board height in pixels. |
1069
1070
  | `margin` | float | ✓ | Board outer margin in pixels. |
1070
1071
  | `card_padding` | float | ✓ | Padding added to each card side in pixels. |
1071
1072
  | `card_gap` | float | ✓ | Gap between cards in pixels. |
@@ -1222,6 +1223,24 @@ Authored overlay for PageStyle. Page-level (outer HTML canvas) styling.
1222
1223
  |-------|------|:--------:|-------------|
1223
1224
  | `background` | str | ✓ | Page canvas background color (behind the face board). |
1224
1225
 
1226
+ <a id="footerstyle"></a>
1227
+ ## FooterStyle
1228
+ Authored overlay for FooterStyle. Authored visibility toggle for the page footer chrome.
1229
+
1230
+ | Field | Type | Optional | Description |
1231
+ |-------|------|:--------:|-------------|
1232
+ | `visible` | bool | ✓ | Show the footer attribution line. |
1233
+
1234
+ <a id="timestampstyle"></a>
1235
+ ## TimestampStyle
1236
+ Authored overlay for TimestampStyle. Authored timestamp chrome: visibility, strftime format, and font.
1237
+
1238
+ | Field | Type | Optional | Description |
1239
+ |-------|------|:--------:|-------------|
1240
+ | `visible` | bool | ✓ | Show the render-timestamp line. |
1241
+ | `format` | str | ✓ | strftime format string for the render timestamp. |
1242
+ | `font` | [FontStyle](#fontstyle) | ✓ | Timestamp font style overrides (size, color, weight, ...). |
1243
+
1225
1244
  <a id="spacingvalues"></a>
1226
1245
  ## SpacingValues
1227
1246
  Pre-parsed CSS spacing (margin/padding).
@@ -0,0 +1,262 @@
1
+ """General project-file tools for the chat agent.
2
+
3
+ Claude-Code-style primitives — read, write, edit, glob, grep — that let an
4
+ agent author and save faces directly, rather than via a domain-specific
5
+ "write a dashboard" verb. The agent composes these with the typed dft tools
6
+ (``validate_dashboard``, ``render_dashboard``, ``schema``, ``execute_query``).
7
+
8
+ Every path is resolved against and confined to the project root. A path that
9
+ escapes the root (``..`` traversal, absolute path outside, symlink out) is
10
+ refused — these tools never touch the wider filesystem.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from pathlib import Path
16
+
17
+ from pydantic import BaseModel, Field
18
+
19
+ # Cap grep/glob payloads — read_file returns full content (model must handle size).
20
+ _MAX_GREP_MATCHES = 200
21
+ _MAX_GLOB_MATCHES = 500
22
+
23
+ # Directory names to skip on whole-tree scans — avoids traversing .venv,
24
+ # .git, node_modules, target, etc. which can be huge and contain no user code.
25
+ _SKIP_DIRS = frozenset(
26
+ {".git", ".venv", "venv", "node_modules", "target", "__pycache__", ".tox"}
27
+ )
28
+
29
+
30
+ class ReadFileArgs(BaseModel):
31
+ """Read a UTF-8 text file from the project. Path is relative to the project root."""
32
+
33
+ path: str = Field(description="File path relative to the project root.")
34
+
35
+
36
+ class WriteFileArgs(BaseModel):
37
+ """Write a UTF-8 text file in the project, creating parent directories.
38
+
39
+ Overwrites an existing file. To change part of an existing file, prefer
40
+ edit_file so you don't clobber content you haven't read. Path is relative
41
+ to the project root and may not escape it.
42
+ """
43
+
44
+ path: str = Field(description="File path relative to the project root.")
45
+ content: str = Field(description="Full file contents to write.")
46
+
47
+
48
+ class EditFileArgs(BaseModel):
49
+ """Replace an exact string in a project file. old_string must occur exactly once.
50
+
51
+ Fails (writing nothing) if old_string is absent or appears more than once —
52
+ include enough surrounding context to make it unique. Path is relative to
53
+ the project root and may not escape it.
54
+ """
55
+
56
+ path: str = Field(description="File path relative to the project root.")
57
+ old_string: str = Field(description="Exact text to replace (must be unique).")
58
+ new_string: str = Field(description="Replacement text.")
59
+
60
+
61
+ class GlobFilesArgs(BaseModel):
62
+ """List files matching a glob pattern under the project root (e.g. 'faces/*.yml')."""
63
+
64
+ pattern: str = Field(description="Glob pattern relative to the project root.")
65
+
66
+
67
+ class GrepFilesArgs(BaseModel):
68
+ """Search file contents for a substring under the project root.
69
+
70
+ Optionally restrict to files matching a glob (e.g. only 'faces/**/*.yml').
71
+ Skips .git, .venv, node_modules, target, and other large non-user directories.
72
+ """
73
+
74
+ pattern: str = Field(description="Substring to search for (case-sensitive).")
75
+ glob: str | None = Field(
76
+ default=None, description="Optional glob to restrict which files are searched."
77
+ )
78
+
79
+
80
+ class ReadFileResult(BaseModel):
81
+ success: bool
82
+ path: str
83
+ content: str | None = None
84
+ error: str | None = None
85
+
86
+
87
+ class WriteFileResult(BaseModel):
88
+ success: bool
89
+ path: str
90
+ bytes_written: int = 0
91
+ error: str | None = None
92
+
93
+
94
+ class EditFileResult(BaseModel):
95
+ success: bool
96
+ path: str
97
+ replacements: int = 0
98
+ error: str | None = None
99
+
100
+
101
+ class GlobResult(BaseModel):
102
+ success: bool
103
+ matches: list[str] = Field(default_factory=list)
104
+ truncated: bool = False
105
+ error: str | None = None
106
+
107
+
108
+ class GrepMatch(BaseModel):
109
+ path: str
110
+ line_number: int
111
+ line: str
112
+
113
+
114
+ class GrepResult(BaseModel):
115
+ success: bool
116
+ matches: list[GrepMatch] = Field(default_factory=list)
117
+ truncated: bool = False
118
+ error: str | None = None
119
+
120
+
121
+ def _resolve_within(project_dir: Path, raw: str) -> Path:
122
+ """Resolve ``raw`` against the project root, refusing anything that escapes it."""
123
+ root = project_dir.resolve()
124
+ candidate = Path(raw)
125
+ candidate = candidate if candidate.is_absolute() else root / candidate
126
+ resolved = candidate.resolve()
127
+ if resolved != root and root not in resolved.parents:
128
+ raise ValueError(f"path {raw!r} escapes the project root")
129
+ return resolved
130
+
131
+
132
+ def _rel(project_dir: Path, p: Path) -> str:
133
+ return str(p.resolve().relative_to(project_dir.resolve()))
134
+
135
+
136
+ def _walk_project(root: Path) -> list[Path]:
137
+ """Return files under root, skipping known large/irrelevant directories."""
138
+ result: list[Path] = []
139
+ for item in sorted(root.iterdir()):
140
+ if item.is_dir():
141
+ if item.name in _SKIP_DIRS or item.name.startswith("."):
142
+ continue
143
+ result.extend(_walk_project(item))
144
+ elif item.is_file():
145
+ result.append(item)
146
+ return result
147
+
148
+
149
+ def read_file(path: str, project_dir: Path) -> ReadFileResult:
150
+ try:
151
+ target = _resolve_within(project_dir, path)
152
+ except ValueError as exc:
153
+ return ReadFileResult(success=False, path=path, error=str(exc))
154
+ if not target.is_file():
155
+ return ReadFileResult(success=False, path=path, error=f"file not found: {path}")
156
+ try:
157
+ content = target.read_text(encoding="utf-8")
158
+ except (OSError, UnicodeDecodeError) as exc:
159
+ return ReadFileResult(success=False, path=path, error=str(exc))
160
+ return ReadFileResult(success=True, path=_rel(project_dir, target), content=content)
161
+
162
+
163
+ def write_file(path: str, content: str, project_dir: Path) -> WriteFileResult:
164
+ try:
165
+ target = _resolve_within(project_dir, path)
166
+ except ValueError as exc:
167
+ return WriteFileResult(success=False, path=path, error=str(exc))
168
+ try:
169
+ target.parent.mkdir(parents=True, exist_ok=True)
170
+ target.write_text(content, encoding="utf-8")
171
+ except OSError as exc:
172
+ return WriteFileResult(success=False, path=path, error=str(exc))
173
+ return WriteFileResult(
174
+ success=True,
175
+ path=_rel(project_dir, target),
176
+ bytes_written=len(content.encode("utf-8")),
177
+ )
178
+
179
+
180
+ def edit_file(
181
+ path: str, old_string: str, new_string: str, project_dir: Path
182
+ ) -> EditFileResult:
183
+ try:
184
+ target = _resolve_within(project_dir, path)
185
+ except ValueError as exc:
186
+ return EditFileResult(success=False, path=path, error=str(exc))
187
+ if not target.is_file():
188
+ return EditFileResult(success=False, path=path, error=f"file not found: {path}")
189
+ try:
190
+ original = target.read_text(encoding="utf-8")
191
+ except (OSError, UnicodeDecodeError) as exc:
192
+ return EditFileResult(success=False, path=path, error=str(exc))
193
+ count = original.count(old_string)
194
+ if count == 0:
195
+ return EditFileResult(
196
+ success=False, path=path, error="old_string not found in file"
197
+ )
198
+ if count > 1:
199
+ return EditFileResult(
200
+ success=False,
201
+ path=path,
202
+ error=f"old_string is not unique ({count} occurrences) — add surrounding context",
203
+ )
204
+ try:
205
+ target.write_text(original.replace(old_string, new_string), encoding="utf-8")
206
+ except OSError as exc:
207
+ return EditFileResult(success=False, path=path, error=str(exc))
208
+ return EditFileResult(success=True, path=_rel(project_dir, target), replacements=1)
209
+
210
+
211
+ def glob_files(pattern: str, project_dir: Path) -> GlobResult:
212
+ root = project_dir.resolve()
213
+ matches: list[str] = []
214
+ try:
215
+ candidates = root.glob(pattern)
216
+ except (ValueError, OSError, NotImplementedError) as exc:
217
+ return GlobResult(success=False, error=str(exc))
218
+ for p in sorted(candidates):
219
+ if not p.is_file():
220
+ continue
221
+ try:
222
+ rel = p.resolve().relative_to(root)
223
+ except ValueError:
224
+ continue # symlink escaping the root
225
+ matches.append(str(rel))
226
+ if len(matches) >= _MAX_GLOB_MATCHES:
227
+ return GlobResult(success=True, matches=matches, truncated=True)
228
+ return GlobResult(success=True, matches=matches)
229
+
230
+
231
+ def grep_files(pattern: str, project_dir: Path, glob: str | None = None) -> GrepResult:
232
+ root = project_dir.resolve()
233
+ matches: list[GrepMatch] = []
234
+ try:
235
+ candidates: list[Path] = (
236
+ sorted(root.glob(glob)) if glob else _walk_project(root)
237
+ )
238
+ except (ValueError, OSError, NotImplementedError) as exc:
239
+ return GrepResult(success=False, error=str(exc))
240
+ for p in candidates:
241
+ if not p.is_file():
242
+ continue
243
+ try:
244
+ rel = p.resolve().relative_to(root)
245
+ except ValueError:
246
+ continue
247
+ try:
248
+ with p.open(encoding="utf-8") as fh:
249
+ for i, line in enumerate(fh, start=1):
250
+ if pattern in line:
251
+ matches.append(
252
+ GrepMatch(
253
+ path=str(rel), line_number=i, line=line.rstrip("\n")
254
+ )
255
+ )
256
+ if len(matches) >= _MAX_GREP_MATCHES:
257
+ return GrepResult(
258
+ success=True, matches=matches, truncated=True
259
+ )
260
+ except (OSError, UnicodeDecodeError):
261
+ continue # skip binary / unreadable files
262
+ return GrepResult(success=True, matches=matches)