Skip to content

[Feature] Add 'datus package' command: export a self-contained project zip - #1262

Merged
Louis-Law merged 22 commits into
Datus-ai:mainfrom
Louis-Law:feat/package-to-server
Aug 14, 2026
Merged

Louis-Law merged 22 commits into
Datus-ai:mainfrom
Louis-Law:feat/package-to-server

Conversation

@Louis-Law

@Louis-Law Louis-Law commented Aug 6, 2026 •

Copy link
Copy Markdown
Collaborator

Why

Sharing a finished Datus project today means hand-copying config, subject YAML, skills, templates, and reports — and hoping no credential travels along. There is no supported way to hand a working project to another person or machine.

Additionally, ask_report declared BLOB_REQUIRED = True, a constraint serving only an external SaaS host: this repo has zero artifact_blob injectors (report_routes.py states publish + ask_report live in a separate SaaS wrapper), while agent_service.py validates ask-agent creation against disk directories. The result: ask_report could be created but never started in plain CLI mode and in standalone datus-api deployments. datus-agent should not be SaaS-aware — local reports/<slug>/ directories are the source of truth.

Solution

datus package (interactive) — new datus/cli/package_cli.py (wizard) + datus/cli/package_builder.py (pure logic, mirrors the plugin_cli.py/plugin_pack.py split). The only flag is -y/--yes (all-defaults, for scripts/non-TTY); everything else is collected via a linear wizard reusing the existing _cli_utils selectors (select_multi_choice's first production caller). Key mechanisms:

  • Self-contained: conf/agent.yml is regenerated, never copied — home: . plus a pinned project_name make the unzipped directory the entire runtime; the receiver's ~/.datus is untouched.
  • Zero secrets: generation starts from the raw (unexpanded) YAML dict — AgentConfig is a lossy projection, so the raw dict is the only lossless base — and overwrites every known secret path with ${VAR} placeholders (existing placeholders are preserved and harvested into the README env table). A final content scan (PEM headers, token prefixes, secret-named keys in generated configs) fails the build with no bypass.
  • Sources, not indexes: metric/semantic YAML ships with a generated scripts/rebuild_kb.sh (one bootstrap-kb call per file; semantic_model before metrics per datasource). Binary LanceDB indexes never ship.
  • Plugins: scripts/install_plugins.sh generated from the .datus/config.yml activation list with versions pinned from the plugin store.
  • Reports/dashboards are staged through the canonical per-prefix allowlists imported from the API services (no fourth copy); an optional wizard step bundles the web-artifact-render dist and rewrites report index.html to relative _assets/ URLs in memory (source tree never mutated).
  • Integration: main.py epilog + _RESERVED_SUBCOMMANDS + an interception block cloned from plugin, and store.RESERVED_PLUGIN_NAMES kept in sync.

BLOB_REQUIRED removal — ask_report now binds exactly like ask_dashboard: an injected artifact_blob still wins when present and usable (the degenerate-blob gate is untouched), otherwise both kinds fall back to the on-disk artifact directory. This is also what makes packaged reports answerable via ask_report on the receiver.

Test Cases

  • tests/unit_tests/cli/test_package_builder.py (new, 40 cases): built-in exclusions (.datus/memory, .datus/plans, sessions/data/logs/…, .env, *.duckdb.wal), include/exclude regex semantics, escaping-symlink drop, output-zip self-exclusion; agent.yml generation (secrets replaced across providers/models/datasources/BI/schedulers/MCP/document, URI password-component rewrite, placeholder preservation, unknown-section passthrough, var collision suffixing); selectors (unknown-name errors, template staging, global-skill materialization, rebuild-script ordering, artifact allowlist, dist rewrite); requirements + editable confirmation flow; secret scan (PEM, ghp_/AKIA/xoxb/sk- prefixes, binary sniff, generated-config self-check catching a simulated future secret section); end-to-end zip round-trip with sha256 manifest verification.
  • tests/unit_tests/cli/test_package_cli.py (new, 14 cases): --yes zero-prompt defaults, non-TTY guard, wizard happy path with captured PackageOptions, empty-selection double-confirm, summary decline, per-step validation reprompts, dist directory validation, exit codes 0/1/2/3.
  • tests/unit_tests/agent/node/test_ask_artifact_agentic_node.py: report disk-fallback contract tests (degenerate blob → disk; no blob → disk; degenerate blob + missing dir → still raises), missing-dir and symlink-rejection parametrized over both kinds, and the blob↔disk parity test upgraded to a same-kind byte-identical comparison (previously impossible for reports).
  • tests/unit_tests/cli/test_main.py: package interception test; test_plugin_dispatch.py reserved-token loop and test_store.py reserved-name rejection cover the new token.
  • No new integration/nightly tests: the command is fully deterministic and filesystem-local; the unit suite covers the end-to-end zip round-trip without external services, per the CI no-network rule.

🤖 Generated with Claude Code

Backport

  • release/0.3.1
  • release/0.3.2
  • release/0.3.3
  • release/0.3.4
  • release/0.3.5
  • release/0.3.6
  • release/0.3.7
  • release/0.3.8
  • release/0.3.9

Summary by CodeRabbit

  • New Features

    • Added the interactive datus package command to export projects as self-contained ZIP archives.
    • Supports selecting project files, subagents, skills, metrics, reports, dashboards, and dependencies.
    • Generates sanitized configuration, setup guidance, manifests, and rebuild information.
    • Provides non-interactive packaging with --yes.
  • Security

    • Replaces credentials with environment-variable placeholders and scans packaged content for secrets.
  • Bug Fixes

    • Reports and dashboards now fall back to valid local artifacts when in-memory data is unavailable.

Summary by CodeRabbit

  • New Features

    • Added a datus package command with an interactive wizard for exporting selected project content as a self-contained ZIP.
    • Supports filtering project files and resources, configuration sanitization, secret detection, generated setup files, and optional report distribution settings.
    • Added package-build error reporting and CLI help documentation.
  • Bug Fixes

    • Artifact-based agents now fall back to valid on-disk artifacts when injected artifact data is missing or unusable, while continuing to use valid in-memory artifacts.

Louis-Law and others added 2 commits August 6, 2026 18:03
…t zip

Interactive wizard (datus package, -y for defaults) that packs the current
project into a zip the receiver can unzip + pip install + run, without
touching their ~/.datus. conf/agent.yml is regenerated (home: ., pinned
project_name, every secret field rewritten to ${VAR} placeholders) and a
final content scan fails the build on any real credential material.
Ships metric/semantic YAML sources with a generated rebuild_kb.sh, plugin
install commands from the activation list, README with the env-var list,
and a sha256 package manifest.

Also removes BLOB_REQUIRED from ask_report so it falls back to disk
artifact binding like ask_dashboard: datus-agent is not SaaS-aware, local
reports/<slug>/ directories are the source of truth, and no artifact_blob
injector exists in this repo — this also fixes ask_report being unusable
in standalone datus-api deployments. Blob injection still wins when a
host provides one.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

The PR adds a datus package command that builds sanitized project ZIP archives. It also unifies report and dashboard artifact binding so unusable blobs fall back to local disk artifacts.

Changes

Project packaging

Layer / File(s) Summary
Packaging contract and component selection
datus/cli/package_builder.py, tests/unit_tests/cli/test_package_builder.py
The builder defines package options, discovers project components, applies selectors and exclusions, stages selected resources, and validates containment and symlinks.
Configuration sanitization and archive generation
datus/cli/package_builder.py, datus/utils/exceptions.py, tests/unit_tests/cli/test_package_builder.py
The builder sanitizes credentials, generates configuration and receiver files, rebuilds indexes, scans secrets, writes manifests, and creates deterministic ZIP archives.
CLI wizard and command dispatch
datus/cli/main.py, datus/cli/package_cli.py, datus/plugins/store.py, tests/unit_tests/cli/test_main.py, tests/unit_tests/cli/test_package_cli.py, tests/unit_tests/plugins/test_store.py
The CLI dispatches datus package, collects and validates options, invokes the builder, reports results, and reserves package from plugin names.

Artifact binding

Layer / File(s) Summary
Shared blob and disk artifact binding
datus/agent/node/base_artifact_ask_agentic_node.py, tests/unit_tests/agent/node/test_ask_artifact_agentic_node.py
Report and dashboard nodes accept usable blobs and fall back to local artifact directories when blobs are absent or degenerate. Tests cover missing sources, symlinks, disk binding, and rendering parity.

Estimated code review effort: 5 (Critical) | ~120 minutes

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 22.99% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely describes the primary change: adding the datus package command to export self-contained project ZIP archives.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@github-actions

github-actions Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

Test Audit Report

Mode: diff-only (P0 blocks, P1 warns)
Files scanned: 7 · Issues: 1 (0 P0, 1 P1)

Severity Count Status
P0 (always blocks) 0 ✅
P1 (warn-only) 1 ⚠️

P1 Issues (warn-only)

File Line Check Message
tests/unit_tests/agent/node/test_ask_artifact_agentic_node.py 2285 weak_assert Weak assertion (is not None, len > 0, bare truthiness) — passes for almost any value
Breakdown by rule
Rule Count
P1 · weak_assert 1

Static test-quality audit — rules from xUnit Test Patterns / Google Hermetic Tests / F.I.R.S.T. / Khorikov. Source: ci/audit_tests.py. To reproduce locally: python ci/audit_tests.py --paths <file>. To suppress a specific finding, append # audit-noqa: <check_name> on the offending line.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
datus/agent/node/base_artifact_ask_agentic_node.py (1)

554-592: 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Model artifact_blob with Pydantic.

artifact_blob crosses the host-to-agent boundary and is currently parsed with manual Any checks in _is_usable_blob() and _bind_artifact_from_blob(). Define ArtifactBlobFile / ArtifactBlob Pydantic models, validate once when loading the agentic_nodes entry, and fall back to disk binding on validation errors. This follows the Python guideline to use type hints and Pydantic for data structures.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/agent/node/base_artifact_ask_agentic_node.py` around lines 554 - 592,
Define Pydantic models ArtifactBlobFile and ArtifactBlob for the injected
artifact_blob shape, including manifest and non-empty files validation as
needed. Validate artifact_blob once while loading the agentic_nodes entry, use
the validated model in _bind_artifact_from_blob, and fall back to
_bind_artifact_from_disk when validation fails; remove the manual Any-based
validation in _is_usable_blob.

Source: Coding guidelines

🧹 Nitpick comments (5)
datus/cli/package_builder.py (3)

1041-1050: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Shell-quote every interpolated value in the generated scripts. Both generators build bash command lines that the receiver executes, and neither escapes the interpolated values. A datasource directory, a YAML filename, or a plugin distribution name that contains ", $, a backtick, or whitespace breaks the script or injects a command. Use shlex.quote on each interpolated value.

  • datus/cli/package_builder.py#L1041-L1050: wrap ds and rel with shlex.quote in the bootstrap-kb lines instead of relying on literal double quotes.
  • datus/cli/package_builder.py#L1090-L1096: wrap the spec value with shlex.quote in the datus plugin install line.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/cli/package_builder.py` around lines 1041 - 1050, Shell-quote every
interpolated command value using shlex.quote in the generated script commands:
update the bootstrap-kb lines at datus/cli/package_builder.py:1041-1050 to quote
both ds and rel, and update the datus plugin install line at
datus/cli/package_builder.py:1090-1096 to quote spec; replace reliance on
literal double quotes with the escaped values while preserving the existing
command structure.

1194-1216: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Hash the staged files in chunks.

entry.read_bytes() loads each staged file fully into memory to compute its SHA-256. The builder already warns at 100 MB per file, so a single read can allocate that much, and write_zip reads the same bytes again afterwards. A chunked hash keeps memory bounded.

♻️ Proposed refactor
+_HASH_CHUNK_BYTES = 1024 * 1024
+
+
+def _sha256_of(entry: StagedEntry) -> str:
+    digest = hashlib.sha256()
+    if entry.content is not None:
+        digest.update(entry.content)
+        return digest.hexdigest()
+    assert entry.source is not None
+    with open(entry.source, "rb") as fh:
+        for chunk in iter(lambda: fh.read(_HASH_CHUNK_BYTES), b""):
+            digest.update(chunk)
+    return digest.hexdigest()
+
+
 def build_package_manifest(
@@
     files = [
-        {"path": entry.arcname, "sha256": hashlib.sha256(entry.read_bytes()).hexdigest(), "size": entry.size()}
+        {"path": entry.arcname, "sha256": _sha256_of(entry), "size": entry.size()}
         for entry in sorted(entries, key=lambda e: e.arcname)
     ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/cli/package_builder.py` around lines 1194 - 1216, Update
build_package_manifest to compute each file’s SHA-256 incrementally in chunks
instead of calling StagedEntry.read_bytes(), while preserving the existing path,
digest, and size fields. Reuse the staged-file access mechanism exposed by
StagedEntry and ensure the hash loop bounds memory independently of file size.

749-756: 🔒 Security & Privacy | 🔵 Trivial | ⚡ Quick win

Apply the recursive secret sweep to provider entries too.

Datasources, BI platforms, schedulers, and channels all get a _rewrite_secret_named_keys sweep after their explicit fields. Providers only get api_key. A provider entry that carries another credential key, for example secret_key, token, or access_key_secret, passes through into the generated conf/agent.yml untouched. The final scan then fails the build with plaintext_secret_key, so nothing leaks, but the user has no way to package that project.

♻️ Proposed change
     providers = data.get("providers")
     if isinstance(providers, dict):
         for name, entry in providers.items():
             if isinstance(entry, dict):
                 # Conventional names (OPENAI_API_KEY, …) — matches agent.yml.example.
                 _rewrite_secret_value(
                     entry, "api_key", f"{_sanitize_var_component(name)}_API_KEY", f"providers.{name}.api_key", alloc
                 )
+                _rewrite_secret_named_keys(
+                    entry, f"DATUS_PROVIDER_{_sanitize_var_component(name)}", f"providers.{name}", alloc
+                )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/cli/package_builder.py` around lines 749 - 756, Apply the same
recursive secret sweep used for datasources, BI platforms, schedulers, and
channels to each provider entry in package_builder.py after the explicit api_key
rewrite. Update the providers loop in the package-building flow so provider
dicts call _rewrite_secret_named_keys with the correct provider path, while
preserving the existing _rewrite_secret_value handling for api_key. Ensure any
additional credential fields on providers such as secret_key, token, or
access_key_secret are rewritten before the final plaintext_secret_key scan.
tests/unit_tests/cli/test_package_builder.py (1)

394-456: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add coverage for generate_mcp_json.

The fixture never creates {home}/conf/.mcp.json, so generate_mcp_json returns None in every test. The header and env placeholder rewriting for MCP servers, plus the conf/.mcp.json member and its entry in _scan_generated_config, are untested. Add one test that writes an .mcp.json with a plaintext Authorization header and asserts the staged member holds a ${VAR} placeholder.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/cli/test_package_builder.py` around lines 394 - 456, Add a
test in TestGeneratedFiles that creates the fixture’s home conf/.mcp.json with
an MCP server containing a plaintext Authorization header, builds the package,
and verifies the generated conf/.mcp.json member exists with that header
rewritten to the expected ${VAR} placeholder. Also assert the member is included
by _scan_generated_config.
DatusPackage-review.md (1)

58-58: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

为代码块补充语言标识。

markdownlint 报告 MD040。此处的目录树代码块没有语言标识。使用 text 可以消除告警。

♻️ 建议修改
-```
+```text
 sales-project.zip
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@DatusPackage-review.md` at line 58, Update the directory-tree markdown code
block in DatusPackage-review.md to include a language identifier by changing the
fenced block around the sales-project.zip tree to use text. Keep the existing
block content unchanged and apply the fix to the code fence itself so
markdownlint MD040 is satisfied.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@datus/cli/package_builder.py`:
- Around line 1250-1267: Update scan_for_secrets to detect when entry.read_bytes
reaches _SCAN_READ_CAP_BYTES and record a truncation warning instead of silently
scanning only the prefix. Propagate these warnings through _build_package
alongside the existing warnings, while preserving current secret findings and
generated-config scanning behavior.
- Around line 25-28: Update the module docstring’s configuration guidance to
reference the actual extension points, _sanitize_agent_tree and
_SECRET_KEY_NAMES, instead of the nonexistent _SECRET_PATH_TABLE symbol.
- Around line 689-703: Update the URI handling around _URI_CREDENTIAL_RE and
_rewrite_secret_value so passwordless userinfo URIs such as user@host are
preserved rather than replaced wholesale. Only invoke whole-value replacement
when the URI contains an actual password component; otherwise leave the original
URI unchanged and continue allocating or preserving credentials only when
_URI_CREDENTIAL_RE identifies a password.
- Around line 588-605: Update _stage_index_html to validate that report_dist /
_DIST_CSS_NAME and report_dist / _DIST_JS_NAME both exist before rewriting
index.html or returning asset StagedEntry objects. If either file is missing,
raise PackageError with a clear message identifying the missing dist asset,
while preserving the existing staged-as-is behavior for unreadable HTML or
absent CDN URLs.
- Around line 1270-1278: Update write_zip to use one fixed timestamp for every
archive member instead of calling datetime.now(timezone.utc) inside the entry
loop. Reuse the build timestamp already carried by package_manifest.json,
passing it into or otherwise making it available to write_zip, and apply that
same value when constructing each ZipInfo.
- Around line 899-911: Guard the plugin_config_schema lookup and secret-field
extraction inside the plugin-processing loop so any registry or malformed-spec
exception is handled without escaping _build_package. On failure, treat the
schema as unavailable by leaving secret_fields unset, preserve the
all-string-leaves fallback, and emit the existing warning through the
established logging path.

In `@datus/cli/package_cli.py`:
- Line 8: Update the module docstring in package_cli.py to use English only by
replacing the Chinese character in the section reference with the numeric
equivalent, keeping the rest of the docstring unchanged. Anchor the edit on the
top-level docstring near the ``DatusPackage-review.md`` reference and ensure the
text reads with 3 instead of 三.
- Line 90: The helper interfaces around _run_wizard and the downstream helpers
at the referenced locations lack complete typing. Add Python 3.12+ annotations
for raw’s key/value types and replace untyped pb, options, and result parameters
or returns with the appropriate builder and result contract types, covering each
helper signature without changing runtime behavior.

In `@DatusPackage-review.md`:
- Line 76: 将 README.md 的目录注释“env 清单 + 四步快速启动”更新为“五步快速启动”,使其与第 14 行和第 141
行描述的验收流程保持一致。

---

Outside diff comments:
In `@datus/agent/node/base_artifact_ask_agentic_node.py`:
- Around line 554-592: Define Pydantic models ArtifactBlobFile and ArtifactBlob
for the injected artifact_blob shape, including manifest and non-empty files
validation as needed. Validate artifact_blob once while loading the
agentic_nodes entry, use the validated model in _bind_artifact_from_blob, and
fall back to _bind_artifact_from_disk when validation fails; remove the manual
Any-based validation in _is_usable_blob.

---

Nitpick comments:
In `@datus/cli/package_builder.py`:
- Around line 1041-1050: Shell-quote every interpolated command value using
shlex.quote in the generated script commands: update the bootstrap-kb lines at
datus/cli/package_builder.py:1041-1050 to quote both ds and rel, and update the
datus plugin install line at datus/cli/package_builder.py:1090-1096 to quote
spec; replace reliance on literal double quotes with the escaped values while
preserving the existing command structure.
- Around line 1194-1216: Update build_package_manifest to compute each file’s
SHA-256 incrementally in chunks instead of calling StagedEntry.read_bytes(),
while preserving the existing path, digest, and size fields. Reuse the
staged-file access mechanism exposed by StagedEntry and ensure the hash loop
bounds memory independently of file size.
- Around line 749-756: Apply the same recursive secret sweep used for
datasources, BI platforms, schedulers, and channels to each provider entry in
package_builder.py after the explicit api_key rewrite. Update the providers loop
in the package-building flow so provider dicts call _rewrite_secret_named_keys
with the correct provider path, while preserving the existing
_rewrite_secret_value handling for api_key. Ensure any additional credential
fields on providers such as secret_key, token, or access_key_secret are
rewritten before the final plaintext_secret_key scan.

In `@DatusPackage-review.md`:
- Line 58: Update the directory-tree markdown code block in
DatusPackage-review.md to include a language identifier by changing the fenced
block around the sales-project.zip tree to use text. Keep the existing block
content unchanged and apply the fix to the code fence itself so markdownlint
MD040 is satisfied.

In `@tests/unit_tests/cli/test_package_builder.py`:
- Around line 394-456: Add a test in TestGeneratedFiles that creates the
fixture’s home conf/.mcp.json with an MCP server containing a plaintext
Authorization header, builds the package, and verifies the generated
conf/.mcp.json member exists with that header rewritten to the expected ${VAR}
placeholder. Also assert the member is included by _scan_generated_config.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d3b6a64-b25c-450c-94e1-ce0cd8e87449

📥 Commits

Reviewing files that changed from the base of the PR and between e241977 and d67be78.

📒 Files selected for processing (12)
  • DatusPackage-review.md
  • datus/agent/node/ask_report_agentic_node.py
  • datus/agent/node/base_artifact_ask_agentic_node.py
  • datus/cli/main.py
  • datus/cli/package_builder.py
  • datus/cli/package_cli.py
  • datus/plugins/store.py
  • tests/unit_tests/agent/node/test_ask_artifact_agentic_node.py
  • tests/unit_tests/cli/test_main.py
  • tests/unit_tests/cli/test_package_builder.py
  • tests/unit_tests/cli/test_package_cli.py
  • tests/unit_tests/plugins/test_store.py
💤 Files with no reviewable changes (1)
  • datus/agent/node/ask_report_agentic_node.py

Comment thread datus/cli/package_builder.py
Comment thread datus/cli/package_builder.py
Comment thread datus/cli/package_builder.py
Comment thread datus/cli/package_builder.py Outdated
Comment thread datus/cli/package_builder.py Outdated
Comment thread datus/cli/package_builder.py
Comment thread datus/cli/package_cli.py Outdated
Comment thread datus/cli/package_cli.py Outdated
Comment thread DatusPackage-review.md Outdated
… list

Found while packaging a real project (baisheng): datasource host/port/
database placeholders never pass through the secret-path rewriters, so the
generated README's required-env table missed them and the receiver could
not connect. After sanitization, walk the generated agent.yml (and
.mcp.json) and record every remaining ${VAR} / ${VAR:-default} occurrence
— whole-value or embedded — as a preexisting binding.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@github-actions

github-actions Bot commented Aug 6, 2026 •

Copy link
Copy Markdown
Contributor

📊 PR Test Coverage Report

Metric Value Threshold Status
Overall Coverage 85.73% - ℹ️
Diff Coverage 83.00% 80% ✅
PR Tests 17867/19853 passed, 1986 skipped pass ✅
Coverage Details

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • datus/agent/agent.py (25.0%): Missing lines 676-677,686,688,690,696
  • datus/cli/_cli_utils.py (87.7%): Missing lines 41,336,412-416,620,920
  • datus/cli/main.py (100%)
  • datus/cli/package_builder.py (84.9%): Missing lines 286-287,291-293,303-305,308-311,320,322,344,400,415,428,464-466,485-487,495-497,504,512-514,532,549-552,555-562,601,655,658,665,682,743-744,761-762,770-772,776-777,779,821,919,925,942,952-953,990-991,1015-1017,1019-1020,1102-1103,1140-1141,1176-1178,1208,1228,1241,1244,1254,1259,1277,1281-1283,1286-1289,1299-1308,1321,1331-1333,1352,1359-1360,1366,1378-1384,1392,1420,1433-1449,1456-1460,1472-1473,1590,1723,1811-1813,1817-1818,1836-1838,1884-1886,1893,1904,1947,2004
  • datus/cli/package_cli.py (85.7%): Missing lines 58-61,110-111,126,185,203,219,282,291-292,305,337-346,351,373,376-377
  • datus/main.py (100%)
  • datus/plugins/store.py (100%)
  • datus/storage/reference_sql/reference_sql_init.py (8.8%): Missing lines 481-483,491-504,506-507,518-522,524-526,528-529,531-532
  • datus/storage/subject_tree/store.py (92.3%): Missing lines 587
  • datus/utils/exceptions.py (100%)

Summary

  • Total: 1397 lines
  • Missing: 236 lines
  • Coverage: 83%

datus/agent/agent.py

Lines 672-681

  672                         sql_summary_dir, "SQL summary directory", force=force
  673                     ):
  674                         return {"status": "cancelled", "message": "User cancelled deletion of SQL summary directory"}
  675                     self.global_config.save_storage_config("reference_sql")
! 676                 elif kb_update_strategy == "overwrite":
! 677                     self.global_config.save_storage_config("reference_sql")
  678                 else:
  679                     self.global_config.check_init_storage_config("reference_sql")
  680 
  681                 # Initialize reference SQL storage

Lines 682-694

  682                 from datus.storage.reference_sql import ReferenceSqlRAG
  683                 from datus.storage.reference_sql.reference_sql_init import init_reference_sql
  684 
  685                 self.reference_sql_store = ReferenceSqlRAG(self.global_config)
! 686                 if from_summaries:
  687                     # Re-index the committed summary YAML verbatim — no LLM.
! 688                     from datus.storage.reference_sql.reference_sql_init import init_reference_sql_from_summaries
  689 
! 690                     results[component] = init_reference_sql_from_summaries(
  691                         self.reference_sql_store,
  692                         self.global_config,
  693                         summaries_dir=getattr(self.args, "summaries_dir", "") or "",
  694                         build_mode=kb_update_strategy,

Lines 692-700

  692                         self.global_config,
  693                         summaries_dir=getattr(self.args, "summaries_dir", "") or "",
  694                         build_mode=kb_update_strategy,
  695                     )
! 696                     continue
  697 
  698                 if kb_update_strategy == "overwrite":
  699                     self.reference_sql_store.truncate()
  700                 self._reset_reference_sql_stream_state()

datus/cli/_cli_utils.py

Lines 37-45

  37 
  38 
  39 def _raise_if_cancelled(result):
  40     if result is _CANCELLED:
! 41         raise KeyboardInterrupt
  42     return result
  43 
  44 
  45 def prompt_with_back(label: str, default: str = "", password: bool = False) -> str:

Lines 332-340

  332 
  333     except (KeyboardInterrupt, EOFError):
  334         print_warning(console, "\nInput cancelled")
  335         if cancellable:
! 336             raise KeyboardInterrupt from None
  337         return default
  338     except Exception as e:
  339         logger.error(f"Interactive select error: {e}")
  340         print_error(console, f"Selection error: {str(e)}")

Lines 408-420

  408 
  409     def _depth(self, key: str) -> int:
  410         depth, seen = 0, {key}
  411         while key in self._parent_of:
! 412             key = self._parent_of[key]
! 413             if key in seen:  # malformed hierarchy — stop instead of looping
! 414                 break
! 415             seen.add(key)
! 416             depth += 1
  417         return depth
  418 
  419     def _sync_parents(self) -> None:
  420         # Deepest first, so a grandparent sees its children's fresh state.

Lines 616-624

  616 
  617     except (KeyboardInterrupt, EOFError):
  618         print_warning(console, "\nInput cancelled")
  619         if cancellable:
! 620             raise KeyboardInterrupt from None
  621         return []
  622     except Exception as e:
  623         logger.error(f"Interactive multi-select error: {e}")
  624         print_error(console, f"Multi-select error: {str(e)}")

Lines 916-922

  916     """
  917     console.print(f"[bold]{message}[/bold]")
  918     choices = {"y": "Yes", "n": "No"}
  919     default_key = "y" if default else "n"
! 920     result = select_choice(console, choices, default=default_key, cancellable=cancellable)
  921     return result == "y"

datus/cli/package_builder.py

Lines 282-297

  282     leak into the generated file. Returns ``None`` when no config exists.
  283     """
  284     try:
  285         path = parse_config_path("")
! 286     except DatusException:
! 287         return None
  288     try:
  289         with open(path, "r", encoding="utf-8") as fh:
  290             data = yaml.safe_load(fh) or {}
! 291     except (OSError, yaml.YAMLError) as exc:
! 292         logger.warning("package: cannot read agent config %s: %s", path, exc)
! 293         return None
  294     agent = data.get("agent")
  295     return agent if isinstance(agent, dict) else None
  296 

Lines 299-315

  299     """Project name to pin into the package: override > agent.yml > CWD-derived."""
  300     override = load_project_override(cwd=str(root))
  301     if override is not None and override.project_name:
  302         return override.project_name
! 303     raw_name = raw.get("project_name")
! 304     if isinstance(raw_name, str) and raw_name.strip():
! 305         try:
  306             # Private-by-convention validator; packaging must apply the same
  307             # shape rule the loader does or the receiver diverges.
! 308             return _validate_project_name(raw_name.strip())
! 309         except DatusException:
! 310             logger.warning("package: agent.yml project_name %r invalid; deriving from CWD", raw_name)
! 311     return _normalize_project_name(str(root))
  312 
  313 
  314 def resolve_source_home(raw: Dict[str, Any], root: Path) -> Path:
  315     """The *source* project's ``home`` (where templates live)."""

Lines 316-326

  316     home = raw.get("home")
  317     if isinstance(home, str) and home.strip():
  318         expanded = Path(home).expanduser()
  319         if not expanded.is_absolute():
! 320             expanded = (root / expanded).resolve()
  321         return expanded
! 322     return DatusPathManager.resolve_home(None)
  323 
  324 
  325 def list_subagents(raw: Dict[str, Any]) -> Dict[str, str]:
  326     """``{name: description}`` for every ``agentic_nodes`` entry."""

Lines 340-348

  340     path that does NOT follow ``home: .`` on the receiver."""
  341     out: Dict[str, Path] = {}
  342     for base in (Path.home() / ".datus" / "skills", root / ".datus" / "skills"):
  343         if not base.is_dir():
! 344             continue
  345         for entry in sorted(base.iterdir()):
  346             if entry.is_dir() and (entry / "SKILL.md").is_file():
  347                 out[entry.name] = entry
  348     return out

Lines 396-404

  396 def _summary_subject_values(root: Path) -> List[str]:
  397     """``subject_tree`` of every committed reference-SQL summary."""
  398     base = root / _SQL_SUMMARIES_REL
  399     if not base.is_dir():
! 400         return []
  401     return [str(_read_yaml_mapping(path).get("subject_tree") or "") for path in sorted(base.rglob("*.y*ml"))]
  402 
  403 
  404 def _metric_subject_values(root: Path) -> List[str]:

Lines 411-419

  411 
  412 def _metric_yaml_files(root: Path) -> List[Path]:
  413     base = root / "subject" / "semantic_models"
  414     if not base.is_dir():
! 415         return []
  416     return sorted(p for p in base.rglob("*.y*ml") if p.is_file() and "metrics" in p.relative_to(base).parts[:-1])
  417 
  418 
  419 def _metric_subject_path(doc: Any) -> str:

Lines 424-432

  424     shorter form.
  425     """
  426     metric = doc.get("metric") if isinstance(doc, dict) else None
  427     if not isinstance(metric, dict):
! 428         return ""
  429     locked = metric.get("locked_metadata")
  430     tag_lists = [metric.get("tags"), locked.get("tags") if isinstance(locked, dict) else None]
  431     for tags in tag_lists:
  432         for tag in tags or []:

Lines 460-470

  460     roots: List[str] = []
  461     try:
  462         with open(path, "r", encoding="utf-8") as fh:
  463             docs = list(yaml.safe_load_all(fh))
! 464     except (OSError, yaml.YAMLError) as exc:
! 465         logger.warning("package: unreadable metric yaml %s: %s", path, exc)
! 466         return roots
  467     for doc in docs:
  468         subject_path = _metric_subject_path(doc)
  469         if subject_path:
  470             roots.append(subject_path)

Lines 481-491

  481     document is kept, so the caller can ship the file untouched.
  482     """
  483     try:
  484         text = path.read_text(encoding="utf-8")
! 485     except OSError as exc:
! 486         logger.warning("package: unreadable metric yaml %s: %s", path, exc)
! 487         return None
  488 
  489     wanted = set(selected_subjects)
  490     chunks = _split_yaml_documents(text)
  491     kept: List[str] = []

Lines 491-501

  491     kept: List[str] = []
  492     for chunk in chunks:
  493         try:
  494             doc = yaml.safe_load(chunk)
! 495         except yaml.YAMLError:
! 496             kept.append(chunk)  # unparseable: keep rather than silently drop
! 497             continue
  498         subject_path = _metric_subject_path(doc)
  499         # Untagged metrics belong to no subject and would match no selection;
  500         # keep them rather than dropping them from every filtered package.
  501         if not subject_path or _subject_matches(subject_path, wanted):

Lines 500-508

  500         # keep them rather than dropping them from every filtered package.
  501         if not subject_path or _subject_matches(subject_path, wanted):
  502             kept.append(chunk)
  503     if len(kept) == len(chunks):
! 504         return None
  505     return ("\n---\n".join(kept) + "\n").encode("utf-8")
  506 
  507 
  508 def _read_yaml_mapping(path: Path) -> Dict[str, Any]:

Lines 508-518

  508 def _read_yaml_mapping(path: Path) -> Dict[str, Any]:
  509     try:
  510         with open(path, "r", encoding="utf-8") as fh:
  511             doc = yaml.safe_load(fh)
! 512     except (OSError, yaml.YAMLError) as exc:
! 513         logger.warning("package: unreadable yaml %s: %s", path, exc)
! 514         return {}
  515     return doc if isinstance(doc, dict) else {}
  516 
  517 
  518 def _vector_db_subject_roots(root: Path, raw: Dict[str, Any], project_name: str) -> Dict[str, str]:

Lines 528-536

  528     from datus.utils.path_manager import get_path_manager, set_current_path_manager
  529 
  530     datasources = list(((raw.get("services") or {}).get("datasources")) or {})
  531     if not datasources:
! 532         return {}
  533     try:
  534         previous = get_path_manager()
  535     except Exception:  # pragma: no cover - no context installed yet
  536         previous = None

Lines 545-566

  545         for datasource in datasources:
  546             try:
  547                 store = SubjectTreeStore(project=project_name, datasource_id=str(datasource))
  548                 for node in store.get_children(None) or []:
! 549                     name = str(node.get("name") or "").strip()
! 550                     if not name:
! 551                         continue
! 552                     roots.setdefault(name, str(node.get("description") or ""))
  553                     # Second level: a root alone is usually too coarse to be a
  554                     # useful selection (baisheng keeps all 22 metrics under one).
! 555                     for child in store.get_children(node.get("node_id")) or []:
! 556                         child_name = str(child.get("name") or "").strip()
! 557                         if child_name:
! 558                             roots.setdefault(f"{name}/{child_name}", str(child.get("description") or ""))
! 559             except Exception as exc:
! 560                 logger.debug("package: subject tree unavailable for %s: %s", datasource, exc)
! 561     except Exception as exc:
! 562         logger.warning("package: cannot read the subject tree from the vector store: %s", exc)
  563     finally:
  564         if previous is not None:
  565             set_current_path_manager(previous)
  566     return roots

Lines 597-605

  597             parts.append(f"{metric_counts[path]} metrics")
  598         if sql_counts.get(path):
  599             parts.append(f"{sql_counts[path]} reference SQL")
  600         if not parts:
! 601             parts.append("no packaged entries")
  602         description = tree_nodes.get(path) or ""
  603         suffix = f" — {description}" if description else ""
  604         indent = "  └ " if depth else ""
  605         labels[path] = f"{indent}{leaf} ({', '.join(parts)}){suffix}"

Lines 651-662

  651     if override is not None and override.default_datasource:
  652         return override.default_datasource
  653     datasources = ((raw.get("services") or {}).get("datasources")) or {}
  654     if not isinstance(datasources, dict) or not datasources:
! 655         return None
  656     for name, entry in datasources.items():
  657         if isinstance(entry, dict) and entry.get("default"):
! 658             return str(name)
  659     return str(next(iter(datasources))) if len(datasources) == 1 else None
  660 
  661 
  662 def list_metric_datasources(root: Path) -> List[str]:

Lines 661-669

  661 
  662 def list_metric_datasources(root: Path) -> List[str]:
  663     base = root / "subject" / "semantic_models"
  664     if not base.is_dir():
! 665         return []
  666     return sorted(p.name for p in base.iterdir() if p.is_dir())
  667 
  668 
  669 # kind → (top-level dir name, per-prefix walker allowlist). The single map

Lines 678-686

  678     """Slugs with a ``manifest.json`` under ``reports/`` / ``dashboards/``."""
  679     kind_dir, _ = _ARTIFACT_KIND_DIRS[kind]
  680     base = root / kind_dir
  681     if not base.is_dir():
! 682         return []
  683     slugs = []
  684     for entry in sorted(base.iterdir()):
  685         if entry.is_dir() and ARTIFACT_SLUG_RE.fullmatch(entry.name) and (entry / "manifest.json").is_file():
  686             slugs.append(entry.name)

Lines 739-748

  739                 # Selector-owned: metric / reference-SQL selection stages
  740                 # these subtrees, filtered by the chosen subject roots.
  741                 pruned.append(d)
  742             elif (Path(dirpath) / d).is_symlink():
! 743                 warnings.append(f"skipped symlinked directory: {(rel_dir / d).as_posix()}")
! 744                 pruned.append(d)
  745         for d in pruned:
  746             dirnames.remove(d)
  747         dirnames.sort()

Lines 757-766

  757                 continue
  758             try:
  759                 if fpath.resolve() == output_resolved:
  760                     continue
! 761             except OSError:
! 762                 continue
  763             if include_res and not any(p.search(rel) for p in include_res):
  764                 continue
  765             if any(p.search(rel) for p in exclude_res):
  766                 continue

Lines 766-783

  766                 continue
  767             if fpath.is_symlink():
  768                 try:
  769                     resolved = fpath.resolve(strict=True)
! 770                 except OSError:
! 771                     warnings.append(f"skipped broken symlink: {rel}")
! 772                     continue
  773                 if not resolved.is_relative_to(root):
  774                     warnings.append(f"skipped symlink escaping project root: {rel}")
  775                     continue
! 776                 entries.append(StagedEntry(arcname=rel, source=resolved))
! 777                 continue
  778             if not fpath.is_file():
! 779                 continue
  780             entries.append(StagedEntry(arcname=rel, source=fpath))
  781     return entries, warnings
  782 

Lines 817-825

  817             entries.append(StagedEntry(arcname=f"template/{template.name}", source=template))
  818         elif entry.get("system_prompt"):
  819             # Built-in node names fall back to packaged templates; a custom
  820             # system_prompt with no template file will fail on the receiver.
! 821             warnings.append(f"subagent {name!r}: template {template.name} not found under {template_dir}")
  822     return kept, entries, warnings
  823 
  824 
  825 def select_skills(

Lines 915-923

  915     disappearing from every package.
  916     """
  917     base = root / _SQL_SUMMARIES_REL
  918     if not base.is_dir():
! 919         return [], 0, []
  920     wanted = set(selected_subjects)
  921     entries: List[StagedEntry] = []
  922     warnings: List[str] = []
  923     for path in sorted(base.rglob("*.y*ml")):

Lines 921-929

  921     entries: List[StagedEntry] = []
  922     warnings: List[str] = []
  923     for path in sorted(base.rglob("*.y*ml")):
  924         if not path.is_file() or _is_junk_path(path.relative_to(base)):
! 925             continue
  926         subject_path = str(_read_yaml_mapping(path).get("subject_tree") or "")
  927         if not _subject_segments(subject_path):
  928             warnings.append(f"{path.name}: no subject_tree — packaged regardless of the subject selection")
  929         elif not _subject_matches(subject_path, wanted):

Lines 938-946

  938     resolved_root = artifact_dir.resolve()
  939     for sub, (suffixes, recursive) in dirs_spec.items():
  940         base = artifact_dir / sub
  941         if not base.is_dir():
! 942             continue
  943         iterator = base.rglob("*") if recursive else base.iterdir()
  944         for path in iterator:
  945             if not path.is_file() or _is_junk_path(path.relative_to(base)):
  946                 continue

Lines 948-957

  948             if not any(name_lower.endswith(sfx) for sfx in suffixes):
  949                 continue
  950             try:
  951                 path.resolve().relative_to(resolved_root)
! 952             except ValueError:
! 953                 continue  # symlink escaping the artifact — drop
  954             out.append(path)
  955     return sorted(out)
  956 

Lines 986-995

  986         staged_assets = False
  987         for asset_name in (_DIST_CSS_NAME, _DIST_JS_NAME):
  988             asset = assets_dir / asset_name
  989             if asset.is_file():
! 990                 entries.append(StagedEntry(arcname=f"{kind_dir}/{slug}/_assets/{asset_name}", source=asset))
! 991                 staged_assets = True
  992         index_html = artifact_dir / "index.html"
  993         if index_html.is_file():
  994             entries.extend(_stage_index_html(index_html, kind, kind_dir, slug, report_dist, staged_assets, warnings))
  995     return kept, entries, warnings

Lines 1011-1024

  1011     from datus.agent.node.visual_artifact._artifact_html_renderer import CDN_BUNDLE_CSS, CDN_BUNDLE_JS
  1012 
  1013     try:
  1014         html = index_html.read_text(encoding="utf-8")
! 1015     except OSError as exc:
! 1016         warnings.append(f"{arc}: unreadable ({exc}); staged as-is")
! 1017         return [StagedEntry(arcname=arc, source=index_html)]
  1018     if CDN_BUNDLE_CSS not in html and CDN_BUNDLE_JS not in html:
! 1019         warnings.append(f"{arc}: no CDN bundle URLs found; staged as-is")
! 1020         return [StagedEntry(arcname=arc, source=index_html)]
  1021     missing = [name for name in (_DIST_CSS_NAME, _DIST_JS_NAME) if not (report_dist / name).is_file()]
  1022     if missing:
  1023         # The CLI validates the dist dir up front, but the builder API can be
  1024         # handed any path — never rewrite to assets we cannot actually ship.

Lines 1098-1107

  1098             for key, value in tree.items():
  1099                 child = f"{config_path}.{key}" if config_path else str(key)
  1100                 self.harvest(value, child)
  1101         elif isinstance(tree, list):
! 1102             for idx, item in enumerate(tree):
! 1103                 self.harvest(item, f"{config_path}[{idx}]")
  1104         elif isinstance(tree, str):
  1105             for var in _PLACEHOLDER_ANY_RE.findall(tree):
  1106                 if var not in self._used:
  1107                     self._record(var, config_path, preexisting=True)

Lines 1136-1145

  1136     match = _URI_CREDENTIAL_RE.match(value)
  1137     if match:
  1138         pwd = match.group("pwd")
  1139         if _PLACEHOLDER_RE.match(pwd):
! 1140             alloc.keep_preexisting(pwd, config_path)
! 1141             return
  1142         placeholder = alloc.allocate(pwd, preferred_var, config_path)
  1143         container[key] = f"{match.group('prefix')}{placeholder}{match.group('suffix')}"
  1144     elif re.search(r"://[^/@]+:[^@]+@", value):
  1145         # A password component IS present but the URI doesn't parse —

Lines 1172-1182

  1172             elif isinstance(value, str) and (all_string_leaves or _is_secret_key(key)):
  1173                 _rewrite_secret_value(
  1174                     tree, key, f"{scope_var_prefix}_{_sanitize_var_component(key)}", child_path, alloc
  1175                 )
! 1176     elif isinstance(tree, list):
! 1177         for idx, item in enumerate(tree):
! 1178             _rewrite_secret_named_keys(
  1179                 item,
  1180                 f"{scope_var_prefix}_{idx}",
  1181                 f"{config_path}[{idx}]",
  1182                 alloc,

Lines 1204-1212

  1204     models = data.get("models")
  1205     if isinstance(models, dict):
  1206         for name, entry in models.items():
  1207             if not isinstance(entry, dict):
! 1208                 continue
  1209             prefix = f"DATUS_MODEL_{_sanitize_var_component(name)}"
  1210             _rewrite_secret_value(entry, "api_key", f"{prefix}_API_KEY", f"models.{name}.api_key", alloc)
  1211             headers = entry.get("default_headers")
  1212             if isinstance(headers, dict):

Lines 1224-1232

  1224         datasources = services.get("datasources")
  1225         if isinstance(datasources, dict):
  1226             for name, entry in datasources.items():
  1227                 if not isinstance(entry, dict):
! 1228                     continue
  1229                 prefix = f"DATUS_DS_{_sanitize_var_component(name)}"
  1230                 path = f"services.datasources.{name}"
  1231                 for fld in ("password", "username", "account", "private_key_file_pwd"):
  1232                     _rewrite_secret_value(

Lines 1237-1248

  1237                 _rewrite_secret_named_keys(entry, prefix, path, alloc)
  1238         for section, scope in (("bi_platforms", "DATUS_BI"), ("schedulers", "DATUS_SCHEDULER")):
  1239             block = services.get(section)
  1240             if not isinstance(block, dict):
! 1241                 continue
  1242             for name, entry in block.items():
  1243                 if not isinstance(entry, dict):
! 1244                     continue
  1245                 prefix = f"{scope}_{_sanitize_var_component(name)}"
  1246                 path = f"services.{section}.{name}"
  1247                 for fld in ("password", "username", "api_key", "token"):
  1248                     _rewrite_secret_value(

Lines 1250-1263

  1250                     )
  1251                 _rewrite_secret_named_keys(entry, prefix, path, alloc)
  1252         semantic = services.get("semantic_layer")
  1253         if isinstance(semantic, dict):
! 1254             _rewrite_secret_named_keys(semantic, "DATUS_SEMANTIC", "services.semantic_layer", alloc)
  1255         mcp = services.get("mcp_servers")
  1256         if isinstance(mcp, dict):
  1257             for name, entry in mcp.items():
  1258                 if not isinstance(entry, dict):
! 1259                     continue
  1260                 prefix = f"DATUS_MCP_{_sanitize_var_component(name)}"
  1261                 headers = entry.get("headers")
  1262                 if isinstance(headers, dict):
  1263                     for header in list(headers):

Lines 1273-1293

  1273                     _rewrite_secret_named_keys(env, f"{prefix}_ENV", f"services.mcp_servers.{name}.env", alloc)
  1274 
  1275     plugins = data.get("plugins")
  1276     if isinstance(plugins, dict):
! 1277         _sanitize_plugin_profiles(plugins, alloc, warnings)
  1278 
  1279     channels = data.get("channels")
  1280     if isinstance(channels, dict):
! 1281         for name, entry in channels.items():
! 1282             if isinstance(entry, dict):
! 1283                 _rewrite_secret_named_keys(
  1284                     entry, f"DATUS_CHANNEL_{_sanitize_var_component(name)}", f"channels.{name}", alloc
  1285                 )
! 1286                 extra = entry.get("extra")
! 1287                 if isinstance(extra, dict):
! 1288                     for fld in ("bot_token", "app_token", "app_secret", "app_id"):
! 1289                         _rewrite_secret_value(
  1290                             extra,
  1291                             fld,
  1292                             f"DATUS_CHANNEL_{_sanitize_var_component(name)}_{_sanitize_var_component(fld)}",
  1293                             f"channels.{name}.extra.{fld}",

Lines 1295-1312

  1295                         )
  1296 
  1297     observability = data.get("observability")
  1298     if isinstance(observability, dict):
! 1299         _rewrite_secret_named_keys(observability, "DATUS_TRACING", "observability", alloc)
! 1300         tracing = observability.get("tracing")
! 1301         if isinstance(tracing, dict):
! 1302             adapters = tracing.get("adapters")
! 1303             if isinstance(adapters, list):
! 1304                 for idx, adapter in enumerate(adapters):
! 1305                     if isinstance(adapter, dict) and isinstance(adapter.get("headers"), dict):
! 1306                         headers = adapter["headers"]
! 1307                         for header in list(headers):
! 1308                             _rewrite_secret_value(
  1309                                 headers,
  1310                                 header,
  1311                                 f"DATUS_TRACING_{idx}_HEADER_{_sanitize_var_component(header)}",
  1312                                 f"observability.tracing.adapters[{idx}].headers.{header}",

Lines 1317-1325

  1317     if isinstance(document, dict):
  1318         _rewrite_secret_value(document, "tavily_api_key", "TAVILY_API_KEY", "document.tavily_api_key", alloc)
  1319         for name, entry in document.items():
  1320             if isinstance(entry, dict):
! 1321                 _rewrite_secret_value(
  1322                     entry,
  1323                     "github_token",
  1324                     "GITHUB_TOKEN",
  1325                     f"document.{name}.github_token",

Lines 1327-1337

  1327                 )
  1328 
  1329     api = data.get("api")
  1330     if isinstance(api, dict):
! 1331         auth = api.get("auth_provider")
! 1332         if isinstance(auth, dict) and isinstance(auth.get("kwargs"), dict):
! 1333             _rewrite_secret_named_keys(
  1334                 auth["kwargs"], "DATUS_API_AUTH", "api.auth_provider.kwargs", alloc, all_string_leaves=True
  1335             )
  1336 

Lines 1348-1356

  1348         plugin_config_schema = None  # type: ignore[assignment]
  1349 
  1350     for plugin_name, profiles in plugins.items():
  1351         if not isinstance(profiles, dict):
! 1352             continue
  1353         secret_fields: Optional[Set[str]] = None
  1354         if plugin_config_schema is not None:
  1355             # A broken plugin manifest must degrade to "no schema" (all string
  1356             # leaves become placeholders), not crash the build.

Lines 1355-1364

  1355             # A broken plugin manifest must degrade to "no schema" (all string
  1356             # leaves become placeholders), not crash the build.
  1357             try:
  1358                 specs = plugin_config_schema(str(plugin_name))
! 1359                 if specs:
! 1360                     secret_fields = {spec["name"] for spec in specs if isinstance(spec, dict) and spec.get("secret")}
  1361             except Exception as exc:
  1362                 logger.warning("package: plugin_config_schema(%r) failed: %s", plugin_name, exc)
  1363                 secret_fields = None
  1364         for profile_name, profile in profiles.items():

Lines 1362-1370

  1362                 logger.warning("package: plugin_config_schema(%r) failed: %s", plugin_name, exc)
  1363                 secret_fields = None
  1364         for profile_name, profile in profiles.items():
  1365             if not isinstance(profile, dict):
! 1366                 continue
  1367             prefix = f"DATUS_PLUGIN_{_sanitize_var_component(plugin_name)}_{_sanitize_var_component(profile_name)}"
  1368             path = f"plugins.{plugin_name}.{profile_name}"
  1369             if secret_fields is None:
  1370                 warnings.append(

Lines 1374-1388

  1374                 _rewrite_secret_named_keys(profile, prefix, path, alloc, all_string_leaves=True)
  1375             else:
  1376                 # Sorted: allocation order decides collision suffixes (_2, _3),
  1377                 # so set order would rename variables between builds.
! 1378                 for dotted in sorted(secret_fields):
! 1379                     container: Any = profile
! 1380                     parts = dotted.split(".")
! 1381                     for part in parts[:-1]:
! 1382                         container = container.get(part) if isinstance(container, dict) else None
! 1383                     if isinstance(container, dict):
! 1384                         _rewrite_secret_value(
  1385                             container,
  1386                             parts[-1],
  1387                             f"{prefix}_{_sanitize_var_component(dotted)}",
  1388                             f"{path}.{dotted}",

Lines 1388-1396

  1388                             f"{path}.{dotted}",
  1389                             alloc,
  1390                         )
  1391                 # Belt & braces: secret-named keys outside the schema.
! 1392                 _rewrite_secret_named_keys(profile, prefix, path, alloc)
  1393 
  1394 
  1395 def generate_agent_yml(
  1396     raw: Dict[str, Any],

Lines 1416-1424

  1416         filtered = {name: nodes[name] for name in kept_subagents if name in nodes}
  1417         if filtered:
  1418             data["agentic_nodes"] = filtered
  1419         else:
! 1420             data.pop("agentic_nodes", None)
  1421 
  1422     _sanitize_agent_tree(data, alloc, warnings)
  1423     alloc.harvest(data, "")
  1424     text = yaml.safe_dump({"agent": data}, allow_unicode=True, sort_keys=False)

Lines 1429-1453

  1429     """Sanitized copy of ``{home}/conf/.mcp.json`` (headers → placeholders)."""
  1430     source = source_home / "conf" / ".mcp.json"
  1431     if not source.is_file():
  1432         return None
! 1433     try:
! 1434         payload = json.loads(source.read_text(encoding="utf-8"))
! 1435     except (OSError, json.JSONDecodeError) as exc:
! 1436         logger.warning("package: unreadable %s (%s); skipping", source, exc)
! 1437         return None
! 1438     if not isinstance(payload, dict):
! 1439         return None
! 1440     servers = payload.get("mcpServers")
! 1441     if isinstance(servers, dict):
! 1442         for name, entry in servers.items():
! 1443             if not isinstance(entry, dict):
! 1444                 continue
! 1445             prefix = f"DATUS_MCP_{_sanitize_var_component(name)}"
! 1446             headers = entry.get("headers")
! 1447             if isinstance(headers, dict):
! 1448                 for header in list(headers):
! 1449                     _rewrite_secret_value(
  1450                         headers,
  1451                         header,
  1452                         f"{prefix}_HEADER_{_sanitize_var_component(header)}",
  1453                         f".mcp.json:{name}.headers.{header}",

Lines 1452-1464

  1452                         f"{prefix}_HEADER_{_sanitize_var_component(header)}",
  1453                         f".mcp.json:{name}.headers.{header}",
  1454                         alloc,
  1455                     )
! 1456             env = entry.get("env")
! 1457             if isinstance(env, dict):
! 1458                 _rewrite_secret_named_keys(env, f"{prefix}_ENV", f".mcp.json:{name}.env", alloc)
! 1459     alloc.harvest(payload, ".mcp.json")
! 1460     return (json.dumps(payload, indent=2, ensure_ascii=False) + "\n").encode("utf-8")
  1461 
  1462 
  1463 def generate_project_config(root: Path, project_name: str) -> bytes:
  1464     """Regenerate ``.datus/config.yml`` with the pinned project name."""

Lines 1468-1477

  1468         try:
  1469             loaded = yaml.safe_load(source.read_text(encoding="utf-8"))
  1470             if isinstance(loaded, dict):
  1471                 payload = loaded
! 1472         except (OSError, yaml.YAMLError) as exc:
! 1473             logger.warning("package: unreadable %s (%s); regenerating minimal config", source, exc)
  1474     payload["project_name"] = project_name
  1475     text = yaml.safe_dump(payload, allow_unicode=True, sort_keys=False, default_flow_style=False)
  1476     return text.encode("utf-8")

Lines 1586-1594

  1586     for name in sorted(kept):
  1587         distribution, version = versions.get(name, (name, ""))
  1588         spec = f"{distribution}=={version}" if version else distribution
  1589         if not version:
! 1590             warnings.append(f"plugin {name!r}: installed version unknown; install line left unpinned")
  1591         # --force replaces an already-installed plugin, so re-running the
  1592         # script (or init.sh) is idempotent instead of erroring out.
  1593         commands.append(f"datus plugin install {spec} --force")
  1594     script = "\n".join(

Lines 1719-1727

  1719             grouped.setdefault(binding.var, []).append(binding.config_path)
  1720         for var in sorted(grouped):
  1721             lines.append(f"| `{var}` | {', '.join(sorted(set(grouped[var])))} |")
  1722     else:
! 1723         lines.append("None — this package carries no credential-bearing config.")
  1724     lines += [
  1725         "",
  1726         "`.env` files are NOT auto-loaded when datus-agent is installed via pip.",
  1727         "Export the variables in your shell, or run `set -a; source .env; set +a`.",

Lines 1807-1822

  1807                     _walk(value, child)
  1808                 elif isinstance(value, str) and value.strip() and _is_secret_key(str(key)):
  1809                     if not _PLACEHOLDER_RE.match(value.strip()):
  1810                         findings.append(SecretFinding(arcname=arcname, locator=child, kind="plaintext_secret_key"))
! 1811         elif isinstance(node, list):
! 1812             for idx, item in enumerate(node):
! 1813                 _walk(item, f"{path}[{idx}]")
  1814 
  1815     try:
  1816         parsed = json.loads(text) if arcname.endswith(".json") else yaml.safe_load(text)
! 1817     except (ValueError, yaml.YAMLError):
! 1818         return findings
  1819     _walk(parsed, "")
  1820     return findings
  1821 

Lines 1832-1842

  1832     generated_conf = {"conf/agent.yml", "conf/.mcp.json", PROJECT_CONFIG_REL}
  1833     for entry in entries:
  1834         try:
  1835             head = entry.read_bytes(cap=_SCAN_READ_CAP_BYTES)
! 1836         except OSError as exc:
! 1837             findings.append(SecretFinding(arcname=entry.arcname, locator=str(exc), kind="unreadable"))
! 1838             continue
  1839         if entry.size() > _SCAN_READ_CAP_BYTES:
  1840             warnings.append(
  1841                 f"secret scan truncated for {entry.arcname}: only the first "
  1842                 f"{_SCAN_READ_CAP_BYTES // (1024 * 1024)} MB of {entry.size()} bytes were scanned"

Lines 1880-1890

  1880     try:
  1881         return _build_package(options)
  1882     except PackageError as exc:
  1883         return PackageResult(ok=False, error=str(exc))
! 1884     except (OSError, zipfile.BadZipFile) as exc:
! 1885         logger.error("package build failed: %s", exc)
! 1886         return PackageResult(ok=False, error=str(exc))
  1887 
  1888 
  1889 def _build_package(options: PackageOptions) -> PackageResult:
  1890     root = options.root.resolve()

Lines 1889-1897

  1889 def _build_package(options: PackageOptions) -> PackageResult:
  1890     root = options.root.resolve()
  1891     raw = load_raw_agent_config()
  1892     if raw is None:
! 1893         raise PackageError("no agent configuration found (conf/agent.yml or ~/.datus/conf/agent.yml)")
  1894     project_name = resolve_effective_project_name(root, raw)
  1895     source_home = resolve_source_home(raw, root)
  1896     output_path = (options.output or default_output_path(root, project_name)).resolve()

Lines 1900-1908

  1900 
  1901     def _add(new_entries: Sequence[StagedEntry]) -> None:
  1902         for entry in new_entries:
  1903             if entry.arcname in entries_by_arc:
! 1904                 warnings.append(f"duplicate staging of {entry.arcname}; keeping the later entry")
  1905             entries_by_arc[entry.arcname] = entry
  1906 
  1907     walk_entries, walk_warnings = collect_project_files(root, options.include, options.exclude, output_path)
  1908     warnings.extend(walk_warnings)

Lines 1943-1951

  1943     _add([StagedEntry(arcname="conf/agent.yml", content=agent_yml)])
  1944 
  1945     mcp_json = generate_mcp_json(source_home, alloc)
  1946     if mcp_json is not None:
! 1947         _add([StagedEntry(arcname="conf/.mcp.json", content=mcp_json)])
  1948 
  1949     _add([StagedEntry(arcname=PROJECT_CONFIG_REL, content=generate_project_config(root, project_name))])
  1950 
  1951     packages = enumerate_datus_packages()

Lines 2000-2008

  2000         staged.append(entry)
  2001 
  2002     for entry in staged:
  2003         if entry.source is not None and entry.size() > _LARGE_FILE_WARN_BYTES:
! 2004             warnings.append(f"large file packaged: {entry.arcname} ({entry.size() // (1024 * 1024)} MB)")
  2005 
  2006     selections = {
  2007         "subagents": kept_subagents,
  2008         "skills": kept_skills,

datus/cli/package_cli.py

Lines 54-65

  54 
  55 
  56 def _is_interactive() -> bool:
  57     """Both streams must be TTYs — mirrors ``service_bootstrap._is_interactive``."""
! 58     try:
! 59         return bool(sys.stdin.isatty() and sys.stdout.isatty())
! 60     except (AttributeError, OSError):
! 61         return False
  62 
  63 
  64 def run_package_command(argv: List[str]) -> int:
  65     parser = _build_parser()

Lines 106-115

  106         path = Path(target)
  107         if path.is_file():
  108             path.unlink()
  109             print_warning(console, f"Removed the partially written {path}")
! 110     except OSError as exc:
! 111         print_warning(console, f"Could not remove the partially written {target}: {exc}")
  112 
  113 
  114 # --------------------------------------------------------------------------- #
  115 # Wizard                                                                      #

Lines 122-130

  122     print_info(console, f"Packaging project {project_name!r} from {root}")
  123 
  124     output = _step_output_path(console, pb, root, project_name)
  125     if output is None:
! 126         return None
  127 
  128     include, exclude = _step_file_scope(console)
  129 
  130     subagents = _step_multi(

Lines 181-189

  181 
  182 def _is_under(path: Path, root: Path) -> bool:
  183     try:
  184         path.resolve().relative_to(root.resolve())
! 185         return True
  186     except ValueError:
  187         return False
  188 

Lines 199-207

  199             continue
  200         if candidate.exists() and not confirm_prompt(
  201             console, f"{candidate} exists — overwrite?", default=False, cancellable=True
  202         ):
! 203             continue
  204         return candidate
  205 
  206 
  207 def _step_file_scope(console: Console) -> Tuple[List[str], List[str]]:

Lines 215-223

  215 def _prompt_patterns(console: Console, message: str) -> List[str]:
  216     while True:
  217         answer = prompt_input(console, message, default="", allow_interrupt=True).strip()
  218         if not answer:
! 219             return []
  220         patterns = [part.strip() for part in answer.split(",") if part.strip()]
  221         for pattern in patterns:
  222             try:
  223                 re.compile(pattern)

Lines 278-286

  278     nodes = raw.get("agentic_nodes")
  279     if isinstance(nodes, dict):
  280         gen_report = nodes.get("gen_visual_report")
  281         if isinstance(gen_report, dict) and isinstance(gen_report.get("report_dist"), str):
! 282             configured = gen_report["report_dist"]
  283     while True:
  284         answer = prompt_input(
  285             console,
  286             "Path to the web-artifact-render dist directory (empty = skip)",

Lines 287-296

  287             default=configured,
  288             allow_interrupt=True,
  289         ).strip()
  290         if not answer:
! 291             print_info(console, "Skipping the local dist; reports will use the CDN.")
! 292             return None
  293         resolved = _resolve_dist(Path(answer))
  294         if resolved is None:
  295             print_warning(console, f"{answer} is not a valid dist (needs index.css + index.umd.js)")
  296             continue

Lines 301-309

  301     from datus.cli._render_utils import build_row_table
  302 
  303     def _fmt(values: Optional[Tuple[str, ...]]) -> str:
  304         if values is None:
! 305             return "(all)"
  306         return ", ".join(values) if values else "(none)"
  307 
  308     rows = [
  309         {"item": "Project", "value": project_name},

Lines 333-355

  333 def _selection_summary_lines(selections: Dict) -> List[str]:
  334     """Human-readable "what went in" lines for the build result."""
  335     if not selections:
  336         return []
! 337     lines: List[str] = []
! 338     subjects = selections.get("subjects")
! 339     if subjects is not None:
! 340         shown = ", ".join(subjects) if subjects else "(none)"
! 341         lines.append(f"Subject areas: {shown} → {selections.get('reference_sql_entries', 0)} reference-SQL summaries")
! 342     for key, label in (("subagents", "Subagents"), ("skills", "Skills"), ("metrics", "Metric datasources")):
! 343         values = selections.get(key)
! 344         if values is not None:
! 345             lines.append(f"{label}: {', '.join(values) if values else '(none)'}")
! 346     return lines
  347 
  348 
  349 def _report_result(console: Console, result: "PackageResult") -> int:
  350     for warning in result.warnings:
! 351         print_warning(console, warning)
  352     if not result.ok:
  353         print_status(console, "Package build failed.", ok=False)
  354         for finding in result.secret_findings:
  355             print_error(

Lines 369-381

  369     # Spell out what the selection actually produced — counting zip entries by
  370     # hand is easy to get wrong (``unzip -l`` wraps long/CJK names onto several
  371     # lines), so a filtered package can look unfiltered.
  372     for line in _selection_summary_lines(result.selections):
! 373         print_info(console, line)
  374     env_vars = sorted({binding.var for binding in result.env_vars})
  375     if env_vars:
! 376         print_info(console, "Receiver must export: " + ", ".join(env_vars))
! 377         print_info(console, "The generated README.md lists where each variable is used.")
  378     return 0
  379 
  380 
  381 __all__ = ["run_package_command"]

datus/storage/reference_sql/reference_sql_init.py

Lines 477-487

  477 
  478     Returns:
  479         Dict with the same shape as :func:`init_reference_sql`
  480     """
! 481     directory = Path(summaries_dir) if summaries_dir else Path(global_config.path_manager.sql_summary_path())
! 482     if not directory.is_dir():
! 483         return {
  484             "status": "success",
  485             "message": f"reference_sql summaries directory not found: {directory}",
  486             "valid_entries": 0,
  487             "processed_entries": 0,

Lines 487-511

  487             "processed_entries": 0,
  488             "total_stored_entries": storage.get_reference_sql_size(),
  489         }
  490 
! 491     items: List[Dict[str, Any]] = []
! 492     invalid: List[str] = []
! 493     for path in sorted(directory.rglob("*.y*ml")):
! 494         if not path.is_file():
! 495             continue
! 496         try:
! 497             with open(path, "r", encoding="utf-8") as fh:
! 498                 doc = yaml.safe_load(fh)
! 499         except (OSError, yaml.YAMLError) as exc:
! 500             invalid.append(f"{path.name}: unreadable ({exc})")
! 501             continue
! 502         if not isinstance(doc, dict):
! 503             invalid.append(f"{path.name}: not a YAML mapping")
! 504             continue
  505 
! 506         subject_path = [part.strip() for part in str(doc.get("subject_tree") or "").split("/") if part.strip()]
! 507         item = {
  508             "id": doc.get("id"),
  509             "name": doc.get("name"),
  510             "sql": doc.get("sql"),
  511             "comment": doc.get("comment", ""),

Lines 514-536

  514             "subject_path": subject_path,
  515             "tags": doc.get("tags", ""),
  516             "filepath": doc.get("filepath") or str(path),
  517         }
! 518         missing = [key for key in ("name", "sql", "summary", "search_text") if not item.get(key)]
! 519         if missing or not subject_path:
! 520             invalid.append(f"{path.name}: missing {', '.join(missing + ([] if subject_path else ['subject_tree']))}")
! 521             continue
! 522         items.append(item)
  523 
! 524     if build_mode == "overwrite":
! 525         storage.truncate()
! 526         storage.store_batch(items)
  527     else:
! 528         storage.upsert_batch(items)
! 529     storage.after_init()
  530 
! 531     logger.info("Reindexed %d reference SQL entries from %s (no LLM)", len(items), directory)
! 532     return {
  533         "status": "success",
  534         "message": f"reference_sql reindexed from summaries ({build_mode} mode)",
  535         "valid_entries": len(items),
  536         "processed_entries": len(items),

datus/storage/subject_tree/store.py

Lines 583-591

  583             return _node_to_dict(rows[0])
  584 
  585         target = normalize_subject_node_name(name)
  586         if not target:
! 587             return None
  588         siblings = self._table.query(
  589             SubjectNodeRecord,
  590             where={"parent_id": db_parent_id, "datasource_id": self.datasource_id},
  591         )

Test Failure Details

17867/19853 tests passed, 1986 skipped


Generated by pytest-cov + diff-cover

Found during the baisheng end-to-end run: bootstrap-kb defaults to the
'check' strategy, which only reports counts — the generated script
ingested nothing on the receiver. The script now uses 'overwrite' for
the first semantic_model call only (it truncates the whole
project-scoped store, safe exactly once on a fresh unzip) and
'incremental' for every subsequent call so multi-file/multi-datasource
selections don't wipe each other.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@Louis-Law
Louis-Law force-pushed the feat/package-to-server branch from 594ffee to 4d9b584 Compare August 9, 2026 08:11
Standards axis: PackageError now derives from DatusException with a
dedicated PACKAGE_BUILD_ERROR code (mirrors plugins.store.StoreError);
non-ASCII section markers in comments replaced; missing type hints
filled in (wizard signatures, allocator/plugin sets). Duplicated
selection resolution extracted into _resolve_selection, the
report/dashboard kind switch collapsed into one _ARTIFACT_KIND_DIRS
map, and placeholder harvesting moved onto _PlaceholderAllocator so
nothing reaches into its private state.

Spec axis: the final secret scan now covers Fernet tokens (gAAAAA...)
as the design review requires; editable installs are surfaced as a
warning even under --yes instead of silently vanishing when the
confirmation is skipped; the module docstring states the scan's
binary-sniff/read-cap limits honestly; and package_manifest.json now
carries per-file provenance (generated vs project).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (4)
datus/cli/package_cli.py (1)

182-194: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

_prompt_patterns reports only the first failing pattern name reliably.

The try wraps the whole loop. pattern in the except handler holds the last bound value, which is the failing one. The behavior is correct. Consider moving the try inside the loop for clarity.

♻️ Proposed refactor
-        patterns = [part.strip() for part in answer.split(",") if part.strip()]
-        try:
-            for pattern in patterns:
-                re.compile(pattern)
-        except re.error as exc:
-            print_warning(console, f"Invalid regex {pattern!r}: {exc}")
-            continue
-        return patterns
+        patterns = [part.strip() for part in answer.split(",") if part.strip()]
+        invalid = False
+        for pattern in patterns:
+            try:
+                re.compile(pattern)
+            except re.error as exc:
+                print_warning(console, f"Invalid regex {pattern!r}: {exc}")
+                invalid = True
+                break
+        if invalid:
+            continue
+        return patterns
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/cli/package_cli.py` around lines 182 - 194, Refactor _prompt_patterns
so each pattern is compiled inside its own try/except block, with the
corresponding pattern directly available when reporting Invalid regex warnings.
Preserve the current retry behavior and return values for valid and empty input.
tests/unit_tests/cli/test_package_builder.py (2)

562-567: 🔒 Security & Privacy | 🔵 Trivial | 💤 Low value

extractall on an untrusted archive is unsafe; here the archive is self-produced.

Static analysis flags zf.extractall(extract) as Zip Slip. The archive comes from build_package in the same test, and Line 541 already asserts that no member path contains .., a leading /, or a backslash. The current call is safe. Add a short comment so the pattern is not copied into code that reads external archives.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/cli/test_package_builder.py` around lines 562 - 567, Add a
concise comment immediately before the zf.extractall(extract) call explaining
that the archive is produced by build_package and its member paths were already
validated against traversal, absolute, and backslash paths. Leave the extraction
logic unchanged.

Source: Linters/SAST tools


238-245: 🩺 Stability & Availability | 🔵 Trivial | 💤 Low value

symlink_to needs privileges on Windows.

Path.symlink_to raises OSError on Windows without Developer Mode or the create-symlink privilege. If CI runs Windows, guard this test.

🛡️ Proposed fix
+    import os
+
     def test_symlink_escaping_root_dropped(self, project, tmp_path):
+        if os.name == "nt":
+            pytest.skip("symlink creation requires elevated privileges on Windows")
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/cli/test_package_builder.py` around lines 238 - 245, Guard
test_symlink_escaping_root_dropped so it is skipped on Windows when symlink
creation is unavailable, while preserving the existing assertions on supported
platforms. Use the test’s existing platform-detection conventions or pytest’s
skip mechanism around the symlink setup.
datus/cli/package_builder.py (1)

941-953: 🎯 Functional Correctness | 🔵 Trivial | ⚡ Quick win

Iterate secret_fields in sorted order.

secret_fields is a set. The iteration order varies between runs. _PlaceholderAllocator.allocate assigns collision suffixes (_2, _3) in allocation order, so two schema fields that sanitize to the same variable name can receive swapped names across builds. Sorting makes the generated conf/agent.yml and the README table stable.

♻️ Proposed fix
-                for dotted in secret_fields:
+                for dotted in sorted(secret_fields):
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@datus/cli/package_builder.py` around lines 941 - 953, Update the loop in the
secret-field rewriting flow to iterate over secret_fields in sorted order before
calling _rewrite_secret_value, ensuring _PlaceholderAllocator allocation and
generated outputs remain deterministic.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit_tests/cli/test_package_builder.py`:
- Around line 83-95: Update the project fixture to set USERPROFILE to fake_home
alongside HOME before changing directories, ensuring Path.home() and ~ expansion
resolve within the isolated test profile on Windows.

---

Nitpick comments:
In `@datus/cli/package_builder.py`:
- Around line 941-953: Update the loop in the secret-field rewriting flow to
iterate over secret_fields in sorted order before calling _rewrite_secret_value,
ensuring _PlaceholderAllocator allocation and generated outputs remain
deterministic.

In `@datus/cli/package_cli.py`:
- Around line 182-194: Refactor _prompt_patterns so each pattern is compiled
inside its own try/except block, with the corresponding pattern directly
available when reporting Invalid regex warnings. Preserve the current retry
behavior and return values for valid and empty input.

In `@tests/unit_tests/cli/test_package_builder.py`:
- Around line 562-567: Add a concise comment immediately before the
zf.extractall(extract) call explaining that the archive is produced by
build_package and its member paths were already validated against traversal,
absolute, and backslash paths. Leave the extraction logic unchanged.
- Around line 238-245: Guard test_symlink_escaping_root_dropped so it is skipped
on Windows when symlink creation is unavailable, while preserving the existing
assertions on supported platforms. Use the test’s existing platform-detection
conventions or pytest’s skip mechanism around the symlink setup.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 58e4716b-141d-4b63-890c-701618d2e264

📥 Commits

Reviewing files that changed from the base of the PR and between 594ffee and e733a90.

📒 Files selected for processing (4)
  • datus/cli/package_builder.py
  • datus/cli/package_cli.py
  • datus/utils/exceptions.py
  • tests/unit_tests/cli/test_package_builder.py

Comment thread tests/unit_tests/cli/test_package_builder.py
- Guard the plugin_config_schema call itself, not just its import: a
  broken plugin manifest now degrades to the all-string-leaves fallback
  with a warning instead of escaping build_package's never-raise contract.
- Surface secret-scan truncation: scan_for_secrets returns warnings and
  flags any file larger than the read cap, so a partial scan can no
  longer read as a full one.
- Leave passwordless URIs (scheme://user@host/db) untouched — only URIs
  that actually carry a password component are rewritten, so receivers
  keep host/port/database instead of exporting a whole URI under a
  *_URI_PASSWORD variable.
- Validate dist assets exist before rewriting report index.html to
  relative _assets/ URLs; missing files keep the CDN html and warn.
- Use one timestamp for all zip members; fix the stale
  _SECRET_PATH_TABLE docstring reference; set USERPROFILE alongside HOME
  in the test fixture for Windows isolation.

Already addressed in the previous commit: English-only docstrings and
wizard type annotations. The Pydantic artifact_blob suggestion is
pre-existing tolerant-by-design wire handling owned by the injecting
host and is left as a follow-up.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (1)
tests/unit_tests/cli/test_package_builder.py (1)

577-584: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add concrete type hints for the local test values.

Annotate boom with its parameter type and Never return type. Change warnings: list to warnings: list[str].

As per coding guidelines, “Use Python 3.12+ type hints throughout.”

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/unit_tests/cli/test_package_builder.py` around lines 577 - 584, Update
the local test values in the _sanitize_plugin_profiles setup: annotate boom’s
_name parameter with its concrete string type and its return type with Never,
and change warnings from an unparameterized list to list[str].

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/unit_tests/cli/test_package_builder.py`:
- Around line 304-309: Update
test_unparseable_uri_with_password_component_fully_replaced to assert that
container["uri"] exactly equals "${DATUS_DS_X_URI_PASSWORD}" after
_rewrite_uri_password, replacing the weaker substring-only assertion while
preserving the existing malformed URI setup.
- Around line 425-441: Update test_report_dist_missing_asset_keeps_cdn_html to
save the original index.html content before calling _build, then assert the
packaged HTML exactly equals that original content when a required dist asset is
missing. Retain the existing archive and warning assertions, and ensure the test
verifies the complete CDN HTML rather than only checking that some CDN reference
remains.

---

Nitpick comments:
In `@tests/unit_tests/cli/test_package_builder.py`:
- Around line 577-584: Update the local test values in the
_sanitize_plugin_profiles setup: annotate boom’s _name parameter with its
concrete string type and its return type with Never, and change warnings from an
unparameterized list to list[str].
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 1d1c4997-43b6-430e-9911-66e90f7320be

📥 Commits

Reviewing files that changed from the base of the PR and between e733a90 and 5f95191.

📒 Files selected for processing (2)
  • datus/cli/package_builder.py
  • tests/unit_tests/cli/test_package_builder.py
🚧 Files skipped from review as they are similar to previous changes (1)
  • datus/cli/package_builder.py

Comment thread tests/unit_tests/cli/test_package_builder.py
Comment thread tests/unit_tests/cli/test_package_builder.py
Louis-Law and others added 15 commits August 10, 2026 09:31
Self-test feedback on the package command:

- Editable/source installs no longer gate the build behind a
  confirmation — they are pinned as-is with a warning in both
  interactive and --yes runs. The now-unused confirm_cb hook is
  removed from PackageOptions.
- A vim swap file open during packaging (conf/.agent.yml.swp) was
  collected by the walk and vanished before zip write, failing the
  whole build with FileNotFoundError. Editor swap/backup junk
  (*.swp/*.swo/*.swx, *~, .DS_Store) is now excluded up front, and any
  disk-backed entry that disappears between collection and finalize is
  dropped with a warning instead of aborting the build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AppleDouble xattr sidecars (._*) — which macOS writes next to real files
on SMB/FAT volumes — were not excluded anywhere, and the component
selectors rglob their own subtrees without the generic walk's pruning:
._orders.yml would be staged as a semantic-model file and even written
into rebuild_kb.sh for the receiver to bootstrap against. A shared
_is_junk_path predicate now guards the walk and all three selectors
(skills, metrics, artifacts), and the any-depth dir exclusions gain
__MACOSX plus external-volume metadata dirs (.Spotlight-V100, .Trashes,
.fseventsd, .TemporaryItems, .DocumentRevisions-V100).

Deliberately not handled: xattrs/resource forks are dropped by design
(quarantine flags and Finder metadata must not travel), and receiver-
side quarantine does not affect `bash scripts/*.sh` invocations.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Under home: . the REPL command history lands at ./history in the
project root — user activity, potentially sensitive queries — and the
walk shipped it (confirmed leaked in a real self-test package). A new
top-level-only file exclusion drops it; a nested project file named
"history" (e.g. docs/history) still ships.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Self-test found both components unselectable.

Reference SQL: candidate corpora are discovered by content (top-level
dirs holding *.sql) and get a wizard step plus a reference_sql
bootstrap-kb line in rebuild_kb.sh, running against the project's
default datasource (project pin -> `default: true` -> single-datasource
shortcut; an ambiguous setup emits a commented manual command instead).
Selection drives the KB rebuild only — every .sql directory still ships
via the generic walk, so deselecting can never drop a user's files —
and the default is the conventionally-named subset: a real baisheng run
showed an init/ directory of DDL being discovered, and bootstrapping
that as reference SQL would poison the store. The generated script also
warns that the rebuild re-runs one LLM summary per statement rather
than reusing the shipped subject/sql_summaries/.

Plugins: candidates are the union of the project's activation list and
the managed store, so a project that never wrote a `plugins:` key can
now ship install lines at all. The wizard picks which ones;
generate_install_plugins_script takes the selection and reports what it
kept for the manifest.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Reference SQL now behaves exactly like metrics rather than being a
special case:

- Canonical path. Only reference_sql/ (or reference_sqls/ ref_sql/) is
  treated as reference SQL, the same property that lets the metric
  selector trust subject/semantic_models/. Content-sniffing arbitrary
  *.sql directories is gone — a migrations/ or init/ tree of DDL ships
  as ordinary project content and is never bootstrapped.
- Per-datasource units. reference_sql/<datasource>/ mirrors
  subject/semantic_models/<datasource>/: one selectable unit per
  datasource, and the rebuild line takes --datasource from the
  directory name. A flat reference_sql/*.sql layout stays supported as
  a single unit bound to the project's default datasource; when that
  cannot be resolved the corpus still ships and the rebuild is emitted
  as a commented manual command.
- Selection gates packaging, like every other selector: selected
  corpora ship whole, unselected ones do not.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…om summaries

The KB indexes metrics and reference SQL under one subject tree per
datasource, held in the vector store — so that tree, not a directory
layout, is the natural unit for choosing what a package carries.

Agent side: new `bootstrap-kb --components reference_sql
--from_summaries [--summaries_dir DIR]` re-indexes the committed
subject/sql_summaries/*.yaml verbatim. Those files already carry sql,
summary, search_text, subject_tree and tags, so re-deriving them through
the LLM costs time and credits and drifts from the reviewed originals;
this path is the restore route for a fresh checkout, a machine
migration, or an unzipped package (81 statements: ~40 min of LLM before,
seconds now). The overwrite branch no longer wipes the summaries
directory under --from_summaries — that directory is the input.

Package side: one "Subject areas" wizard step, sourced from the vector
store's subject tree (falling back to the artifacts when the KB was
never built on this machine) and labelled with what each root actually
costs. The selection gates metric documents (matched on their
`subject_tree:` tag) and reference-SQL summaries (matched on their
subject_tree field); semantic-model documents are table definitions and
always travel with their datasource, and the raw .sql corpus ships as
ordinary project content. rebuild_kb.sh now ends with the
--from_summaries call instead of an LLM re-derivation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Subject paths are written by an LLM one entry at a time and node lookup
matched names exactly, so a single area accumulated siblings that only
differed in case or separators — each splitting the same content into
its own subtree and its own line in the package selector. Node lookup
now falls back to a normalized comparison (case-folded, separators and
surrounding punctuation ignored) and reuses the existing node; the
stored name is unchanged. Genuinely different wording still stays
distinct — collapsing 营销 into 营销分析 needs a curated tree via
bootstrap-kb --subject_tree.

Also: a reference-SQL summary with no subject_tree matched no selection
and silently disappeared from every package. It now ships regardless,
with a warning naming the file.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A filtered package can look unfiltered: counting zip entries by hand is
error-prone because `unzip -l` wraps long or CJK filenames onto several
lines, so a run that packaged one summary out of 77 reads as if nothing
was filtered. The build result now carries the resolved selections and
the CLI prints them, e.g.

  Subject areas: activity → 1 reference-SQL summaries
  Subagents: baisheng_metadata, baisheng_metrics, baisheng_ref_sql

The counts come from the same numbers written into
package_manifest.json, so what is reported is what shipped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Metric documents record their subject as a `subject_tree:` tag, but the
tag is optional — hand-written or older metric YAML has none. Those
files matched no subject root, so any package with a subject selection
dropped them silently, and with them the metrics rebuild line: a real
run selecting two subjects produced a rebuild_kb.sh with no
`--components metrics` step at all.

Untagged metric files now travel regardless of the selection (same rule
already applied to untagged reference-SQL summaries) and the build
reports a warning naming the file, so the missing tag is visible rather
than silently costing the receiver its metrics.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Ctrl+C could not cancel the wizard, and on the final prompt it did the
opposite of what the user asked: the shared prompts swallow the
interrupt and return a plausible answer — select_multi_choice returns []
(identical to "deselect everything") and select_choice returns its
default, so Ctrl+C on "Build the package now?" (default yes) started the
build.

The shared helpers gain an opt-in `cancellable=True` that turns Ctrl+C /
EOF into a KeyboardInterrupt instead; the default stays exactly as it
was, so existing callers and their tests are untouched. The wizard opts
in everywhere and exits 130 (the shell convention for SIGINT) without
writing anything. An interrupt during zip assembly also removes the
truncated archive — a half-written package that looks complete is worse
than none.

Verified against a real terminal: Ctrl+C on the Subagents screen exits
130 with "Aborted — nothing was written." and leaves an existing
baisheng.zip byte-identical.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Setting up a received package meant reading the README and running three
things in the right order. `scripts/init.sh` is that order, generated
with only the steps a given package actually needs: dependencies, then
plugins, then the knowledge base. The per-step scripts stay because they
are worth running on their own later (re-index after editing subject
YAML, reinstall a plugin).

Everything in it is re-runnable: plugin install lines now pass --force,
so a second run replaces the installed plugin instead of failing, and
the KB steps already overwrite.

Two things the first real run surfaced. Dependency install targets an
explicit interpreter — uv first (uv-created virtualenvs have no pip
module at all), then `python -m pip`, with a clear error when neither
exists; a bare `pip` had resolved to a different Python than the venv
running datus. And the closing echo is single-quoted: inside a
double-quoted echo the backticks around `datus` were command
substitution, which would have launched the REPL at the end of setup.

Verified end to end: unzip, `bash scripts/init.sh` → deps installed,
airflow plugin installed, KB rebuilt (10 semantic objects, 3 metrics,
77 reference SQL), exit 0.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The subject menu listed only tree roots, so the smallest selectable unit
was far larger than users wanted: baisheng has 61 distinct subject paths
under 22 roots, and picking one root pulled in everything beneath it.

The menu is now the subject tree rendered two levels deep — roots plus
their direct children, with deeper paths folded into their depth-2
parent. Counts roll up, so a root still shows the full cost of taking
everything under it. Selecting either level matches by path prefix, so
"marketing" keeps its old meaning while "marketing/campaigns" narrows.

Verified on baisheng: root selection ships 24 reference-SQL summaries,
the depth-2 child ships 15.
The two-level subject menu rendered as a tree but still behaved like a
flat list: checking a parent left its children unchecked, and checking
every child left the parent unchecked. Both read as bugs against the
indentation the menu shows.

MultiSelectState now holds the checkbox state for select_multi_choice
and, when the caller passes a child -> parent map, enforces both
directions: toggling a parent applies the same state to its children,
and a parent is checked exactly when all of its children are. Pre-
selection is reconciled the same way, so the wizard's "everything
selected" default still comes up fully checked. A parent whose children
are only partly selected renders as [-] instead of looking empty.

Only the subject screen passes a hierarchy; every other multi-select
keeps flat behaviour.
Two test assertions were weak enough to pass on a wrong implementation:
the unparseable-URI case only checked that the password substring was
gone (a partial redaction would have passed), and the missing-dist case
only checked that some CDN reference survived (a half-rewritten page
that swapped the CSS but not the JS would have passed). Both now assert
the exact expected value.

Plugin secret fields are now rewritten in sorted order — allocation
order decides collision suffixes (_2, _3), so set iteration order could
rename variables between builds of the same project.

Also: compile each include/exclude regex in its own try block instead of
reading the loop variable after the handler; note why the test's blanket
extractall is safe on a self-produced archive; skip the symlink test on
Windows, where creating one needs Developer Mode; and type the local
helpers in the broken-plugin-schema test.
Adds cli/package_command.md (EN + zh) and wires it into the CLI nav in
both locales. Covers the wizard flow, the two-level subject tree and its
cascade, the produced package layout, what never ships, how credentials
become ${VAR} placeholders, and the receiver's one-command setup.

Content is checked against package_cli.py / package_builder.py; examples
come from a real package built from a project (env-var table, generated
agent.yml shape, rebuild_kb.sh output).
validate_coverage_map.py --strict failed the coverage job: every page in
the mkdocs nav must be claimed by a flow or excluded with a rationale,
and docs/cli/package_command.md was neither.

Adds the flow extension.project_package rather than an exclusion —
`datus package` is a user-facing surface with its own contracts (self-
contained home: ., zero-secret placeholders, selection-driven contents,
sources instead of binary indexes). PR and merge-queue layers point at
the existing unit suites; nightly and weekly are not applicable because
packaging touches no LLM, remote service, or network.
@Louis-Law
Louis-Law added this pull request to the merge queue Aug 14, 2026
Merged via the queue into Datus-ai:main with commit 94b4833 Aug 14, 2026
9 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant