dataface 0.3.2.dev22__py3-none-any.whl → 0.3.2.dev111__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 (185) hide show
  1. dataface/__init__.py +70 -23
  2. dataface/agent_api/__init__.py +162 -68
  3. dataface/agent_api/_paths.py +9 -3
  4. dataface/agent_api/docs/yaml-reference.md +240 -106
  5. dataface/agent_api/init.py +1 -1
  6. dataface/agent_api/project_session.py +2 -1
  7. dataface/agent_api/serve.py +115 -0
  8. dataface/ai/agent.py +28 -2
  9. dataface/cli/_project.py +100 -1
  10. dataface/cli/commands/board_artifact.py +5 -2
  11. dataface/cli/commands/describe.py +7 -3
  12. dataface/cli/commands/docs.py +12 -3
  13. dataface/cli/commands/init.py +7 -14
  14. dataface/cli/commands/inspect.py +6 -1
  15. dataface/cli/commands/mcp_init.py +23 -43
  16. dataface/cli/commands/migrate.py +8 -3
  17. dataface/cli/commands/query.py +11 -8
  18. dataface/cli/commands/render.py +20 -9
  19. dataface/cli/commands/search.py +8 -2
  20. dataface/cli/commands/serve.py +26 -76
  21. dataface/cli/commands/skills_init.py +2 -19
  22. dataface/cli/commands/validate.py +7 -2
  23. dataface/cli/filesystem_project.py +5 -4
  24. dataface/cli/main.py +2 -19
  25. dataface/core/__init__.py +73 -28
  26. dataface/core/{compile/colors.py → colors.py} +20 -5
  27. dataface/core/compile/compiler.py +97 -34
  28. dataface/core/compile/data_table.py +7 -2
  29. dataface/core/compile/enrich.py +18 -44
  30. dataface/core/compile/face_warnings.py +46 -8
  31. dataface/core/compile/format.py +47 -1
  32. dataface/core/compile/inherit_graph.py +3 -3
  33. dataface/core/compile/inherit_registry.yaml +1 -1
  34. dataface/core/compile/migrations.py +112 -9
  35. dataface/core/compile/models/chart/normalized/kpi.py +0 -4
  36. dataface/core/compile/models/chart/resolved/_base.py +28 -10
  37. dataface/core/compile/models/chart/resolved/callout.py +2 -3
  38. dataface/core/compile/models/chart/resolved/kpi.py +7 -3
  39. dataface/core/compile/models/chart/resolved/table.py +16 -2
  40. dataface/core/compile/models/markers.py +1 -1
  41. dataface/core/compile/models/primitives.py +175 -2
  42. dataface/core/compile/models/source.py +5 -5
  43. dataface/core/compile/models/style/authored/__init__.py +32 -8
  44. dataface/core/compile/models/style/authored/_base.py +126 -69
  45. dataface/core/compile/models/style/authored/table.py +61 -3
  46. dataface/core/compile/models/style/resolved/__init__.py +12 -2
  47. dataface/core/compile/models/style/resolved/_base.py +142 -57
  48. dataface/core/compile/models/style/resolved/_cartesian.py +6 -11
  49. dataface/core/compile/models/style/resolved/_cascade.py +141 -54
  50. dataface/core/compile/models/style/resolved/area.py +1 -1
  51. dataface/core/compile/models/style/resolved/bar.py +1 -1
  52. dataface/core/compile/models/style/resolved/line.py +1 -1
  53. dataface/core/compile/models/style/resolved/scatter.py +3 -2
  54. dataface/core/compile/models/style/theme/__init__.py +36 -14
  55. dataface/core/compile/models/style/theme/_chart_base.py +17 -9
  56. dataface/core/compile/models/style/theme/axis.py +432 -312
  57. dataface/core/compile/models/style/theme/charts.py +10 -6
  58. dataface/core/compile/models/style/theme/kpi.py +2 -1
  59. dataface/core/compile/models/style/theme/page.py +5 -2
  60. dataface/core/compile/models/style/theme/table.py +2 -2
  61. dataface/core/compile/models/vega_lite/config.py +1 -1
  62. dataface/core/compile/normalize_charts.py +6 -2
  63. dataface/core/compile/normalize_queries.py +21 -7
  64. dataface/core/compile/resolve/__init__.py +2 -0
  65. dataface/core/compile/resolve/_axes.py +147 -44
  66. dataface/core/compile/resolve/_channels.py +1 -1
  67. dataface/core/compile/resolve/_dispatch.py +1 -1
  68. dataface/core/compile/resolve/_domain.py +49 -32
  69. dataface/core/compile/resolve/_kwargs.py +39 -12
  70. dataface/core/compile/resolve/_marks.py +8 -3
  71. dataface/core/compile/resolve/_palette.py +7 -3
  72. dataface/core/compile/resolve/_table.py +124 -33
  73. dataface/core/compile/resolve/area.py +48 -18
  74. dataface/core/compile/resolve/bar.py +245 -64
  75. dataface/core/compile/resolve/geo.py +10 -3
  76. dataface/core/compile/resolve/heatmap.py +16 -8
  77. dataface/core/compile/resolve/line.py +35 -12
  78. dataface/core/compile/resolve/pie.py +4 -1
  79. dataface/core/compile/resolve/pie_attachment.py +1 -1
  80. dataface/core/compile/resolve/scatter.py +25 -13
  81. dataface/core/compile/resolve/simple.py +25 -2
  82. dataface/core/compile/schema_renderers/prompt.py +1 -1
  83. dataface/core/compile/schema_renderers/textmate_grammar.py +8 -1
  84. dataface/core/compile/source_map.py +211 -28
  85. dataface/core/compile/style_cascade.py +126 -55
  86. dataface/core/compile/yaml_error_formatter.py +1 -1
  87. dataface/core/connections.py +10 -3
  88. dataface/core/dashboard.py +24 -18
  89. dataface/core/defaults/themes/_base.yaml +49 -39
  90. dataface/core/defaults/themes/cream.yaml +4 -4
  91. dataface/core/defaults/themes/diagnostics-title-baseline.yaml +1 -1
  92. dataface/core/defaults/themes/diagnostics-title-color-extreme.yaml +1 -1
  93. dataface/core/defaults/themes/diagnostics-title-font-extreme.yaml +1 -1
  94. dataface/core/defaults/themes/diagnostics-title-size-extreme.yaml +1 -1
  95. dataface/core/defaults/themes/diagnostics-title-weight-extreme.yaml +1 -1
  96. dataface/core/defaults/themes/editorial.yaml +6 -6
  97. dataface/core/defaults/themes/neon.yaml +15 -8
  98. dataface/core/defaults/themes/plain.yaml +6 -4
  99. dataface/core/defaults/themes/stark.yaml +16 -7
  100. dataface/core/defaults/themes/vivid.yaml +15 -8
  101. dataface/core/diagnostics/__init__.py +2 -2
  102. dataface/core/diagnostics/codes_compile.py +13 -31
  103. dataface/core/diagnostics/codes_execute.py +79 -0
  104. dataface/core/diagnostics/codes_render.py +87 -28
  105. dataface/core/diagnostics/diagnostic.py +30 -0
  106. dataface/core/diagnostics/hints.py +19 -0
  107. dataface/core/dialects/base.py +17 -0
  108. dataface/core/dialects/postgres.py +14 -0
  109. dataface/core/dialects/redshift.py +5 -0
  110. dataface/core/execute/__init__.py +59 -14
  111. dataface/core/execute/adapters/dbt_adapter.py +4 -1
  112. dataface/core/execute/adapters/dbt_adapter_factory.py +19 -0
  113. dataface/core/execute/adapters/dbt_utils.py +84 -20
  114. dataface/core/execute/adapters/postgres_connection_manager.py +48 -0
  115. dataface/core/execute/adapters/sql_adapter.py +72 -4
  116. dataface/core/execute/dbt_jinja.py +65 -10
  117. dataface/core/execute/executor.py +69 -16
  118. dataface/core/font_measure.py +28 -0
  119. dataface/core/fonts.py +8 -5
  120. dataface/core/inspect/__init__.py +46 -6
  121. dataface/core/project.py +13 -1
  122. dataface/core/render/chart/data_table_attachment.py +14 -12
  123. dataface/core/render/chart/emitters/_cartesian.py +21 -18
  124. dataface/core/render/chart/emitters/_channels.py +56 -8
  125. dataface/core/render/chart/emitters/_label_overlap.py +18 -15
  126. dataface/core/render/chart/emitters/_measured_label_padding.py +4 -4
  127. dataface/core/render/chart/emitters/area.py +3 -3
  128. dataface/core/render/chart/emitters/bar.py +60 -35
  129. dataface/core/render/chart/emitters/geo.py +8 -13
  130. dataface/core/render/chart/emitters/heatmap.py +20 -26
  131. dataface/core/render/chart/emitters/line.py +2 -2
  132. dataface/core/render/chart/emitters/scatter.py +7 -30
  133. dataface/core/render/chart/features/baseline.py +22 -9
  134. dataface/core/render/chart/features/endpoint_labels.py +55 -32
  135. dataface/core/render/chart/features/mirror_axis.py +12 -9
  136. dataface/core/render/chart/features/value_labels.py +1 -1
  137. dataface/core/render/chart/kpi.py +11 -90
  138. dataface/core/render/chart/presentation.py +6 -8
  139. dataface/core/render/chart/rendering.py +3 -12
  140. dataface/core/render/chart/session.py +10 -0
  141. dataface/core/render/chart/spec.py +3 -0
  142. dataface/core/render/chart/spec_builders.py +18 -18
  143. dataface/core/render/chart/table.py +81 -36
  144. dataface/core/render/chart/table_support.py +2 -53
  145. dataface/core/render/chart/time_unit_detect.py +3 -2
  146. dataface/core/render/chart/translate.py +82 -44
  147. dataface/core/render/chart/type_inference.py +45 -25
  148. dataface/core/render/chart/validation.py +91 -0
  149. dataface/core/render/chart/vega_lite.py +30 -11
  150. dataface/core/render/chart/vl_field_maps.py +111 -76
  151. dataface/core/render/converters/chart.py +16 -6
  152. dataface/core/render/faces.py +3 -2
  153. dataface/core/render/fonts/DFTSerifOldstyleProportional-Regular.woff2 +0 -0
  154. dataface/core/render/fonts/DFTSerifOldstyleTabular-Regular.woff2 +0 -0
  155. dataface/core/render/fonts/SOURCE_CODE_PRO_LICENSE.txt +93 -93
  156. dataface/core/render/fonts/_font_face.css +10 -0
  157. dataface/core/render/layout_sizing.py +267 -238
  158. dataface/core/render/numeral_expr.py +80 -0
  159. dataface/core/render/warnings/bar_band_width_too_narrow.py +64 -6
  160. dataface/core/render/warnings/layered_chart_shared_y_axis_scale_mismatch.py +25 -3
  161. dataface/core/render/warnings/likely_currency_or_percent_missing_formatter.py +12 -6
  162. dataface/core/render/warnings/palette_unsupported.py +6 -0
  163. dataface/core/render/warnings/pie_dominant_segment.py +1 -0
  164. dataface/core/render/warnings/pie_too_many_segments.py +1 -0
  165. dataface/core/render/warnings/point_map_out_of_projection.py +1 -0
  166. dataface/core/render/warnings/query_returned_zero_rows.py +1 -0
  167. dataface/core/render/warnings/redundant_encoding.py +20 -1
  168. dataface/core/render/warnings/registry.py +7 -3
  169. dataface/core/render/warnings/temporal_single_point.py +1 -0
  170. dataface/core/render/warnings/too_many_color_categories.py +1 -0
  171. dataface/core/render/warnings/too_many_x_categories.py +18 -2
  172. dataface/core/render/warnings/value_labels_crowd_width.py +9 -0
  173. dataface/core/render/warnings/y_encoding_mostly_null.py +1 -0
  174. dataface/core/render_format.py +18 -0
  175. dataface/core/text/format_d3.py +56 -0
  176. dataface/core/text/numeral_scale.py +218 -0
  177. dataface/core/utils.py +103 -10
  178. dataface/data/schemas/board-resolved/board-resolved.schema.json +9487 -7978
  179. dataface/data/schemas/yaml/0.3.2.json +1 -0
  180. dataface/data/schemas/yaml/manifest.json +7 -0
  181. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/METADATA +1 -1
  182. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/RECORD +185 -177
  183. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/WHEEL +0 -0
  184. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/entry_points.txt +0 -0
  185. {dataface-0.3.2.dev22.dist-info → dataface-0.3.2.dev111.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,115 @@
1
+ """Typed startup composition for `dft serve`: validation, resolution, and app construction."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ from pathlib import Path
7
+ from typing import TYPE_CHECKING, Any
8
+
9
+ from pydantic import BaseModel, ConfigDict
10
+
11
+ from dataface.core.diagnostics import Diagnostic
12
+ from dataface.core.diagnostics.base import DatafaceError
13
+
14
+ if TYPE_CHECKING:
15
+ from dataface.cli.filesystem_project import FilesystemProject
16
+
17
+
18
+ class ServeSetup(BaseModel):
19
+ """The resolved outcome of a successful `prepare_serve` call.
20
+
21
+ `app` is `Any` rather than `FastAPI`: `create_server`'s real return value is
22
+ a `FastAPI` instance, but the ASGI-app contract `uvicorn.run` actually needs
23
+ is duck-typed (any ASGI-callable) — pinning it to `FastAPI` would reject the
24
+ test doubles `dataface/tests/cli/test_serve_cli.py` legitimately passes in
25
+ place of a real server.
26
+ """
27
+
28
+ model_config = ConfigDict(frozen=True, arbitrary_types_allowed=True)
29
+
30
+ app: Any
31
+ port: int
32
+ dialect: str
33
+ dialect_inferred: bool
34
+
35
+
36
+ def prepare_serve(
37
+ project: FilesystemProject,
38
+ *,
39
+ port: int | None,
40
+ host: str,
41
+ dialect: str | None,
42
+ target: str | None,
43
+ max_workers: int | None,
44
+ no_cache: bool,
45
+ cache_path: Path | None,
46
+ allow_external_access: bool,
47
+ ) -> ServeSetup | Diagnostic:
48
+ """Validate startup config, resolve port/dialect, and build the ASGI app.
49
+
50
+ Returns a `Diagnostic` (not a raise) when `DFT_DEFAULT_THEME` names an
51
+ unknown theme — the CLI formats and exits on either return shape. A
52
+ `dataface.yml` validation failure (`load_config`) raises `ValidationError`
53
+ uncaught; the CLI's own `try/except` around this call handles that,
54
+ unchanged from before this function existed.
55
+ """
56
+ from dataface.core.compile.config import list_built_in_themes, load_config
57
+ from dataface.core.diagnostics import ERR_INVALID_DEFAULT_THEME
58
+ from dataface.core.project_roots import infer_dialect_from_dbt
59
+ from dataface.core.serve.port import resolve_port
60
+ from dataface.core.serve.server import create_server
61
+
62
+ env_theme = os.environ.get(
63
+ "DFT_DEFAULT_THEME"
64
+ ) # noqa: TID251 — DFT_DEFAULT_THEME knob
65
+ # Empty string is treated as unset (matches get_default_theme_name, which
66
+ # falls back to the shipped default) — only a non-empty unknown name errors.
67
+ if env_theme:
68
+ available = list_built_in_themes()
69
+ if env_theme not in available:
70
+ return DatafaceError.from_code(
71
+ ERR_INVALID_DEFAULT_THEME,
72
+ source="DFT_DEFAULT_THEME env var",
73
+ theme=env_theme,
74
+ available=available,
75
+ ).to_diagnostic()
76
+
77
+ # Validate dataface.yml wholesale: stray keys (style:, board:, theme:, etc.) are a
78
+ # hard error here at startup rather than silently ignored.
79
+ load_config(project)
80
+
81
+ project_dir = project.root
82
+ resolved_port = resolve_port(explicit_port=port, project_dir=project_dir, host=host)
83
+
84
+ effective_target = target or os.environ.get("DBT_TARGET") or None
85
+
86
+ dialect_inferred = False
87
+ effective_dialect = dialect
88
+ if effective_dialect is None:
89
+ inferred = infer_dialect_from_dbt(project_dir, effective_target)
90
+ effective_dialect = inferred or "duckdb"
91
+ dialect_inferred = bool(inferred)
92
+
93
+ app = create_server(
94
+ project,
95
+ dialect=effective_dialect,
96
+ target=effective_target,
97
+ max_workers=max_workers,
98
+ no_cache=no_cache,
99
+ cache_path=cache_path,
100
+ allow_external_access_in_readonly=allow_external_access,
101
+ )
102
+
103
+ return ServeSetup(
104
+ app=app,
105
+ port=resolved_port,
106
+ dialect=effective_dialect,
107
+ dialect_inferred=dialect_inferred,
108
+ )
109
+
110
+
111
+ def format_startup_failure(exc: Exception) -> Diagnostic:
112
+ """Wrap an uncaught uvicorn startup exception in the ERR-STARTUP-FAILED envelope."""
113
+ from dataface.core.diagnostics import ERR_STARTUP_FAILED
114
+
115
+ return DatafaceError.from_code(ERR_STARTUP_FAILED, detail=str(exc)).to_diagnostic()
dataface/ai/agent.py CHANGED
@@ -103,7 +103,7 @@ def schema_exploration_guidance(adapter_registry: AdapterRegistry) -> str:
103
103
  Reads only registered connection config (source types) — never the
104
104
  warehouse. Returns "" when no sources are configured (nothing to explore).
105
105
  """
106
- types = {str(s.get("type", "")) for s in adapter_registry.list_sql_sources()}
106
+ types = {s["type"] for s in adapter_registry.list_sql_sources()}
107
107
  if not types:
108
108
  return ""
109
109
  hints = [
@@ -125,7 +125,7 @@ def build_sources_context(adapter_registry: AdapterRegistry) -> str:
125
125
  sources = adapter_registry.list_sql_sources()
126
126
  if not sources:
127
127
  return ""
128
- lines = [f"- {s['name']} ({s.get('type', 'unknown')})" for s in sources]
128
+ lines = [f"- {s['name']} ({s['type']})" for s in sources]
129
129
  return (
130
130
  "## Data sources (use these exact names in `source:` and queries)\n\n"
131
131
  + "\n".join(lines)
@@ -248,6 +248,7 @@ def run_agent(
248
248
  context: DatafaceAIContext,
249
249
  messages: list[AgentMessage] | None = None,
250
250
  max_iterations: int = 25,
251
+ max_identical_calls: int = 3,
251
252
  profile: AgentProfile | None = None,
252
253
  tools: list[dict[str, Any]] | None = None,
253
254
  extra_handlers: dict[str, ToolHandler] | None = None,
@@ -255,6 +256,9 @@ def run_agent(
255
256
  """Run the agent loop and stream typed events.
256
257
 
257
258
  Args:
259
+ max_identical_calls: Consecutive iterations issuing an identical tool-call
260
+ batch (same names and arguments) before the loop errors instead of
261
+ dispatching again; any different call resets the count.
258
262
  profile: An `AgentProfile` supplying the tool surface + system prompt. The
259
263
  way to scope the agent (dashboard vs SQL-specialist vs an experiment).
260
264
  Defaults to the dashboard analyst.
@@ -272,6 +276,9 @@ def run_agent(
272
276
  conversation.append(UserMessage(content=prompt))
273
277
  system_prompt = active.build_system_prompt(context)
274
278
 
279
+ last_call_key: tuple[tuple[str, str], ...] | None = None
280
+ identical_count = 0
281
+
275
282
  for _ in range(max_iterations):
276
283
  response_text = ""
277
284
  tool_calls: list[ToolCallEvent] = []
@@ -306,6 +313,25 @@ def run_agent(
306
313
  yield AgentDone(response=response_text)
307
314
  return
308
315
 
316
+ call_key = tuple(
317
+ (tc.name, json.dumps(tc.arguments, sort_keys=True)) for tc in tool_calls
318
+ )
319
+ if call_key == last_call_key:
320
+ identical_count += 1
321
+ else:
322
+ identical_count = 1
323
+ last_call_key = call_key
324
+
325
+ if identical_count >= max_identical_calls:
326
+ names = ", ".join(dict.fromkeys(tc.name for tc in tool_calls))
327
+ yield AgentError(
328
+ message=(
329
+ f"Stopped: {names} called with identical arguments "
330
+ f"{identical_count} times in a row without making progress."
331
+ )
332
+ )
333
+ return
334
+
309
335
  for tc in tool_calls:
310
336
  result = dispatch_tool_call(
311
337
  tc.name,
dataface/cli/_project.py CHANGED
@@ -10,6 +10,7 @@ fallback. `with_project` is that one rule, injected once.
10
10
  from __future__ import annotations
11
11
 
12
12
  from collections.abc import Callable
13
+ from dataclasses import dataclass
13
14
  from functools import wraps
14
15
  from pathlib import Path
15
16
  from typing import Any, TypeVar
@@ -17,7 +18,11 @@ from typing import Any, TypeVar
17
18
  import typer
18
19
 
19
20
  from dataface.agent_api import Project
20
- from dataface.agent_api._paths import find_dft_root
21
+ from dataface.agent_api._paths import (
22
+ DFT_ROOT_MARKERS as DFT_ROOT_MARKERS,
23
+ find_dft_root,
24
+ find_repo_root,
25
+ )
21
26
  from dataface.cli._error_format import print_error
22
27
  from dataface.cli.filesystem_project import FilesystemProject
23
28
 
@@ -55,6 +60,100 @@ def resolve_project_dir(project_dir: Path | None) -> Path:
55
60
  return found
56
61
 
57
62
 
63
+ def has_faces_marker(start: Path) -> bool:
64
+ """True iff *start* sits inside a Dataface project that has a ``faces/`` directory.
65
+
66
+ Anchors on ``find_dft_root`` so the walk-up stops at the project root rather
67
+ than blindly hunting upward for stray ``faces/`` dirs elsewhere on the filesystem.
68
+ """
69
+ if (start / "faces").is_dir():
70
+ return True
71
+ project = find_dft_root(start)
72
+ return project is not None and (project / "faces").is_dir()
73
+
74
+
75
+ @dataclass(frozen=True)
76
+ class InitRootResolution:
77
+ """Result of ``resolve_init_root``.
78
+
79
+ ``git_root`` is set only for the ambiguous case (no dft project found, but a git
80
+ root diverges from cwd); ``None`` means unambiguous, use ``root`` as-is.
81
+ """
82
+
83
+ root: Path
84
+ git_root: Path | None = None
85
+
86
+
87
+ def resolve_init_root(explicit: Path | None, cwd: Path) -> InitRootResolution:
88
+ """Determine the init scaffold root without prompting the user.
89
+
90
+ Explicit dir wins (resolved, no git_root). Else walks up for a dft project —
91
+ if found, that's unambiguous. Else checks for a git repo: git root == cwd is
92
+ unambiguous; git root above cwd is ambiguous and the caller must prompt.
93
+ """
94
+ if explicit is not None:
95
+ return InitRootResolution(root=explicit.resolve())
96
+
97
+ project_root = find_dft_root(cwd)
98
+ if project_root is not None:
99
+ return InitRootResolution(root=project_root)
100
+
101
+ git_root = find_repo_root(cwd)
102
+ if git_root is None or git_root == cwd.resolve():
103
+ return InitRootResolution(root=cwd.resolve())
104
+
105
+ return InitRootResolution(root=cwd.resolve(), git_root=git_root)
106
+
107
+
108
+ @dataclass(frozen=True)
109
+ class McpProjectDirResolution:
110
+ """Result of ``resolve_mcp_project_dir``.
111
+
112
+ ``ai_config_root`` is always set (nearest dft project root, else git repo root,
113
+ else cwd) — where AI-client configs get written. ``project_dir`` is the project
114
+ the MCP server actually serves, ``None`` when unresolvable; ``nearest_root``
115
+ gives the caller a "did you mean" hint.
116
+ """
117
+
118
+ ai_config_root: Path
119
+ project_dir: Path | None
120
+ nearest_root: Path | None
121
+
122
+
123
+ def resolve_mcp_project_dir(
124
+ explicit: Path | None, cwd: Path
125
+ ) -> McpProjectDirResolution:
126
+ """Determine both where AI configs are written and which project the MCP server serves."""
127
+ dft_root = find_dft_root(cwd)
128
+ ai_config_root = dft_root or find_repo_root(cwd) or cwd
129
+
130
+ if explicit is not None:
131
+ nearest = find_dft_root(explicit)
132
+ project_dir = explicit.resolve() if nearest == explicit.resolve() else None
133
+ return McpProjectDirResolution(
134
+ ai_config_root=ai_config_root,
135
+ project_dir=project_dir,
136
+ nearest_root=nearest,
137
+ )
138
+
139
+ return McpProjectDirResolution(
140
+ ai_config_root=ai_config_root,
141
+ project_dir=dft_root,
142
+ nearest_root=dft_root,
143
+ )
144
+
145
+
146
+ def resolve_skill_install_root(start: Path) -> Path:
147
+ """Return the directory where skills should be installed.
148
+
149
+ Skills are agent tooling that belong at the git root so monorepos with nested
150
+ dft projects don't accumulate divergent skill copies.
151
+ """
152
+ project_root = find_dft_root(start) or start.resolve()
153
+ git_root = find_repo_root(project_root)
154
+ return git_root if git_root is not None else project_root
155
+
156
+
58
157
  def with_project(func: Callable[..., R]) -> Callable[..., R]:
59
158
  """Replace a command body's `project_dir: Path | None` with an injected `project`.
60
159
 
@@ -6,8 +6,7 @@ from pathlib import Path
6
6
 
7
7
  import typer
8
8
 
9
- from dataface.agent_api import Project, ProjectSession
10
- from dataface.agent_api.board_artifact import render_board_from_files
9
+ from dataface.agent_api import Project
11
10
  from dataface.cli._console import dft_console
12
11
  from dataface.cli._error_format import print_diagnostics
13
12
  from dataface.cli._parsing import parse_kv_pairs
@@ -28,6 +27,8 @@ def emit_board_command(
28
27
  json_output: bool = False,
29
28
  ) -> None:
30
29
  """Compile, resolve, and execute a face; write its board artifact + recording."""
30
+ from dataface.agent_api import ProjectSession
31
+
31
32
  boards_dir = face_path.parent / "boards"
32
33
  artifact_path = artifact or boards_dir / f"{face_path.stem}.board.json"
33
34
  recording_path = recording or boards_dir / f"{face_path.stem}.recording.json"
@@ -66,6 +67,8 @@ def render_board_command(
66
67
  json_output: bool = False,
67
68
  ) -> None:
68
69
  """Render a board from its artifact + recording — no compile, no warehouse."""
70
+ from dataface.agent_api.board_artifact import render_board_from_files
71
+
69
72
  result = render_board_from_files(artifact_path, recording_path)
70
73
 
71
74
  if json_output:
@@ -4,17 +4,19 @@ from __future__ import annotations
4
4
 
5
5
  import json
6
6
  from pathlib import Path
7
- from typing import Any
7
+ from typing import TYPE_CHECKING, Any
8
8
 
9
9
  import typer
10
10
  from rich.table import Table
11
11
 
12
- from dataface.agent_api import Project, ProjectSession
13
- from dataface.agent_api.describe import DescribeFaceResult
12
+ from dataface.agent_api import Project
14
13
  from dataface.cli._console import dft_console
15
14
  from dataface.cli._error_format import print_diagnostics
16
15
  from dataface.cli._project import with_project
17
16
 
17
+ if TYPE_CHECKING:
18
+ from dataface.agent_api.describe import DescribeFaceResult
19
+
18
20
  console = dft_console()
19
21
 
20
22
 
@@ -23,6 +25,8 @@ def describe_command(
23
25
  paths: list[Path], *, project: Project, json_output: bool = False
24
26
  ) -> None:
25
27
  """Describe the structure of one or more face files."""
28
+ from dataface.agent_api import ProjectSession
29
+
26
30
  with ProjectSession.from_project(project) as project_session:
27
31
  results = project_session.describe_paths(paths)
28
32
  _emit(results, json_output=json_output)
@@ -1,6 +1,8 @@
1
1
  """CLI command for `dft docs`."""
2
2
 
3
- from typing import Literal
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING, Literal
4
6
 
5
7
  import typer
6
8
  from rich.markdown import Markdown
@@ -8,11 +10,12 @@ from rich.padding import Padding
8
10
  from rich.table import Table
9
11
 
10
12
  from dataface._docs_site import docs_site_url
11
- from dataface.agent_api.diagnostics import get_diagnostic_code, list_diagnostic_codes
12
- from dataface.agent_api.docs import TopicEntry, docs as _docs
13
13
  from dataface.cli._console import dft_console, is_plain_output
14
14
  from dataface.cli._json_output import print_json_result
15
15
 
16
+ if TYPE_CHECKING:
17
+ from dataface.agent_api.docs import TopicEntry
18
+
16
19
  console = dft_console()
17
20
 
18
21
  # The two user-facing CLI verbs over the one diagnostic registry.
@@ -26,6 +29,8 @@ def docs_diagnostics_list(
26
29
  level: Literal["error", "warning"], json_output: bool
27
30
  ) -> None:
28
31
  """Print all registered codes at `level` with one-line descriptions, sorted."""
32
+ from dataface.agent_api.diagnostics import list_diagnostic_codes
33
+
29
34
  result = list_diagnostic_codes(level=level)
30
35
 
31
36
  if json_output:
@@ -61,6 +66,8 @@ def docs_diagnostic_detail(
61
66
  code" for a code we ship — with a note pointing at the verb that matches
62
67
  its actual level.
63
68
  """
69
+ from dataface.agent_api.diagnostics import get_diagnostic_code
70
+
64
71
  result = get_diagnostic_code(code)
65
72
 
66
73
  if json_output:
@@ -113,6 +120,8 @@ def docs_command(
113
120
  docs_diagnostics_list(level, json_output)
114
121
  return
115
122
 
123
+ from dataface.agent_api.docs import docs as _docs
124
+
116
125
  result = _docs(topic=topic, search=search, limit=limit)
117
126
 
118
127
  if json_output:
@@ -8,10 +8,7 @@ from pathlib import Path
8
8
 
9
9
  import typer
10
10
 
11
- from dataface.agent_api.init import init_project
12
-
13
- # tach-ignore(cli->core debt: route via agent_api; burn-down task)
14
- from dataface.core.project_roots import find_dft_root, find_repo_root
11
+ from dataface.cli._project import resolve_init_root
15
12
 
16
13
 
17
14
  def _resolve(
@@ -30,17 +27,11 @@ def _resolve(
30
27
 
31
28
  def _resolve_init_root(project_dir: Path | None, *, yes: bool) -> Path:
32
29
  """Choose the init root for the wizard without changing lower-level API defaults."""
33
- if project_dir is not None:
34
- return project_dir.resolve()
35
-
36
30
  cwd = Path.cwd().resolve()
37
- project_root = find_dft_root(cwd)
38
- if project_root is not None:
39
- return project_root
40
-
41
- git_root = find_repo_root(cwd)
42
- if git_root is None or git_root == cwd:
43
- return cwd
31
+ resolution = resolve_init_root(project_dir, cwd)
32
+ git_root = resolution.git_root
33
+ if git_root is None:
34
+ return resolution.root
44
35
 
45
36
  typer.echo(f" No Dataface or dbt project found above {cwd}.")
46
37
  typer.echo(f" git root: {git_root}")
@@ -70,6 +61,8 @@ def run_wizard(
70
61
  cursor: bool | None,
71
62
  ) -> None:
72
63
  """Run the dft init wizard: prompt, scaffold, dispatch opt-ins."""
64
+ from dataface.agent_api.init import init_project
65
+
73
66
  root = _resolve_init_root(project_dir, yes=yes)
74
67
 
75
68
  if skills is not None:
@@ -12,7 +12,6 @@ from pathlib import Path
12
12
 
13
13
  import typer
14
14
 
15
- from dataface.agent_api import inspect as _api
16
15
  from dataface.cli._console import dft_console
17
16
 
18
17
  console = dft_console()
@@ -24,6 +23,8 @@ def eject_command(
24
23
  force: bool = False,
25
24
  output_dir: Path | None = None,
26
25
  ) -> None:
26
+ from dataface.agent_api import inspect as _api
27
+
27
28
  target_dir = output_dir or (Path.cwd() / "faces" / "inspect")
28
29
  to_eject: list[str] | None = None if all_templates else (templates or None)
29
30
  if not all_templates and not templates:
@@ -55,6 +56,8 @@ def eject_command(
55
56
 
56
57
 
57
58
  def validate_ejected_templates_command(output_dir: Path | None = None) -> None:
59
+ from dataface.agent_api import inspect as _api
60
+
58
61
  target = output_dir or (Path.cwd() / "faces" / "inspect")
59
62
  try:
60
63
  result = _api.validate_ejected_templates(target_dir=target)
@@ -85,6 +88,8 @@ def validate_ejected_templates_command(output_dir: Path | None = None) -> None:
85
88
 
86
89
 
87
90
  def templates_command() -> None:
91
+ from dataface.agent_api import inspect as _api
92
+
88
93
  console.print("\n[bold]Available Inspect Templates[/bold]\n")
89
94
  for t in _api.list_templates():
90
95
  console.print(f" • {t.name}")
@@ -8,43 +8,7 @@ from pathlib import Path
8
8
  import typer
9
9
 
10
10
  from dataface.agent_api import mcp_install
11
-
12
- # tach-ignore(cli->core debt: route via agent_api; burn-down task)
13
- from dataface.core.project_roots import (
14
- DFT_ROOT_MARKERS,
15
- find_dft_root,
16
- find_repo_root,
17
- )
18
-
19
-
20
- def _resolve_project_dir(explicit: Path | None, discovered: Path | None) -> Path:
21
- """The dft project the MCP server points at: an explicit --project-dir (validated)
22
- or the project discovered from cwd. Exits with an error when neither resolves."""
23
- markers_str = ", ".join(DFT_ROOT_MARKERS)
24
- if explicit is not None:
25
- nearest = find_dft_root(explicit)
26
- if nearest != explicit:
27
- msg = (
28
- f"Error: --project-dir {explicit} does not contain a Dataface "
29
- f"or dbt project.\n"
30
- f"Looked for: {markers_str}."
31
- )
32
- if nearest is not None:
33
- msg += f"\nTip: did you mean {nearest}?"
34
- typer.echo(msg, err=True)
35
- raise typer.Exit(1)
36
- return explicit
37
-
38
- if discovered is None:
39
- typer.echo(
40
- f"Error: No Dataface or dbt project found at or above {Path.cwd()}.\n"
41
- f"Looked for: {markers_str}.\n"
42
- f"Re-run from inside your Dataface project, "
43
- f"or pass --project-dir <path>.",
44
- err=True,
45
- )
46
- raise typer.Exit(1)
47
- return discovered
11
+ from dataface.cli._project import DFT_ROOT_MARKERS, resolve_mcp_project_dir
48
12
 
49
13
 
50
14
  def run_init(
@@ -54,13 +18,29 @@ def run_init(
54
18
  project_dir: Path | None = None,
55
19
  ) -> None:
56
20
  """Shared implementation for `dft init mcp`."""
57
- # Where the AI-client configs get written (.cursor/mcp.json, .vscode/mcp.json,
58
- # .mcp.json, …): the dft project root if we're in one, else the repo root, else
59
- # cwd. Distinct from project_dir below — the project the server actually serves.
60
21
  cwd = Path.cwd()
61
- dft_root = find_dft_root(cwd)
62
- ai_config_root = dft_root or find_repo_root(cwd) or cwd
63
- project_dir = _resolve_project_dir(project_dir, dft_root)
22
+ resolution = resolve_mcp_project_dir(project_dir, cwd)
23
+ if resolution.project_dir is None:
24
+ markers_str = ", ".join(DFT_ROOT_MARKERS)
25
+ if project_dir is not None:
26
+ msg = (
27
+ f"Error: --project-dir {project_dir} does not contain a Dataface "
28
+ f"or dbt project.\n"
29
+ f"Looked for: {markers_str}."
30
+ )
31
+ if resolution.nearest_root is not None:
32
+ msg += f"\nTip: did you mean {resolution.nearest_root}?"
33
+ else:
34
+ msg = (
35
+ f"Error: No Dataface or dbt project found at or above {cwd}.\n"
36
+ f"Looked for: {markers_str}.\n"
37
+ f"Re-run from inside your Dataface project, "
38
+ f"or pass --project-dir <path>."
39
+ )
40
+ typer.echo(msg, err=True)
41
+ raise typer.Exit(1)
42
+ ai_config_root = resolution.ai_config_root
43
+ project_dir = resolution.project_dir
64
44
 
65
45
  dft_executable = mcp_install.resolve_dft_executable(ai_config_root)
66
46
  server_args: list[str] = ["mcp", "serve"]
@@ -3,15 +3,17 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from pathlib import Path, PurePosixPath
6
+ from typing import TYPE_CHECKING
6
7
 
7
8
  import typer
8
9
 
9
- from dataface.agent_api import Project, ProjectSession
10
- from dataface.agent_api._paths import resolve_face_path
11
- from dataface.agent_api.migrate import MigrateSummary
10
+ from dataface.agent_api import Project
12
11
  from dataface.cli._console import dft_console
13
12
  from dataface.cli._project import with_project
14
13
 
14
+ if TYPE_CHECKING:
15
+ from dataface.agent_api.migrate import MigrateSummary
16
+
15
17
  _console = dft_console()
16
18
 
17
19
 
@@ -23,6 +25,9 @@ def migrate_command(
23
25
  dry_run: bool,
24
26
  ) -> None:
25
27
  """Rewrite supported retired face syntax in the selected paths."""
28
+ from dataface.agent_api import ProjectSession
29
+ from dataface.agent_api._paths import resolve_face_path
30
+
26
31
  relpaths = (
27
32
  [PurePosixPath(resolve_face_path(path, project).relpath) for path in paths]
28
33
  if paths
@@ -14,14 +14,15 @@ from rich.table import Table
14
14
  if TYPE_CHECKING:
15
15
  from rich.console import Console
16
16
 
17
- from dataface.agent_api import Project, ProjectSession
18
- from dataface.agent_api.describe_query import DescribeQueryResult
19
- from dataface.agent_api.query import (
20
- ExecuteQueryResult,
21
- FaceQueryLookupResult,
22
- QueryFaceResult,
23
- )
24
- from dataface.agent_api.validate_query import QueryDiagnostic
17
+ from dataface.agent_api.describe_query import DescribeQueryResult
18
+ from dataface.agent_api.query import (
19
+ ExecuteQueryResult,
20
+ FaceQueryLookupResult,
21
+ QueryFaceResult,
22
+ )
23
+ from dataface.agent_api.validate_query import QueryDiagnostic
24
+
25
+ from dataface.agent_api import Project
25
26
  from dataface.cli._console import dft_console
26
27
  from dataface.cli._json_output import print_json_result
27
28
  from dataface.cli._project import with_project
@@ -278,6 +279,8 @@ def query_command(
278
279
  project: Project,
279
280
  ) -> None:
280
281
  """Run a named face query, raw SQL, or static SQL lint/describe — selected from the input args."""
282
+ from dataface.agent_api import ProjectSession
283
+
281
284
  # --- 1. Resolve input shape ---
282
285
  sql_text, mode_is_named, query_name, face, source = _resolve_query_input(
283
286
  context, query_text, file