Datus Plugin Development Skill
Goal
Turn documentation the user hands you (an SDK reference, a REST/OpenAPI spec, a
CLI man page, a README, a set of function signatures, …) into an installable
Datus plugin — a Python package discovered through the datus.plugins
entry-point group that adds a datus <name> command plus optional bundled
skills, prompt awareness, bash permissions, and tool transformers.
The contract is declarative. A plugin ships a single manifest file —
datus-plugin.yml inside the importable package — that names its CLI entry
function, its permissions, its skills directory, its system-prompt template,
and its config schema. The only Python you write are plain functions. There is
no plugin class, no base class, and no datus import anywhere.
This skill is design-first. It NEVER writes plugin code on the first pass. It reads the provided documentation, produces a design draft the user can review, and then stops and waits for explicit confirmation. Only after the user approves (or amends) the draft does it implement the package.
This skill is fully self-contained: the complete plugin contract is inlined in the Contract Reference appendix below. Do not depend on external docs or on reading Datus source code — everything needed to design and build a plugin is here.
Non-negotiable ground rules
Every design and every line of generated code must honor these (details in the appendix):
- A plugin never imports
datus.*and never depends ondatus(or any shared SDK) inpyproject.toml. Datus is the config broker; the plugin only ships a manifest plus plain functions that Datus calls. - One distribution ships exactly one plugin. Each
pyproject.tomldeclares a singledatus.pluginsentry point — never bundle several plugins into one distribution. Code shared across plugins goes into a separate library distribution (it registers no entry point) that the plugins depend on. - The entry-point value is the package name —
<name> = "datus_plugin_<name>", a pure name → package mapping. A legacypkg.module:Classvalue is rejected bydatus plugin installanddatus plugin pack. datus-plugin.ymllives at the package root (inside the importable package, next to__init__.py) and must ship in the wheel. Onlymanifest_version: 1is required; every other key is optional.- The CLI entry is a function —
main(argv: list[str], profile: dict) -> int | None, named by the manifest'sclikey.argvis everything afterdatus <name>with Datus' own--profile/--configalready stripped;profileis the resolved,${VAR}-expandedagent.plugins.<name>.<profile>dict. Return an exit code;Nonemeans0; do not callsys.exit()on the success path. - Reserved names. The entry-point name may not be
upgrade,skill, orplugin, and may not start with-. It doubles as both thedatus <name>command and theagent.plugins.<name>config key, so pick one clean identifier. - Never surface secrets. Secret config fields are marked
x-secret: truein the manifest'sconfig_schema; Datus strips them (and every field NOT declared in the schema) before the system-prompt template renders, recursing into declared nested objects — so declare every non-secret field the template needs. Secrets in config are always${ENV_VAR}references, never literals.
If the requested command name is reserved / collides / starts with -, flag it
in the draft and propose an alternative.
Input
$ARGUMENTS— path or URL to the source documentation, plus (ideally) the desired command name. If the docs location or the command name is missing, ask for it before drafting.- The user may instead paste the docs directly into the conversation.
Phase A — DESIGN DRAFT (always first; then STOP)
Step A1: Ingest the source documentation
Read the user-provided docs. Extract:
- Capabilities — the concrete operations the plugin could expose (API endpoints, SDK methods, CLI verbs). For each, note inputs, outputs, and whether it is read-only or state-changing. Inventory them ALL first — when the source exposes a large surface (an OpenAPI spec, dozens of resource groups), enumerate every group/endpoint before deciding anything; never silently skip parts of the API.
- Authentication & connection — base URL / host, auth scheme (bearer token,
API key, basic, OAuth), region, workspace/tenant, timeouts. These become
profile fields in the
config_schema. - Dependencies — which third-party libraries the wrapper needs (
requests,httpx, the vendor SDK,typer, …). Neverdatus.
Relevance filter — Datus is a data-analysis / SQL / warehouse / ETL / metrics agent. These plugins exist to serve that agent, not to mirror a vendor's whole API. After inventorying every capability, tier each by how well it serves data-analysis workflows and pick a preliminary scope that leads with the high-relevance operations. The discriminating axis is analysis-relevant vs. management/config, NOT read-vs-write:
- Keep analysis-relevant writes — authoring metric/query SQL, triggering an
ingestion/ETL run, managing a warehouse connection — gated as
ask. Do not let a "read-only is safe" bias quietly shrink the scope; include the authoring/ETL writes the agent genuinely needs. - Drop analysis-irrelevant capabilities — feature-flag toggles, role/permission admin, project settings — even when they offer tidy CRUD.
You never finalize the scope alone. The draft must present, side by side, the selection rationale for what you kept AND the full list of excluded capabilities with reasons, so the user makes the final call (Step A2 §5).
If the docs are ambiguous or incomplete, collect the open questions — do not guess silently.
Step A2: Produce the draft
Render the draft in the conversation using this exact structure. Keep it concrete — every CLI command must trace back to a specific part of the source docs.
## Datus Plugin Design Draft: <name>
- CLI command / config key: `datus <name>` · `agent.plugins.<name>`
- Purpose: <one line>
- Source docs: <what was provided>
- Scope: <K of N inventoried capabilities selected — rationale + exclusions in §5>
- CLI routing style: <dict dispatch | argparse | REST wrapper | Typer>
- Third-party dependencies: <libs, none = pure stdlib> (never datus)
### 1. Configuration parameters (config_schema)
| Field | Type | Required | Secret (`x-secret`) | Default | Source doc ref | Notes |
|-------|------|----------|---------------------|---------|----------------|-------|
| api_base_url | string | yes | no | — | <spec §> | endpoint root |
| token | string | yes | YES -> `${VAR}` | — | <auth §> | bearer token |
| ... | | | | | | |
- This table becomes the manifest's inline JSON Schema (`config_schema`).
- Secret fields are configured as `${ENV_VAR}` references, expanded by Datus.
- A `name` key is injected by Datus (= profile name); do not declare it.
- Every NON-secret field the system-prompt template will reference MUST appear
here — undeclared fields are stripped before rendering.
- A group of related options (e.g. an `s3` credentials block) is declared as a
nested `type: object` property with its own `properties`; use the dotted
path as the table's field name (`s3.secret_access_key`) and mark the secret
**leaves** `x-secret`, not the whole block, so the non-secret leaves stay
editable in the `/plugins` TUI.
### 2. CLI command list
For each subcommand:
#### `datus <name> <subcommand> [args...]`
- Function: <what it does, in one or two lines>
- Analysis value: <the data-analysis / SQL / warehouse / ETL / metrics workflow
this serves — if you cannot state one, it probably belongs in §5 exclusions>
- Doc ref: <SDK method / endpoint / section it maps to>
- Permission:
- normal: allow | ask | deny — <pattern, e.g. `list-pets:*`>
- auto: allow | ask | deny — <pattern>
- rationale: <read-only => allow; state-changing => ask under normal>
(Repeat per subcommand. Group read-only vs. state-changing so the permission
posture is obvious.)
This command list is written twice into the manifest: as `permissions` patterns
(the posture) and as the `commands` catalogue (the descriptive tree that powers
`!<plugin>` completion — see [Declaring the CLI command catalogue](#declaring-the-cli-command-catalogue)).
Command groups become nested `subcommands`; note each command's key arguments so
the catalogue can hint them.
Permission patterns are namespace-relative — Datus prefixes `datus <name> `
automatically. Syntax: `cmd` exact, `cmd:*` prefix, `cmd:glob` prefix + first-arg
glob, `:*` whole namespace. Only `normal` and `auto` profiles are accepted.
### 3. Bundled skills & prompt template
- Main skill `<name>` — description + when the agent should invoke it.
- Setup skill `<name>-setup` — the profile fields it collects from the user,
and that it writes `${VAR}` references for secrets (never literals).
- Each skill is ONE `SKILL.md` (no `references/`, no `assets/`): list the
templates/scripts to inline in it, and split into another skill rather than
another file when the content does not fit.
- system_prompt template — the NON-SECRET fields to surface per environment
(e.g. base URL, region, env name); all of them must be declared in §1.
Plan the `{% else %}` installed-but-unconfigured branch pointing at
`<name>-setup`.
### 4. Tool transformers (only if applicable)
- If the plugin should rewrite/deny the agent's tool calls (e.g. SQL scoping),
list the tool patterns and the transform/deny rule. Otherwise: "none".
### 5. Scope selection & excluded capabilities
For any non-trivial API, the reader must be able to audit — and overturn — your
scoping. Show both halves:
- **Selection rationale** — why the §2 commands were kept: which data-analysis /
SQL / warehouse / ETL / metrics workflows they serve. Tie back to the
relevance filter (Step A1).
- **Excluded / deferred capabilities** — EVERY capability inventoried in Step A1
but left out. Group by resource, one-line reason each (analysis-irrelevant /
redundant / low priority / needs clarification). Never silently drop parts of
a large API — the user decides the final scope from this list, and may pull
any excluded item back in.
| Capability / resource group | In scope? | Reason |
|-----------------------------|-----------|--------|
| metrics — read + warehouse-native SQL authoring | yes | core metric analysis |
| ingestions — trigger / backfill | yes | ETL the agent drives (`ask`) |
| experiments — read results / pulse | yes | analysis output |
| feature gates / segments / holdouts | no — deferred | feature-flag mgmt, not data analysis |
| roles / users / project settings | no — deferred | access & project admin, not analysis |
| ... (list every remaining group) | | |
End with an explicit prompt: "Confirm this scope, or tell me which excluded
capabilities to include."
### 6. End-to-end testability
- Deterministic user goal: <one bounded task the plugin must complete through `datus -p`>.
- Environment fixtures: <minikube / MinIO / Flink Operator / other pinned services, or none>.
- Independent oracle: <exact external state, schema, count, checksum, or resource fields to verify without calling the tested plugin>.
- Generated artifacts: <workspace-relative output patterns to preserve>.
- Support plugins and minimum permission prefixes: <only what the workflow needs>.
- Efficiency signals: <expected command sequence plus tool-call / turn / token / unexpected-failure budgets>.
- Feasibility: <runnable now | reference workflow pending fixture/oracle>, including prerequisites.
This section becomes the input to `$build-datus-plugin-e2e` after implementation. The E2E oracle, not the LLM's narrative, owns the correctness verdict.
### 7. Open questions
- <anything the docs did not resolve: auth details, pagination, rate limits,
command-name conflicts, or scope calls you are unsure about …>
Step A3: STOP and wait for confirmation
After presenting the draft:
- Explicitly ask the user to confirm, amend, or reject the draft, to confirm the scope or pull any excluded capability (§5) back in, and to answer any open questions.
- Do not create any files, do not write any code, do not scaffold the package. End the turn here.
- Proceed to Phase B only after the user gives explicit approval. If the user requests changes, revise the draft and stop again.
Phase B — IMPLEMENTATION (only after explicit confirmation)
Use the recipes and semantics from the Contract Reference appendix. Implement in this order:
- Package layout —
datus-plugin-<name>/withpyproject.tomland adatus_plugin_<name>/package (__init__.py,datus-plugin.yml,cli.py,prompts/,skills/). See Package layout & entry point. - Entry point — register under
[project.entry-points."datus.plugins"]as<name> = "datus_plugin_<name>"(the package name — no:Class). Dependencies list the plugin's own libs, neverdatus. - The manifest — write
datus_plugin_<name>/datus-plugin.ymlfrom the approved draft:manifest_version: 1,description,cli,permissions(§2 table),system_prompt,skills,config_schema(§1 table), andcommands(the §2 command list as a nested catalogue — see Declaring the CLI command catalogue). See The manifest. - The CLI function —
main(argv, profile)using the routing style from the draft (see CLI recipes). Read endpoint/credentials fromprofile; map subcommands to operations; return conventional exit codes (0ok,1runtime,2usage,3config,8missing optional dep). - The prompt template —
prompts/system.md.j2with a{% if profiles %}configured branch and an{% else %}branch pointing at<name>-setup(System-prompt template). - Bundled skills —
skills/<name>/SKILL.mdandskills/<name>-setup/SKILL.md, each one self-contained file: noreferences/orassets/siblings, every template inlined as a fenced block (see Bundling skills and Bundling a setup skill). - Packaging — ensure the manifest, template, and skill files ship in the
wheel (Hatchling packages them by default; with setuptools add
[tool.setuptools.package-data]). - Tests — call
main(argv, profile)with a plain dict (noagent.yml, no Datus imports); validate the manifest withyaml.safe_loadand render the template with plain Jinja2. See Testing your plugin. - E2E handoff — after unit and manifest-contract tests pass, invoke
$build-datus-plugin-e2ewhen end-to-end coverage is in scope. Implement the approved §6 deterministic goal through the repository's pytest harness; do not add a standalone test CLI and do not use LLM judgment as the oracle.
Verify against the constraints checklist
Before declaring done, confirm every item in Constraints checklist.
End-to-end verification
- CLI dispatch:
datus plugin install src:./datus-plugin-<name>(orpip install -einto datus' environment) thendatus <name> .... If it falls through to the REPL, the entry point is missing or misnamed; if it prints "declares no CLI command", the manifest loaded but has noclikey. - Skills: start
datus, run/skill list— bundled skills appear. - Prompt injection: render the template in a unit test; optionally start a session and ask the agent "which plugins are configured?".
Report
Summarize: the package created, entry-point name, manifest contents (config
schema, CLI commands and their permission posture, skills), and the checklist +
verification results. Note any open questions still outstanding. Include the
deterministic E2E target and either the $build-datus-plugin-e2e result or the
exact handoff still required.
Contract Reference (inlined — the single source of truth for this skill)
A plugin is an installable Python package discovered through the
datus.plugins entry-point group. The defining constraints: a plugin never
imports datus.*, and the whole contract is one declarative file —
datus-plugin.yml shipped inside the package. Datus is the config broker —
it reads agent.yml, expands ${VAR} references, resolves the active profile,
and calls the manifest's declared cli function with a plain dict.
Reading the manifest never executes plugin code: skills, permissions, prompt
sections, and config schemas are collected without importing the package. Only
the cli function (on datus <name> ... dispatch) and declared
tool_transformers are imported, lazily.
Package layout & entry point
datus-plugin-hello/
├── pyproject.toml
└── datus_plugin_hello/
├── __init__.py
├── datus-plugin.yml # the manifest — the whole plugin contract
├── cli.py
├── prompts/
│ └── system.md.j2
└── skills/
├── hello/SKILL.md
└── hello-setup/SKILL.md
Minimal CLI function (datus_plugin_hello/cli.py):
from __future__ import annotations
def main(argv: list[str], profile: dict) -> int:
# `profile` is the resolved agent.plugins.hello.<profile> dict
# (already ${VAR}-expanded by datus). Empty dict is fine.
greeting = profile.get("greeting", "Hello")
name = argv[0] if argv else "world"
print(f"{greeting}, {name}!")
return 0
Register the entry point (pyproject.toml):
[project]
name = "datus-plugin-hello"
version = "0.1.0"
requires-python = ">=3.12"
dependencies = [] # note: NOT datus
[project.entry-points."datus.plugins"]
hello = "datus_plugin_hello" # the PACKAGE name — not a class
[tool.setuptools.package-data]
datus_plugin_hello = ["datus-plugin.yml", "prompts/*", "skills/**/*"]
The entry-point value is the package name — a pure name → package mapping,
no code reference. The entry-point name (hello) alone determines the CLI
command (datus hello) and the config key (agent.plugins.hello) — the
package name is free. Three names are reserved and never dispatched to
plugins: upgrade, skill, and plugin (datus plugin install refuses
them), and names starting with - cannot be dispatched at all.
Declare exactly one datus.plugins entry point per pyproject.toml — one
distribution, one plugin. When several plugins share plumbing, extract it into
a separate library distribution (its own pyproject.toml, no entry point)
that each plugin lists in dependencies.
Plugins run inside datus' own interpreter (Python 3.12+), so code and dependencies must be compatible with it.
Install and run:
datus plugin install src:./datus-plugin-hello # installs into ~/.datus/plugins/hello/
datus hello Ada # -> Hello, Ada!
For a tight edit-run loop while developing, pip install -e datus-plugin-hello
into datus' own environment also works — such plugins are still discovered as a
fallback.
The manifest
datus-plugin.yml lives at the package root. Only manifest_version is
required; every other key is optional:
| Key | Type | Purpose |
|---|---|---|
manifest_version |
int, required | Must be 1. A newer version than datus understands rejects the manifest. |
description |
string | One-line summary shown by datus plugin info. |
cli |
code ref | module.path:function called as main(argv, profile) on datus <name> .... Without it, datus <name> exits 2. |
tool_transformers |
mapping | Tool pattern → code ref (or list of refs) that rewrite or deny the agent's tool calls. |
permissions |
mapping | Bash-permission rules for the plugin's own CLI namespace, per permission profile — pure YAML, no code. |
system_prompt |
path | Package-relative path of a Jinja2 template rendered into the agent's system prompt. |
skills |
path | Package-relative path of a bundled skill directory. Each skill inside it is exactly one SKILL.md — sibling references//assets/ files are never loaded (see "Bundling skills"). A <name>-setup skill must declare requires_mutable_config: true in its frontmatter (see "Bundling a setup skill"). |
config_schema |
JSON Schema | Inline object schema describing one profile — drives the /plugins TUI form and validates profiles before saving. |
commands |
list | Descriptive catalogue of the CLI commands (and nested subcommands / args) the cli function accepts — powers the REPL's !<plugin> completion and argument hints. Purely metadata; Datus never parses the plugin's args (see "Declaring the CLI command catalogue"). |
A code ref is a dotted module.path:function string. Paths are relative to
the package directory and may not escape it. Manifest parsing is defensive: a
malformed section is warned about and dropped while the rest of the manifest
stays usable; only a missing/unsupported manifest_version (or unreadable
YAML) rejects the manifest as a whole.
A complete example:
manifest_version: 1
description: "Query the Petstore API and manage pet inventory ETL."
cli: datus_plugin_petstore.cli:main
permissions:
normal:
allow: ["list-pets:*", "get-pet:*"]
ask: ["reindex:*"]
auto:
allow: ["list-pets:*", "get-pet:*", "reindex:*"]
system_prompt: prompts/system.md.j2
skills: skills
config_schema:
type: object
required: [api_base_url, token]
properties: # property order == /plugins TUI field order
api_base_url:
type: string
description: "API base URL"
default: "https://api.example.com/v1"
token:
type: string
description: "Bearer token"
x-secret: true # masked in the TUI, stripped from the prompt
timeout:
type: string
description: "Request timeout in seconds"
default: "30"
commands: # descriptive CLI catalogue (see below)
- name: list-pets
description: "List pets in the inventory"
args:
- {name: --status, description: "filter by status"}
- name: pets # a command GROUP with nested subcommands
description: "Pet operations"
subcommands:
- name: get
description: "Fetch one pet by id"
args:
- {name: id, required: true, description: "pet id"}
Configuration & profile resolution
Users configure the plugin under agent.plugins.<name>, where each key below
<name> is a profile (an environment):
agent:
plugins:
hello:
prod:
default: true
greeting: Hi
token: ${HELLO_TOKEN} # prefer ${ENV_VAR} for secrets
staging:
greeting: Yo
Datus parses this into agent.plugins.<name>.<profile> -> dict, expands
${VAR} per profile, and injects a name key equal to the profile name.
The active profile is resolved in this order:
- Explicit
--profile <p>on the command line. - Project pin in
./.datus/config.yml(plugins.<name>.active_profile). - The profile flagged
default: true(more than one is an error). - The sole profile, if only one is configured.
- No
agent.plugins.<name>section at all → the plugin runs with an empty{}configuration (config-free plugins still work). - Multiple profiles with no way to disambiguate → Datus errors, asking for
--profile.
Datus resolves the config file in this order: explicit --config →
./conf/agent.yml (project) → ~/.datus/conf/agent.yml (user default). Config
edits take effect on the next datus <plugin> invocation; no restart needed.
agent.plugins_enabled: false is a master switch turning off all plugin
functionality (dispatch, skills, prompt injection, permissions, transformers).
config_schema: form fields and validation
The inline JSON Schema (an object schema for one profile) drives the
/plugins TUI form and validates a candidate profile before Datus saves it:
x-secret: truemarks a secret field: the TUI masks it and prompts the user to enter a${ENV_VAR}reference, and the system-prompt renderer strips it. It is a property-level extension keyword — JSON Schema validators ignore it.requiredmembership marks a form field as required;defaultis pre-filled as the field's initial value, and a field left empty (no default, nothing typed) shows itsdescriptionas a dim placeholder — write descriptions as fill-in hints. Property insertion order == TUI field order.- Nested objects — a
type: objectproperty with its ownpropertiesexpands in the TUI into one field per leaf, named by its dotted path (s3.region,s3.secret_access_key); submitted values are re-assembled into the nested profile shape before saving.x-secret: trueon the object marks every leaf secret (prefer marking only the secret leaves so the rest stays TUI-editable); a leaf is form-required only when its whole ancestor path is required. Prompt stripping recurses into declared nested objects. AddadditionalProperties: falseto a nested block when the runtime rejects unknown keys, so the TUI catches typos the same way. - Validation runs
jsonschemaon the raw candidate dict (values the user just entered, before${VAR}expansion). Values containing${ENV_VAR}placeholders are treated as opaque — pattern/enum violations on them are suppressed, while a missingrequiredfield still fails. Keep the real runtime validation in theclifunction. - TUI-entered values are strings, so prefer
type: stringwithpattern/enumconstraints; reserve other types for hand-writtenagent.ymlprofiles.
The cli function: argv and exit codes
The manifest's cli names a function called as main(argv, profile):
datus hello --profile staging greet Ada
└── stripped ──┘ └── argv = ["greet", "Ada"] ──┘
Only --profile / --config appearing before the first non-option token
are consumed as Datus globals; from the first command token onward everything
belongs to the plugin. datus hello greet --profile staging therefore passes
["greet", "--profile", "staging"] through untouched.
Return an integer exit code (None means 0). Conventions:
| Code | Meaning |
|---|---|
0 |
success |
1 |
runtime error |
2 |
usage error |
3 |
config error |
8 |
missing optional dependency |
Raising is also fine — Datus catches exceptions from the cli function and
maps them to exit code 1 — but returning an explicit code gives users clearer
signals. Do not call sys.exit() on the success path.
CLI recipes
A. Dict dispatch — a few functions, zero dependencies
def main(argv, profile):
if not argv:
print("usage: datus toolbox <add|upper> ...")
return 2
cmd, rest = argv[0], argv[1:]
handlers = {"add": _add, "upper": _upper}
handler = handlers.get(cmd)
if handler is None:
print(f"unknown command: {cmd}")
return 2
return handler(rest)
def _add(args): # datus toolbox add 1 2 3
print(sum(float(a) for a in args))
return 0
def _upper(args): # datus toolbox upper hello
print(" ".join(args).upper())
return 0
B. argparse — typed args, flags, auto usage/-h
argparse prints usage and raises SystemExit on -h or a bad invocation;
Datus surfaces that as the exit code (0 for -h, 2 for usage errors).
import argparse
def main(argv, profile):
parser = argparse.ArgumentParser(prog="datus toolbox")
sub = parser.add_subparsers(dest="cmd", required=True)
p_add = sub.add_parser("add", help="sum numbers")
p_add.add_argument("nums", nargs="+", type=float)
p_grep = sub.add_parser("grep", help="filter lines in a file")
p_grep.add_argument("pattern")
p_grep.add_argument("path")
p_grep.add_argument("-i", "--ignore-case", action="store_true")
ns = parser.parse_args(argv) # SystemExit on -h / bad usage
if ns.cmd == "add":
print(sum(ns.nums))
return 0
if ns.cmd == "grep":
return _grep(ns.pattern, ns.path, ns.ignore_case)
def _grep(pattern, path, ignore_case):
needle = pattern.lower() if ignore_case else pattern
with open(path, encoding="utf-8") as fh:
for line in fh:
hay = line.lower() if ignore_case else line
if needle in hay:
print(line.rstrip())
return 0
C. Wrapping a REST API
Read endpoint and credentials from the profile (Datus already expanded
${VAR}), then map subcommands to requests. Keep credentials in the profile —
never hard-code them, and never echo them.
import argparse
import json
def main(argv, profile):
import requests # a plugin may depend on its own libraries
base = profile.get("api_base_url")
if not base:
print("no api_base_url configured for the profile")
return 3
headers = {}
if profile.get("token"):
headers["Authorization"] = f"Bearer {profile['token']}"
parser = argparse.ArgumentParser(prog="datus petstore")
sub = parser.add_subparsers(dest="cmd", required=True)
sub.add_parser("list-pets")
p_get = sub.add_parser("get-pet")
p_get.add_argument("id")
ns = parser.parse_args(argv)
base = base.rstrip("/")
if ns.cmd == "list-pets":
resp = requests.get(f"{base}/pets", headers=headers, timeout=30)
else:
resp = requests.get(f"{base}/pets/{ns.id}", headers=headers, timeout=30)
if resp.status_code >= 400:
print(f"error {resp.status_code}: {resp.text}")
return 1
print(json.dumps(resp.json(), indent=2))
return 0
Corresponding config:
agent:
plugins:
petstore:
prod:
default: true
api_base_url: https://api.example.com/v1
token: ${PETSTORE_TOKEN}
For a larger command surface, split the CLI into a package
(cli/__init__.py exposing main, one module per command group) — the code
ref stays datus_plugin_<name>.cli:main.
D. Typer / Click — richest UX, one extra dependency
Because Datus calls the entry function per-invocation but the Typer app is a module-level object, expose the active profile through a module global.
import typer
app = typer.Typer(add_completion=False)
_ACTIVE_PROFILE: dict = {}
@app.command("greet")
def greet(name: str, loud: bool = False):
greeting = _ACTIVE_PROFILE.get("greeting", "Hello")
msg = f"{greeting}, {name}!"
print(msg.upper() if loud else msg)
def main(argv, profile):
global _ACTIVE_PROFILE
_ACTIVE_PROFILE = profile
try:
# standalone_mode=False stops Click from calling sys.exit itself,
# so we can return a code and always clear the profile.
app(args=argv, standalone_mode=False)
return 0
except SystemExit as exc: # -h / usage
return int(exc.code or 0)
except typer.Exit as exc:
return exc.exit_code
finally:
_ACTIVE_PROFILE = {}
Add typer to the package's own dependencies (a plugin's deps are its own —
just not datus).
Bundling skills
Declare a package-relative skills directory in the manifest — no code involved:
skills: skills
Layout — one directory per skill, and exactly one SKILL.md inside it:
datus_plugin_hello/
├── datus-plugin.yml
└── skills/
├── hello/
│ └── SKILL.md
└── hello-setup/
└── SKILL.md
A skill is one file. Datus discovers and loads the SKILL.md only. A skill
directory has no support for references/, assets/, scripts/, or any other
sibling file: such files are not loaded, not resolved by relative markdown
links, and not copied anywhere the agent can reach at skill-load time. So the
progressive-disclosure pattern from Claude Code skills (a lean SKILL.md that
says "read references/x.md when relevant") does not work here.
Consequences for writing a plugin skill:
- Inline everything the skill hands to a project — config templates, YAML
manifests, Dockerfiles, SQL, small scripts — as fenced code blocks. Give each
one a
### <filename>heading so the agent knows what to write where, and so tests can extract and validate the block. - Never link to a sibling file.
[reference](references/x.md)is a dead link. Link only to absolute URLs, or to headings inside the same file. - Budget the file. Everything loads at once, so prune "nice to know" material and keep the operational spine: preflight, decision rules, templates, verification, failure modes.
- Split by task, not by document. When one file grows unwieldy, ship a
second skill with its own trigger description (e.g.
<name>for daily use and<name>-setupfor configuration) rather than a second file inside one skill.
Datus discovers the skills at startup (they show up in /skill list). A
minimal SKILL.md is YAML frontmatter plus markdown instructions (the
frontmatter follows the agentskills.io spec used by the Skills system):
---
name: hello
description: Say hello to someone via the `datus hello` CLI
---
# Hello
Run `datus hello <name>` to greet someone. ...
Make sure the skill files are included in the wheel (they are data, not Python). Hatchling packages every file under the package directory by default. With setuptools you must opt in:
[tool.setuptools.package-data]
datus_plugin_hello = ["datus-plugin.yml", "prompts/*", "skills/**/*"]
Verify with unzip -l dist/*.whl | grep SKILL.md.
System-prompt template
A plugin tells the agent, up front, what it is and which environments are configured — so the model chooses it proactively instead of guessing. Declare a Jinja2 template in the manifest:
system_prompt: prompts/system.md.j2
## Hello
Say hello via `datus hello <name>`.
{% if profiles %}
Environments ({{ profiles | length }}):
{% for name, cfg in profiles.items() %}
- {{ name }}: {{ cfg.get("greeting", "?") }}
{% endfor %}
{% else %}
Installed but not configured.
{% if config_mutable %}
Run the `hello-setup` skill to configure an environment.
{% else %}
Configuration is managed by the deployment administrator — tell the user to
contact them.
{% endif %}
{% endif %}
The render context:
| Variable | Value |
|---|---|
plugin_name |
the entry-point name |
profiles |
dict[str, dict] — the plugin's profile mapping, narrowed to the profiles the project activated and secret-stripped (see below) |
config_path |
the loaded agent config file path, or None (always None when the config is read-only) |
config_mutable |
True when the agent may edit the config file; False in managed deployments (multi-tenant chat API / gateway). Requires a datus-agent version that supplies it — on older versions a template referencing it fails to render (strict mode) and the whole section is skipped, so referencing it raises the plugin's minimum datus-agent version |
An installed-but-unconfigured plugin renders with profiles == {} — use
{% if profiles %} and emit a short "installed, not configured" note in the
{% else %} branch instead of disappearing from the prompt. Inside that
branch check config_mutable: point at the bundled setup skill only when it
is True, otherwise defer to the administrator.
Datus prepends its own ## Plugins preamble naming the loaded config file, so
the template must not hard-code config paths. In read-only mode the preamble
switches to wording that forbids config-edit suggestions.
Secrets are stripped structurally. Profile values are ${VAR}-expanded
(real secrets) by prompt time, so Datus filters the profiles before the
template sees them: only fields declared in config_schema and NOT marked
x-secret: true pass through; undeclared fields are dropped too, and declared
nested objects are filtered recursively under the same rules. Without a
config_schema, templates receive profile names with empty dicts. Two
practical consequences:
- Declare in
config_schemaevery non-secret field the template references. - The template renders in strict mode (
StrictUndefined) withtrim_blocks/lstrip_blocks; referencing a stripped field fails the render (the section is skipped and a warning is logged — it can never leak). Usecfg.get("field")for optional fields.
Template errors (missing file, syntax error, undefined variable) are logged and the section is skipped — they never break prompt construction.
CLI bash permissions
When the agent (not a human) runs the CLI through its bash tool, the
command goes through Datus' permission layer. Without a declaration, every such
command prompts for confirmation. The manifest's permissions key declares,
per permission profile, which subcommands are safe to auto-run (allow), which
must be confirmed (ask), and which are blocked (deny) — pure YAML, no code:
permissions:
normal:
allow: ["greet:*"]
ask: ["config set:*"]
auto:
allow: ["greet:*", "config set:*"]
Semantics:
- Patterns are relative to the namespace. Datus prefixes each pattern with
datus <name>, sogreet:*becomesdatus hello greet:*. A plugin can never affect commands outsidedatus <name>. - Pattern syntax:
cmdexact match,cmd:*prefix match,cmd:globprefix match whose first argument must satisfy the glob (e.g.greet:A*). A bare:*covers the whole namespace. - Profile keys: only
normalandautoare accepted. Thedangerousprofile ignores all command-level bash rules by design; adangerouskey is warned about and dropped. - Users always win. A user
denyrule inagent.ymloverrides a pluginallow(deny > ask > allow, regardless of declaration order); plugin declarations can never change a profile's default posture. askrules can be relaxed per project. The confirmation prompt offers "allow (project)", which persists the exact matched pattern to the project's.datus/config.ymlbash_allowlist (exact match only; never widens;denyunaffected).- Scope: only the agent's bash tool is gated. A human typing
datus hello ...in a terminal is never affected. --profileis transparent to matching;--config <path>is not normalized and always falls back to confirmation.- Malformed declarations (wrong types, unknown keys, empty patterns) are logged and skipped — never fatal.
Declare read-only subcommands as allow and state-changing ones as ask under
normal; promote routine state changes to allow under auto only when
re-running them is harmless.
Declaring the CLI command catalogue
The manifest's commands key is a descriptive catalogue of the subcommands
your cli function accepts. Datus never uses it to parse the plugin's arguments
— the cli function still owns all of that. The catalogue exists so the
interactive REPL can help a power user drive the plugin directly with the
!<plugin> … escape hatch:
- Typing
!<plugin>opens a completion menu of the top-level commands; drilling into a command group (!airflow dags) lists that group's subcommands, one level at a time. - Once a command is settled, a dim
<required> [--optional]hint follows the input line, naming the arguments still to supply. datus plugin infoprints the same catalogue.
Shape. commands is a list; each entry has a name (the command token) plus
optional description, args, and nested subcommands (same shape — this is
how you model command groups like `dags trigg
…(truncated)