Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -381,9 +381,16 @@ cfg restore --as-of <date> --dry-run -m "message"
cfg restore --tag <name> --dry-run -m "message"
cfg fsck
cfg whoami
cfg check-update [--snooze [DAYS]]
cfg ui
```

cfgit checks PyPI for a newer release on cheap read commands (throttled to once a day) and, for an
interactive terminal, prints a one-line nudge to stderr with what's new — it never upgrades for
you (run `pip install -U cfgit`) and never prints on `--json`/piped output. `cfg check-update`
forces a check; `cfg check-update --snooze 30` silences it for 30 days; `CFGIT_NO_UPDATE_CHECK=1`
disables it entirely.

Every command supports `--json` for scripts and agents. By default output is human-readable
when stdout is a terminal and JSON when piped or redirected; set `CFG_OUTPUT=json|human|auto`
to control this, and `--json` always forces JSON.
Expand Down Expand Up @@ -511,6 +518,7 @@ Tools include:
- `cfg_whoami`
- `cfg_init`
- `cfg_identity_hash`
- `cfg_check_update`

A portable skill lives at `skills/cfgit/SKILL.md`.
`cfg_impact` accepts the same `against` list/string as the CLI `--against` flag,
Expand Down
11 changes: 8 additions & 3 deletions skills/cfgit/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,9 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w
- Never use cross-project Mongo URIs for writes. Remote managed Mongo writes are forbidden unless the user explicitly grants a per-turn exception and asks for that exact target.
- Treat `changed_outside_cfgit` as the central state. Do not commit over it. Run `cfg diff`, explain what changed, then `cfg adopt` if the user wants to fold that live state into history.
- If `[branches] enabled = true`, draft risky changes on a non-main branch and open a cfgit PR. Branch commits and PR creation do not mutate runtime; `cfg pr merge` is the only branch command that does.
- Prefer `--json` for agent parsing.
- At the start of a cfgit session, call `cfg_check_update` (or `cfg check-update --json`). If `data.update_available` is true and `data.snoozed` is false, tell the user the new version, show `data.notes` (what's new), and offer to upgrade (`pip install -U cfgit`) or snooze — call `cfg_check_update` with `snooze_days=30` if they want to be reminded later. NEVER upgrade for them. It is best-effort and silent when there is nothing to say; skip if `data.disabled` (they set `CFGIT_NO_UPDATE_CHECK`).
- Prefer `--json` for agent parsing. Output is otherwise human-on-a-TTY / JSON-when-piped; `CFG_OUTPUT=json|human|auto` overrides. To avoid repeating flags, `CFG_ENV`, `CFG_CONFIG`, and `CFG_AUTHOR` set session defaults, and cfgit discovers `.cfg.toml` by walking up from the cwd.
- Run `cfg doctor --status` to confirm where you are pointed (config, env, target db, identity mode + whether verified, reachability, tracked/drift counts) before mutating an unfamiliar env. It warns if a `needs_approval` env is still in `open` identity mode — writes there succeed UNAUDITED; surface that to the user.
- Use deterministic `cfg impact` first. Add `--llm` only when the user asks for LLM narration and the impact plugin is installed. Use `--against <collection:id>` when the user wants narration scoped to specific related records instead of the whole system.
- Before the FIRST `cfg import` against a new database or `.cfg.toml`, run `cfg doctor` (read-only). It reports every secret-deny match, oversized field, and key issue at once, with paste-ready `secret_fields`/`ignore_fields` snippets. Fix `.cfg.toml` from its output, then import. This avoids import failing one secret at a time.
- In `authenticated` or `enforced` identity mode, the process must already have a verified identity source, usually `CFGIT_IDENTITY_TOKEN` or a per-user DB principal. `--author` and MCP `author` are hints only; cfgit rejects them if they do not match the verified identity.
Expand Down Expand Up @@ -53,7 +55,7 @@ Use cfgit as the safety layer around a live datastore. The app still reads and w
- Every outcome now carries a top-level `next` block (why + remedy + copy-paste `commands`);
on a refusal, follow `next.commands` rather than guessing.
- For a coupled multi-record change, write a batch JSON file and run `cfg commit --bulk-from <file> -m "<message>" --json`. The batch file can be `[{"record":"collection:id","doc":{...}}]` or `{"collection:id": {...}}`. Preview a collection-scale batch first with `cfg commit --bulk-from <file> --dry-run --json` — it reports each record's `would_commit`/`noop`/`changed_outside_cfgit` and writes nothing.
- Before a risky bulk change, snapshot the current live docs with `cfg export --out backup.json --json` (a portable, re-importable artifact stamped with head seq/oid). To roll back, `cfg import --from backup.json -m "<message>" --json` writes them back through the drift-guarded path (preview with `--dry-run`). For an in-tool rollback with no file, use `cfg tag` + `cfg restore --tag` instead.
- Before a risky bulk change, snapshot the current live docs with `cfg export --out backup.json --json` (a portable, re-importable artifact stamped with head seq/oid). If export prints a size warning, the config may be pointed at a data-plane collection (events/content/jobs) cfgit is not built to version — flag it. To roll back, `cfg import --from backup.json -m "<message>" --json` writes them back through the drift-guarded path (preview with `--dry-run`). If it returns `partial` with `cancelled: true` (the user interrupted), already-applied records are durable and rerunning the same command resumes. For an in-tool rollback with no file, use `cfg tag` + `cfg restore --tag` instead.
- For a draft branch, use `cfg --branch <branch> commit <record> --from <file> -m "<message>" --json` or `cfg --branch <branch> commit --bulk-from <file> -m "<message>" --json`. This writes cfgit refs only; runtime is unchanged.
- If commit returns `changed_outside_cfgit`, stop and inspect drift.
- If bulk commit returns `blocked`, no record was applied; inspect `failed`. If it returns `partial`, some records were applied before a race/failure; inspect `results`, `failed`, and `pending` before continuing.
Expand Down Expand Up @@ -87,7 +89,9 @@ If the cfgit MCP server is available, prefer its tools over shelling out:
- `cfg_impact`
- `cfg_commit`
- `cfg_bulk_commit`
- `cfg_set` for a few scalar field edits (routes through the drift-guarded commit path)
- `cfg_export`
- `cfg_import` (pass `from_export` to restore a snapshot; `dry_run` to preview)
- `cfg_branch_list`
- `cfg_branch_create`
- `cfg_branch_delete`
Expand All @@ -108,7 +112,8 @@ If the cfgit MCP server is available, prefer its tools over shelling out:
- `cfg_whoami`
- `cfg_init`
- `cfg_identity_hash` for setup only. Prefer the local CLI for real tokens because MCP clients may log tool inputs.
- `cfg_check_update` — check PyPI for a newer cfgit; pass `snooze_days` to snooze. Never upgrades.

Every MCP tool returns the same envelope shape as the CLI exit status: `status`, `code`, `message`, `data`.
Every MCP tool returns the same envelope: `status`, `code`, `message`, `data`, plus top-level `state` and `next`. `state` echoes the outcome's terminal state (e.g. `changed_outside_cfgit`, `noop`, `blocked`, `would_commit`); `next` is a self-teaching remedy `{why, remedy, commands}` when the outcome needs one, else null. Branch on `state` and follow `next.commands` on a refusal instead of guessing.
For MCP bulk commits, pass `items` as structured JSON (`[{record, doc}]`) when the client supports it; use a JSON string only when the client cannot send nested objects cleanly.
For MCP history/env mismatches, branch on `status="error"` and `code=6`; surface the `message` to the user and do not continue mutation.
33 changes: 33 additions & 0 deletions src/cfg/cli/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,12 +68,21 @@ def main(argv: list[str] | None = None) -> int:
except ValueError as exc:
_emit_error("error", str(exc), args)
return EXIT_ARG
if args.cmd == "check-update":
from cfg import update

if args.snooze is not None:
_emit(update.snooze(args.snooze), json_mode=args.json)
return EXIT_OK
_emit(update.check(force=True).to_json(), json_mode=args.json)
return EXIT_OK
try:
project = load_config(args.config_file)
engine = _engine(project, args.env, author=args.author)
_warn_open_mode(engine, args)
result, code = _dispatch(engine, args)
_emit(result, json_mode=args.json, record=getattr(args, "record", None))
_maybe_update_nudge(args)
return code
except AmbiguousConfig as exc:
_emit_error("bad_config", str(exc), args, exc=exc)
Expand Down Expand Up @@ -245,6 +254,10 @@ def _parser() -> argparse.ArgumentParser:
p_identity_hash.add_argument("token", nargs="?")
p_identity_hash.add_argument("--stdin", action="store_true")

p_update = sub.add_parser("check-update", help="check PyPI for a newer cfgit (never upgrades for you)")
p_update.add_argument("--snooze", nargs="?", type=int, const=30, default=None,
metavar="DAYS", help="silence the update nudge for DAYS (default 30)")

p_ui = sub.add_parser("ui")
p_ui.add_argument("--host", default="127.0.0.1")
p_ui.add_argument("--port", type=int, default=8765)
Expand Down Expand Up @@ -640,6 +653,26 @@ def _load_json_any(path: str) -> Any:

_MUTATING_CMDS = {"commit", "set", "edit", "adopt", "import", "restore"}

# Cheap, read-only commands safe to piggyback a throttled update check onto — never a write path,
# so a version check never sits in front of a prod mutation.
_READ_COMMANDS = {"status", "doctor", "whoami", "log"}


def _maybe_update_nudge(args: argparse.Namespace) -> None:
"""Deterministic npm/brew-style nudge: after a cheap read command, if a newer cfgit is on
PyPI (throttled daily, snooze-aware, kill-switchable), print one line to stderr — but only
for an interactive human (TTY, not --json). Never touches stdout; never raises."""
if args.cmd not in _READ_COMMANDS or getattr(args, "json", False) or not sys.stdout.isatty():
return
try:
from cfg import update

status = update.check()
if status.message:
print(status.message, file=sys.stderr)
except Exception: # noqa: BLE001 - a nudge must never break the command
pass


def _warn_open_mode(engine: Engine, args: argparse.Namespace) -> None:
"""Loud stderr warning when a mutating command runs against a needs_approval env still in
Expand Down
16 changes: 16 additions & 0 deletions src/cfg/mcp/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -502,6 +502,22 @@ def cfg_identity_hash(token: str) -> dict[str, Any]:
}


@mcp.tool()
def cfg_check_update(snooze_days: int | None = None) -> dict[str, Any]:
"""Check PyPI for a newer cfgit release. Call this at the start of a cfgit session; if
`data.update_available` is true and not `data.snoozed`, tell the user the new version and
offer to upgrade (`pip install -U cfgit`) or to snooze. NEVER upgrade for them.

Pass `snooze_days` (e.g. 30) to record a "don't ask again for N days" snooze when the user
asks to be reminded later. Best-effort and fail-silent; honors CFGIT_NO_UPDATE_CHECK.
"""
from cfg import update

if snooze_days is not None:
return {"status": "ok", "code": actions.EXIT_OK, "message": "", "data": update.snooze(snooze_days)}
return {"status": "ok", "code": actions.EXIT_OK, "message": "", "data": update.check(force=True).to_json()}


def _call(
name: str,
payload: dict[str, Any],
Expand Down
Loading
Loading