Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
d7312eb
docs: add round-4 architecture refactor design spec
Jun 29, 2026
a41976c
docs: add round-4 implementation plan (6 tasks)
Jun 29, 2026
8581114
refactor: move the MCP server + CLI into a ycli.mcp package
Jun 29, 2026
6a11d8a
feat!: remove the raw issues 'full' accessor and RawMapping
Jun 29, 2026
2f45701
feat: status package with native me + read-only status_get MCP tool
Jun 29, 2026
1b9bae0
refactor: type pagination strategies with PEP 695 generics
Jun 29, 2026
3bd6601
docs: add module docstrings to the four empty __init__.py files
Jun 29, 2026
939eeb1
build: render demo output from committed fixtures, not hand-typed text
Jun 29, 2026
2ff13f3
test: harden status_get wiki assertion + doc/typing nits
Jun 29, 2026
da35bc2
ci: drop the CI-bypass marker from the demo-GIF auto-commit
Jun 29, 2026
1e5b292
docs: regenerate demo GIF
actions-user Jun 29, 2026
b5cb72b
refactor: flatten API ref wrappers to scalars via BeforeValidator
Jun 29, 2026
2037145
refactor: simplify the pretty renderer to lay out flat models
Jun 29, 2026
7bddada
build: make the demo render pretty, realistic output
Jun 29, 2026
a2fd735
docs: regenerate demo GIF
actions-user Jun 29, 2026
9f4281b
test: cover render.py unknown-command path + tighten renderer list test
Jun 29, 2026
3a4dc6c
test: align changelog/wiki-comments model test names with the flat-fi…
Jun 29, 2026
32f0edb
fix(status): discriminate the auth me union to survive the MCP round-…
Jun 29, 2026
26d5421
docs(readme): match badge style to DeepWiki (flat, logos, semantic co…
Jun 29, 2026
56bec31
refactor(cli): split domain _args.py into _types.py + _utils.py
Jun 29, 2026
4c8a4cc
refactor(cli): group cli/context/output into the ycli.cli package
Jun 29, 2026
9052318
refactor(cli): drop the lazy __getattr__ shim; reference ycli.cli.app…
Jun 29, 2026
52a5e9a
refactor: drop underscore prefixes from internal yandex modules
Jun 29, 2026
ef5e4f9
docs: regenerate demo GIF
actions-user Jun 29, 2026
eeb89e2
style: collapse the test_settings monkeypatch line (ruff format)
Jun 29, 2026
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
---
date: 2026-06-29
status: APPLIED
disposition: applied
applied_date: 2026-06-29
applied_in: src/ycli/yandex/status/models.py, src/ycli/yandex/status/reporter.py, docs/conventions/resources.md (§5)
priority: MEDIUM
trigger: 8
session_context: round-4 follow-up — status_get me-union mis-hydration found in final review
affected_source:
- docs/conventions/resources.md
- src/ycli/yandex/status/models.py
---

## What diverged

The `status_get` MCP tool returns an `AuthReport` whose per-service `me` field was typed as an
**undiscriminated** union — `TrackerMe | WikiMe | FormsMe | None`. Because all three `me`
models are fully optional and ignore extra keys (`APIModel` is `extra="ignore"`), every payload
validates against every member of that union. That is harmless on the CLI/SDK path, where the
real model instance is carried through and serialized directly.

It is **not** harmless across MCP. fastmcp rebuilds `CallToolResult.data` from the tool's output
JSON schema, and for an undiscriminated `anyOf` it picks the *first* branch that validates. A
wiki payload (`{"username": ...}`) was therefore reconstructed into the **tracker** shape
(`{uid, login, display, email}`) and its fields — including `username` — were silently dropped.
The on-the-wire `structured_content` stayed correct, so the data loss only showed up when a
Python consumer read `result.data`. The repo had no rule against undiscriminated unions in MCP
tool outputs, and `docs/conventions/resources.md` did not mention the constraint, so the next
heterogeneous MCP result would have hit the same trap.

## Why it seemed better

A bare union reads naturally and needed no extra scaffolding: each service's probe just stored
its native `me` model, and `extra="ignore"` made every payload "just validate". The ambiguity
is invisible in unit tests that assert against the freshly-built Python object or against
`structured_content` — only the fastmcp `result.data` reconstruction exposes it, which is an
easy seam to overlook.

## Proposed change

1. Type heterogeneous MCP-tool-output unions as **discriminated** unions: give each member a
`Literal` tag field and annotate the union with `Field(discriminator=...)`. For `AuthReport`,
split `ServiceAuthStatus` into `TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus` keyed on
a `Literal` `service` tag. This makes the output schema self-describing and loss-free across
the fastmcp round-trip.
2. Add a short convention to `docs/conventions/resources.md` (MCP section):

```
### Heterogeneous MCP output unions must be discriminated

fastmcp rebuilds `result.data` from the tool's output JSON schema and picks the first
matching branch of an undiscriminated `anyOf` — silently reshaping one member into another
and dropping fields. Any union returned by an MCP tool must carry a `Literal` discriminator
tag (`Field(discriminator="...")`). The CLI/SDK path is unaffected, but MCP consumers are not.
```

## Resolution

`ServiceAuthStatus` was split into `TrackerAuthStatus | WikiAuthStatus | FormsAuthStatus`,
each carrying a `Literal` `service` tag and its own typed `me`, combined as
`Annotated[…, Field(discriminator="service")]` in `src/ycli/yandex/status/models.py`. The
shared fields live on a `_ServiceAuthStatus` base; the subclasses override `service`/`me`
so field order is preserved. `StatusReporter._probe` now routes each probe through a
`TypeAdapter(ServiceAuthStatus)` (the discriminator is the single source of truth for
service→model), so no separate name→class map is needed. A fastmcp round-trip test in
`tests/yandex/status/test_mcp.py` now asserts `wiki.me.username` survives on `result.data`
(previously it had to read `structured_content` because the undiscriminated union dropped it).
The convention is documented in `docs/conventions/resources.md` §5 and listed in the
enforcement table. The CLI/SDK path is unchanged — it still carries the bare native `me` model.
32 changes: 32 additions & 0 deletions .claude/drift-log/applied/INDEX-2026-06.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
# Applied drift-log entries — 2026-06

Theme-grouped summary of entries resolved this month. Original entry files remain in place
alongside this index as the immutable record.

## Documentation drift (codifying conventions that lived only in memory / live research)

- **2026-06-29 — [full-self-documenting-names-not-in-claude-md](full-self-documenting-names-not-in-claude-md.md)**
(`applied`). The "spell identifiers and env-var names out in full — never abbreviate" rule lived
only in personal agent memory. Added a **Naming** bullet to `CLAUDE.md` → "Project-Specific
Conventions" so a fresh agent/contributor sees it. *Codified in:* `CLAUDE.md`.

- **2026-06-29 — [mcp-client-di-cache-factory-pattern](mcp-client-di-cache-factory-pattern.md)**
(`applied`). The way one client is shared across a mounted MCP domain's tools was discovered by
live research and undocumented. Documented the **as-shipped** pattern — `make_cached_client`
(a `functools.cache`d zero-arg provider in `ycli.yandex._mcp`) consumed via `Depends(...)`, with
the fastmcp `mount()` lifespan rationale — rather than the raw `@functools.cache def` in the
proposal. *Codified in:* `docs/conventions/resources.md` §3, `ARCHITECTURE.md`.

- **2026-06-29 — [docs-drift-from-purged-idioms](docs-drift-from-purged-idioms.md)**
(`applied`, HIGH). Purged idioms (`from_env`) and stale invariant counts kept surviving in prose
after the code moved on. Added **ARCH-11 — no purged idioms in docs** and a grep test. *Codified
in:* `ARCHITECTURE.md`, `tests/test_architecture.py::test_arch11_no_purged_idioms_in_live_docs`.

## MCP output typing

- **2026-06-29 — [mcp-output-unions-must-be-discriminated](2026-06-29-mcp-output-unions-must-be-discriminated.md)**
(`applied`). `status_get` returned an *undiscriminated* `me` union; fastmcp's `result.data`
reconstruction picked the first branch and dropped fields (wiki `username`). Split into
discriminated `Literal`-tagged subclasses (`Field(discriminator="service")`), routed via a
`TypeAdapter`; added a round-trip regression test and a convention. *Codified in:*
`src/ycli/yandex/status/{models,reporter}.py`, `docs/conventions/resources.md` §5.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
---
date: 2026-06-29
status: OPEN
status: APPLIED
disposition: applied
applied_date: 2026-06-29
applied_in: CLAUDE.md
priority: MEDIUM
trigger: 3
session_context: round-2 internals-cleanup / round-3 arch-tooling refactor sessions
Expand Down Expand Up @@ -41,3 +44,9 @@ Add one line to the "Project-Specific Conventions" section of `CLAUDE.md`:
(`timeout_seconds` not `timeout_s`, `organization_id` not `org_id`,
`YANDEX_ID_OAUTH_TOKEN` is already correct).
```

## Resolution

Added the **Naming** bullet to the "Project-Specific Conventions" section of `CLAUDE.md`
(verbatim from the proposed change). The convention is now visible to any agent or human
contributor reading `CLAUDE.md` alone, not only to sessions with the personal memory injected.
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
---
date: 2026-06-29
status: OPEN
status: APPLIED
disposition: applied
applied_date: 2026-06-29
applied_in: docs/conventions/resources.md (§3), ARCHITECTURE.md
priority: MEDIUM
trigger: 4
session_context: round-3 arch-tooling refactor — MCP server wiring research
Expand Down Expand Up @@ -51,3 +54,13 @@ To share one client instance across tools in a mounted domain server, define a
MCP tool functions receive the client via `Depends(tracker_client)`. This is the
only approved pattern; `import_server` is deprecated and must not be used.
```

## Resolution

Documented in `docs/conventions/resources.md` §3 ("Why `<domain>_client` is a cached provider")
and in the `ARCHITECTURE.md` layout prose (the `_mcp.py` line). The codified docs reflect the
pattern as it actually shipped: each `_deps` calls `make_cached_client(ClientCls)` from
`ycli.yandex._mcp` — a helper that wraps a `functools.cache`d zero-arg factory and reads
`Credentials()` from the env once — rather than the raw per-module `@functools.cache def`
sketched in the proposal above. The rationale (fastmcp `mount()` not propagating lifespan
context; `import_server` deprecated) and the `Depends(...)` consumption are unchanged.
11 changes: 8 additions & 3 deletions .github/workflows/demo.yml
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ jobs:
python-version: "3.12"
enable-cache: true
- name: Install project
run: uv sync --locked --dev
run: uv sync --locked --dev --extra mcp # mcp extra: the tape's `mcp methods` step

# Install VHS + ttyd ourselves (ffmpeg is preinstalled on ubuntu-latest).
# The charmbracelet/vhs-action bundles flaky font/ffmpeg downloaders that
Expand All @@ -53,8 +53,13 @@ jobs:
- name: Commit regenerated GIF
uses: stefanzweifel/git-auto-commit-action@v7.1.0
with:
# [skip ci]: the GIF-only commit shouldn't spin up CI or the release workflow.
commit_message: "docs: regenerate demo GIF [skip ci]"
# No CI-bypass marker in the message: recursion is already prevented
# structurally (this job triggers on docs/demo/** while the output lives in
# docs/assets/, so the GIF commit never re-renders), and the auto-commit
# rides the default GITHUB_TOKEN, whose pushes don't start new runs. A bypass
# marker here would instead ride a squash-merge into main and silently cancel
# the python-semantic-release run — the v0.8.0 release incident.
commit_message: "docs: regenerate demo GIF"
file_pattern: docs/assets/demo.gif
commit_user_name: vhs-action 📼
commit_user_email: actions@github.com
Expand Down
32 changes: 20 additions & 12 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,11 @@ and `tests/test_snapshots.py`. A failing build names the violated invariant.

```
src/ycli/
├── cli.py · mcp.py · output.py · log.py · context.py · settings.py # roots
├── cli/ · mcp/ · log.py · settings.py # roots (cli/ = app · context · output)
└── yandex/
├── base.py · transport.py · pagination.py · _mcp.py # shared
├── base.py · transport.py · pagination.py · mcp.py # shared (mcp.py = MCP helpers)
└── <domain>/ # tracker · wiki · forms
├── _base.py · _deps.py · _args.py · client.py · cli.py · mcp.py
├── base.py · dependencies.py · typedefs.py · utils.py · client.py · cli.py · mcp.py
└── <resource>/ # issues · pages · surveys · …
├── client.py # uplink SDK — the ONLY place HTTP happens
├── cli.py # Typer — output via Serializer.serialize
Expand All @@ -25,27 +25,35 @@ src/ycli/
Notable shared pieces:
- `src/ycli/settings.py` — `AppConfig` + `Credentials` pydantic-settings models (app-wide config)
- `src/ycli/yandex/models.py` — `APIModel` base (lenient parse config, no serialization logic)
- `src/ycli/context.py` — `AppContext` (typed composition root for the CLI)
- `src/ycli/cli/context.py` — `AppContext` (typed composition root for the CLI)
- `src/ycli/yandex/pagination.py` — `PaginationStrategy` ABC + concrete strategies
- `src/ycli/yandex/_mcp.py` — shared MCP annotation helpers (`RO`)
- `src/ycli/yandex/<domain>/_args.py` — deduplicated CLI argument/option type aliases
- `src/ycli/yandex/mcp.py` — shared MCP annotation helpers (`RO`) plus the `@cache`d client/config
providers (`make_cached_client`, `app_config`) that share one client across a mounted domain's tools
- `src/ycli/yandex/<domain>/typedefs.py` — deduplicated CLI argument/option type aliases;
`utils.py` — shared CLI helpers where a domain needs them (tracker: request-body builders,
`--field` JSON coercion)

## Invariants (ARCH-1..11)

- **ARCH-1 — Four-surface symmetry.** Every `yandex/<domain>/<resource>/` directory contains
`__init__.py`, `client.py`, `cli.py`, `mcp.py`, `models.py`. Use `/new-endpoint` to scaffold.
*Carve-out:* `yandex/status/` and the `ycli/mcp/` server package are cross-cutting surfaces,
not `<domain>/<resource>` dirs — the four-surface rule and the `_resource_dirs()` check
(which scans only `tracker/wiki/forms`) do not apply to them.
- **ARCH-2 — HTTP confinement.** `cli.py`, `mcp.py`, and `models.py` never import `requests` or
`uplink`. All HTTP lives in `client.py` / `base.py` / `transport.py`.
- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py`. Every MCP
- **ARCH-3 — MCP is read-only.** `fastmcp` is imported only in modules named `mcp.py` and in the
`ycli.mcp` server package (`src/ycli/mcp/server.py`; its `__init__.py` stays fastmcp-free so the
base install loads the CLI sub-app without the extra). Every MCP
tool's verb (last `_`-segment of its name) must be in a fail-closed read-verb **allow-list**
(`get/list/count/full/search/descendants/meta` — a new read adds its verb deliberately), it
(`get/list/count/search/descendants/meta` — a new read adds its verb deliberately), it
carries `readOnlyHint=True` (via the `RO` annotation), and no `mcp.py` may call a client write
method (`.create/.update/.add/.execute/…`).
- **ARCH-4 — Serialization confinement.** Model→output rendering happens only through
`output.Serializer.serialize(...)`; `model_dump_json`, `yaml.safe_dump`, and `json.dumps`
appear only in `src/ycli/output.py`. Models stay plain data (no serialize method); the
strategies live only in `output.py`. Unmodeled API dicts are wrapped in `RawMapping`
(a `RootModel[dict]` in `ycli.yandex.models`) before being passed to the Serializer.
appear only in `src/ycli/cli/output.py`. Models stay plain data (no serialize method); the
strategies live only in `output.py`. Every rendered value is a typed pydantic model — there
is no raw-dict/`RawMapping` escape hatch.
*Carve-out:* a bare `print(int)` for a scalar `count` result is fine — it is not model
output and needs no Serializer wrapping. *Check:* `model_dump_json` / `yaml.safe_dump` /
`json.dumps` only in `output.py`; CLI command bodies render via `Serializer.serialize`.
Expand Down Expand Up @@ -106,7 +114,7 @@ review cover the rest):
## Resource conventions (models, naming, MCP imports)

The conventions that ARCH-1..10 do not capture — `APIModel` inheritance, `XList`/`XResponse`
naming, the `_deps` import path, and the raw-accessor pattern — are documented in
naming and the `dependencies` import path — are documented in
[`docs/conventions/resources.md`](docs/conventions/resources.md).

## Changing an invariant
Expand Down
5 changes: 4 additions & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,11 +25,14 @@ Claude Code **plugin** under `plugins/yandex-360/`. Published on PyPI as `yandex

- **Dependencies:** add with `uv add <pkg>` (runtime) / `uv add --dev <pkg>` (dev) — never
hand-edit `pyproject.toml` dependency lists.
- **Naming:** spell identifiers and env-var names out in full — never abbreviate
(`timeout_seconds` not `timeout_s`, `organization_id` not `org_id`; `YANDEX_ID_OAUTH_TOKEN`
is already correct).
- **Tests:** `uv run pytest`. Async MCP tests rely on `asyncio_mode = "auto"`; HTTP is stubbed
with `responses` (no live network). Mark CLI/MCP wiring tests with `@pytest.mark.integration`.
- **Auth:** credentials (`YANDEX_ID_OAUTH_TOKEN` / `YANDEX_ID_ORGANIZATION_ID`) are read once
at the composition root — `Credentials()` / `AppConfig()` in `AppContext` for the CLI, or
the `_deps` cached factory in each domain's MCP module — and passed as raw `oauth_token` /
the `dependencies` cached factory in each domain's MCP module — and passed as raw `oauth_token` /
`organization_id` constructor arguments to each client. There is no `from_env` or
`session_from_env`; never hardcode credentials. Header casing differs per service
(Tracker `X-Org-ID`, Wiki/Forms `X-Org-Id`).
Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,11 @@
Drive **Tracker**, **Wiki**, and **Forms** from a CLI, an MCP server, a Python SDK,
or a Claude Code plugin. Built for AI agents first — pleasant for humans too.

[![CI](https://img.shields.io/github/actions/workflow/status/bim-ba/ycli/ci.yml?branch=main&style=flat-square&label=ci&color=555)](https://github.com/bim-ba/ycli/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/badge/coverage-100%25-555?style=flat-square)](https://github.com/bim-ba/ycli)
[![PyPI](https://img.shields.io/pypi/v/yandex-cli?style=flat-square&color=555&label=pypi)](https://pypi.org/project/yandex-cli/)
[![Python](https://img.shields.io/badge/python-3.12%2B-555?style=flat-square)](https://www.python.org/)
[![License](https://img.shields.io/badge/license-MIT-555?style=flat-square)](LICENSE)
[![CI](https://img.shields.io/github/actions/workflow/status/bim-ba/ycli/ci.yml?branch=main&logo=githubactions&logoColor=white&label=ci)](https://github.com/bim-ba/ycli/actions/workflows/ci.yml)
[![Coverage](https://img.shields.io/badge/coverage-100%25-brightgreen?logo=pytest&logoColor=white)](https://github.com/bim-ba/ycli)
[![PyPI](https://img.shields.io/pypi/v/yandex-cli?logo=pypi&logoColor=white&label=pypi)](https://pypi.org/project/yandex-cli/)
[![Python](https://img.shields.io/badge/python-3.12%2B-blue?logo=python&logoColor=white)](https://www.python.org/)
[![License](https://img.shields.io/badge/license-MIT-lightgrey?logo=opensourceinitiative&logoColor=white)](LICENSE)
[![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/bim-ba/ycli)

<img src="https://raw.githubusercontent.com/bim-ba/ycli/main/docs/assets/demo.gif" alt="ycli in action" width="760">
Expand Down
Binary file modified docs/assets/demo.gif
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading