diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json index 134cbe8..ea8723e 100644 --- a/.claude-plugin/plugin.json +++ b/.claude-plugin/plugin.json @@ -14,5 +14,5 @@ "license": "BSD-3-Clause", "name": "yas", "repository": "https://github.com/tmck-code/yet-another-statusline", - "version": "0.7.1" + "version": "0.8.0" } diff --git a/CONTEXT.md b/CONTEXT.md index 8d6f7d9..f196d1a 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -18,10 +18,10 @@ _Avoid_: "cache hits" in cost discussions (the per-token price, not the hit-coun Tokens generated by the assistant (`output_tokens`). Shown after the `↑` glyph. **Day Total**: -Sum of **Billed Input**, **Cache Read**, and **Output** across every session today. Persisted to `~/.claude/statusline-tokens.log`, keyed by `session_id` so re-running today's session rewrites its row rather than double-counting. Shown after the `/` in the tokens row. +Sum of **Billed Input**, **Cache Read**, and **Output** across every session today. Persisted to `~/.claude/yas/state/runtime/tokens.log`, keyed by `session_id` so re-running today's session rewrites its row rather than double-counting. Shown after the `/` in the tokens row. **Token-Rate Sparkline**: -The single-row mini-chart trailing the ` t/m` label in the wide tokens row. Drawn from the block elements ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, blank for zero): each cell's height and colour come from its bucket's ratio to the window peak, newest on the left (the live, in-flight cell — sitting next to the `t/m` label and dimmed) with older samples trailing off to the right. It reads the last `token_window` seconds of per-bucket throughput (default 60 s) from `~/.claude/statusline-token-rate.log`. +The single-row mini-chart trailing the ` t/m` label in the wide tokens row. Drawn from the block elements ` ▁▂▃▄▅▆▇█` (U+2581–U+2588, blank for zero): each cell's height and colour come from its bucket's ratio to the window peak, newest on the left (the live, in-flight cell — sitting next to the `t/m` label and dimmed) with older samples trailing off to the right. It reads the last `token_window` seconds of per-bucket throughput (default 60 s) from `~/.claude/yas/state/runtime/token-rate.log`. _Avoid_: "graph" (it is a one-row glyph strip, not a plotted axis), and the old half-block two-row form (removed — it used patchy "Symbols for Legacy Computing" glyphs). **Day Stats Toggle**: @@ -71,7 +71,7 @@ _Avoid_: "hue shift" (was the earlier term; **Shift** is canonical and matches t ### Theming **Theme**: -The complete set of colour decisions the statusline draws with — decorative slots (border, path, branch, helper, label), the compaction traffic-light ladder, the rainbow border gradient stops, the per-model **Anchor** + warm/cool **Shift** tables, and the two **Pill Foreground** slots. Lives as a `Theme` dataclass instance in `claude/yas/themes.py`. Selected at runtime via `--theme=name`, `CLAUDE_STATUSLINE_THEME`, or `~/.claude/statusline-theme` — in that priority order, falling back to `claude-dark`. See [docs/adr/0002-theme-system.md](docs/adr/0002-theme-system.md). +The complete set of colour decisions the statusline draws with — decorative slots (border, path, branch, helper, label), the compaction traffic-light ladder, the rainbow border gradient stops, the per-model **Anchor** + warm/cool **Shift** tables, and the two **Pill Foreground** slots. Lives as a `Theme` dataclass instance in `claude/yas/themes.py`. Selected at runtime via `--theme=name`, `CLAUDE_STATUSLINE_THEME`, or `[appearance].theme` in `yas.toml` — in that priority order, falling back to `claude-dark`. The legacy `~/.claude/statusline-theme` file is no longer read: the installer folds a non-empty value into `yas.toml` once, and the migration then removes the file. See [docs/adr/0002-theme-system.md](docs/adr/0002-theme-system.md). _Avoid_: "palette" as a synonym (a palette is a *source* — Catppuccin Latte, Solarized Light — and a **Theme** is the fully populated dataclass derived from one). **Pill Foreground**: @@ -105,7 +105,7 @@ _Avoid_: "nested subagents" as the feature name (the canonical term is **Subagen **Done state**: A subagent is Done when its transcript captures `end_ts` (an epoch > 0). The primary signal is an assistant line with `stop_reason == "end_turn"` (the epoch of that line). Some sidechain transcripts never emit `end_turn` — every assistant line is `tool_use` or null, including the final result message — so a **terminal-text fallback** also applies: when no `end_turn` is seen and the *last* assistant line is terminal text (a `text` block with no pending `tool_use`), its timestamp becomes `end_ts`. A still-running agent's last assistant line is a `tool_use` awaiting a result, so the fallback cannot fire mid-run. An assistant line *after* an `end_turn` resets `end_ts` to 0 — the agent was resumed (e.g. a warm agent handed a follow-up via SendMessage), so Done means the transcript *currently ends* in an ended turn; a resumed agent that finishes again goes Done at the later time via a new `end_turn` or the terminal-text fallback. Per-message token usage is read **last-line-wins** (keyed by message id): streamed usage counters grow across an id's writes and the final write carries the real totals, so a first-write dedup would undercount output. The activity snippet is message-scoped: block memory accumulates across a streamed message id's writes (its first write is usually the thinking block), so the agent's later `tool_use`/`text` blocks are observed with the usual tool_use > text > thinking priority. In the two-line form a Done agent carries **no marker glyph** — it renders with dimmed styling throughout (duration, type, description, all stats) and a *frozen* elapsed time (`end_ts − first_timestamp` rather than `now − first_timestamp`); a running agent keeps live colours and a ticking duration. In the one-line collapse a Done agent still renders with `✓` (`GLYPH_SUBAGENT_DONE`) in place of the running row's plain `·` marker, dimmed throughout. -**Cohort retirement**: Subagents are shown as a *cohort* scoped to the current user turn (bounded by the `UserPromptSubmit` hook writing `~/.claude/yas-last-prompt.json`). Membership rules: +**Cohort retirement**: Subagents are shown as a *cohort* scoped to the current user turn (bounded by the `UserPromptSubmit` hook writing `~/.claude/yas/state/signals/last-prompt.json`). Membership rules: - When the turn marker is available: include agents whose `first_timestamp ≥ last_prompt_ts`, plus any still-writing stragglers from the previous turn (transcript written within the liveness window). - When the turn marker is absent (hook not installed): fall back to a 60s recency window — include any agent written within 60 s, or still running (`end_ts == 0`). @@ -251,6 +251,31 @@ _Avoid_: "error message" (it is a per-knob *ignored-values* tally, not a single Opt-in, **wide-layout-only** superscript captions painted into the rainbow top border and the dim/solid separator rows directly above the value each names. Each label is anchored over the value's real column — `build_wide` measures the already-rendered content string (ANSI stripped, whitespace-token offsets) rather than using a fixed tuned-offset table, so a caption tracks its value exactly and is emitted only when that value is present. Coverage: `changes` over the git dirty block (`•N*M`); `session` over the session timer always plus `clear` over the clear timer only when it is displayed (clear content non-empty — otherwise `session` alone anchors over the single timer); for the 5h cell `5h` over its glyph plus, in full form, `remain` (countdown), `used` (used %), and `burn rate` (trend) — compact/reset form carries only `5h` + `used`; for the 7d cell `7d` plus `used` and `burn rate` (when a trend renders); `cache` over the cache countdown; on the context separator `context` (token count), `fill` (the context-window `(N%)`), `dumb` (the compaction-risk %); on the tokens separator `input sess/day`/`cache sess/day`/`output sess/day` over the token columns, `cost sess/day` over the cost, and `tokens over time` over the sparkline; `skills + plugins` over the skills row; and content-start captions over the dynamic section separators — `plan` (todo-checklist / task row), `agent` (subagent cohort), `workflow` (workflow cohort), `specs` (OpenSpec change bars), with `plan` + `agent` splitting the caption in the side-by-side checklist+subagents block. In **Subagent Tree View**'s stacked (non-side-by-side) layout, the `agent` caption is followed by five column labels over the tree-single row's own measured anchors: `name` (over the description column), `model` (over the model field), `tok` (over the tok field — the abbreviated form, not `tokens`, because the full word's own run collides with `LOC r/w`'s at typical cohort widths and would get dropped by the label overlay's fitting), `LOC r/w` (anchored so its own `/` stacks over the `/` in the field's ` / ` data, not over the field's start), and `log` (over the trailing activity snippet) — derived from the same `desc_col`/`stats_col`/`activity_col`/`subagent_cluster_field_offsets` anchors the rows themselves use, never a hardcoded offset. The session-level tokens/cost row's own lines-read/-changed segment caption reads `LOC read/write` (the same string abbreviates to `LOC r/w` via `LABEL_ABBREVIATIONS` under width pressure) — the two captions differ because the subagent-tree header is permanently the short form to stay under the narrower per-column data field, while the session-level segment has room for the full word. Default `false`; resolved through the **Precedence Chain** exactly like the wide-only `justify` knob. Each label glyph takes the **gradient** colour (and, on dim separators, the same per-column dim factor) of the column it occupies — never a flat colour. Labels overwrite only *fill* glyphs (`─`/`┄`); they yield to elbows (`┬┴┼`), the frame corners, the embedded session id, and any active **Model Pill** — truncating before, or dropping at, a non-fill column so no elbow, column, or content ever shifts. Narrow and medium layouts ignore the knob entirely. _Avoid_: "rows" (labels add no content rows — they overlay the existing border/separator frame) and "headers" (they caption individual values positionally, not whole sections as a heading row would). The value-label words map to canonical concepts: `remain` = the rate-limit reset countdown, `used` = the **Five-Hour Limit** / **Seven-Day Limit** usage percentage, `burn rate` = the **Burndown Trend**, `limit` = the **Context Window Size** `(N%)` headroom figure, and `until dumb` = the **Compaction-Risk Zone** percentage. +**On-Disk Layout**: +Everything YAS writes except `yas.toml` lives under a single `$CLAUDE_CONFIG_DIR/yas/` subtree: + +``` +$CLAUDE_CONFIG_DIR/ + yas.toml # user config — never moved, never removed by uninstall + yas/ + cache/ # regenerable; safe to `rm -rf` at any time + config.toml.cache + transcripts/.json + state/ + version.json # {schema_version, yas_version, migrated_at} + runtime/ # written and read by the renderer + tokens.log + token-rate.log + render.log + signals/ # written by external processes, read by yas + last-prompt.json # UserPromptSubmit hook + terminal-width # ops/alacritty.py helper + sessions/.json +``` + +`yas/cache/` is disposable by contract — every value in it is re-derivable, so deleting the whole tree costs only a re-parse, never data. Migration from the old flat `~/.claude/statusline-*` / `yas-*` files runs automatically (eagerly on re-install, lazily on the next render) and marks completion in `state/version.json`, after which the steady-state cost is a single `stat()`. +_Avoid_: "state dir" for the whole subtree (`yas/cache/` is disposable, `yas/state/` is not — the distinction is the point). + ### Derived session state **SessionView**: @@ -261,7 +286,7 @@ _Avoid_: "session model" (that is **SessionInfo**, the raw JSON parse; **Session The **Session Share %** denominator carried on the **SessionView**: `(Billed Input + Cache Read + Output) + Σ(Running Subagent total_input + output)`. The one genuinely *derived* SessionView field — it composes the **Transcript Usage** and **Running Subagent** readers — so it lives in exactly one place rather than being recomputed per caller. **Tick Record** (`record_tick`): -The per-render disk-write step, owned by `app` and kept off the **SessionView** so the view stays a pure read. It persists this tick's **Transcript Usage** to the **Day Total** ledger (`TokenLog.update`) and the rate log (`TokenRate.update`), then returns a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` threads the `TickRecord` into the wide layout builder, so **Day Total** and day-cost never masquerade as gathered facts. Sits beside the existing per-render payload write in `app.main` (the `statusline-output` index file) — the one place per-render side effects belong. +The per-render disk-write step, owned by `app` and kept off the **SessionView** so the view stays a pure read. It persists this tick's **Transcript Usage** to the **Day Total** ledger (`TokenLog.update`) and the rate log (`TokenRate.update`), then returns a `TickRecord` bundling `token_log`, `day_cost`, and the token rate. `app` threads the `TickRecord` into the wide layout builder, so **Day Total** and day-cost never masquerade as gathered facts. Sits beside the existing per-render payload write in `app.main` (the `yas/state/sessions/.json` payload) — the one place per-render side effects belong. _Avoid_: "gather the day total" (the **Day Total** is *written*, not gathered — it depends on this tick's persist, which is why it is a **Tick Record** value and not a **SessionView** field). ## Relationships diff --git a/README.md b/README.md index 050658c..1056a46 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,21 @@ curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/ma curl -fsSL https://raw.githubusercontent.com/tmck-code/yet-another-statusline/main/ops/install.sh | YAS_NO_TTY=1 YAS_PYTHON=3.15 bash ``` +### Upgrading — the `yas/` layout move + +Everything YAS writes except `yas.toml` now lives under a single +`$CLAUDE_CONFIG_DIR/yas/` subtree (`yas/cache/` and `yas/state/`). The migration +runs automatically — eagerly when you re-run `ops/install.sh`, lazily on the +first render otherwise — and is a one-off; afterwards the check costs one +`stat()`. + +Day-total token counts and the last-prompt handshake are **moved** and survive +the upgrade. Rate-limit history, render timings, the transcript parse cache and +`mon`'s per-session payloads are **regenerated rather than moved**, so expect a +brief cold start after the first upgrade: the `t/m` rate and sparkline, the +render-time figure and `mon`'s session list start out empty and refill within +one render tick to about five minutes, depending on the item. + ### Reconfigure later — `/yas:config` Run `/yas:config` any time to re-run the wizard against the already-installed @@ -120,7 +135,12 @@ aliases when both are set — the aliases keep working but are deprecated. - **`show_day_stats`** — when `true` (the default), shows today's cumulative token and cost totals alongside the session's, as `session/day` pairs. **Note:** this key lives under **`[tokens]`**, not `[layout]`, unlike the other display toggles. - **`openspec_scan_depth`** — how many repo-levels below `cwd` the OpenSpec downward scan descends looking for nested `openspec/` roots (monorepo-of-repos layout). `1` (the default) finds a repo directly below `cwd`; `2` also finds one nested a level deeper; `0` disables the downward scan entirely (only an `openspec/` found by walking *upward* from `cwd` is used). Unlike the other numeric knobs, `0` is a legal value here. - **CLI flags** — `--theme NAME` / `--bg-shift DIR` also accept the `--theme=NAME` / `--bg-shift=DIR` form. Pass them in the `statusLine.command` of your `~/.claude/settings.json`. -- **Legacy theme file** — `~/.claude/statusline-theme` (contents = a theme name) still works as the lowest-priority theme fallback, below `[appearance].theme`. +- **Legacy theme file** — `~/.claude/statusline-theme` is **no longer read**. If you re-run the installer while the file is non-empty and `yas.toml` does not already set a theme, the installer folds its value into `yas.toml` once, and the migration then deletes the file. Otherwise set the theme by hand: + + ```toml + [appearance] + theme = "claude-dark" + ``` ### Context state word @@ -218,7 +238,7 @@ model = [ | var | default | description | |-----|---------|-------------| -| `CLAUDE_CONFIG_DIR` | `~/.claude` | base dir for config/state files (`yas.toml`, theme file, width file, token-rate log, output payloads) | +| `CLAUDE_CONFIG_DIR` | `~/.claude` | base dir for `yas.toml` and the `yas/` state/cache subtree (logs, width file, session payloads) | | `YAS_DEBUG` | _(unset)_ | when set, prints detailed per-value config-rejection reasons to stderr | | `COLUMNS` | _(unset)_ | terminal-width fallback when tmux / width-file detection fail | @@ -227,7 +247,7 @@ model = [ Width is detected by the first source that returns a positive value: 1. `tmux display-message -p '#{pane_width}'` -2. `~/.claude/terminal-width` file +2. `~/.claude/yas/state/signals/terminal-width` file (written by `ops/alacritty.py`, which honours `CLAUDE_CONFIG_DIR`) 3. `COLUMNS` env var 4. `shutil.get_terminal_size()` / `/dev/tty` ioctl diff --git a/claude/mon/discovery.py b/claude/mon/discovery.py index 2284dc7..abf33d3 100644 --- a/claude/mon/discovery.py +++ b/claude/mon/discovery.py @@ -5,7 +5,7 @@ from datetime import datetime, timedelta from pathlib import Path -from yas.constants import CLAUDE_DIR +from yas.constants import projects_dir, sessions_dir @dataclass @@ -20,9 +20,12 @@ class ActiveSession: def find_active_jsonls( include_after: timedelta, now: datetime, - projects_root: Path = CLAUDE_DIR / 'projects', + projects_root: Path | None = None, ) -> list[tuple[Path, float]]: """Return (jsonl_path, mtime) pairs for .jsonl files whose mtime is within include_after of now.""" + if projects_root is None: + projects_root = projects_dir() + result: list[tuple[Path, float]] = [] now_ts = now.timestamp() cutoff = include_after.total_seconds() @@ -42,9 +45,12 @@ def find_active_jsonls( def index_payloads_by_session( - payloads_root: Path = CLAUDE_DIR / 'statusline-output', + payloads_root: Path | None = None, ) -> dict[str, tuple[Path, float, dict[str, object]]]: """Return most-recent payload file per session_id as (path, mtime, parsed_dict).""" + if payloads_root is None: + payloads_root = sessions_dir() + index: dict[str, tuple[Path, float, dict[str, object]]] = {} if not payloads_root.exists(): diff --git a/claude/yas/app.py b/claude/yas/app.py index 1c4d018..97913fa 100644 --- a/claude/yas/app.py +++ b/claude/yas/app.py @@ -6,7 +6,10 @@ from datetime import datetime from yas.config import Config -from yas.constants import CLAUDE_DIR, MIN_WIDTH, NARROW_WIDTH, MEDIUM_WIDTH, VERSION +from yas.constants import ( + MIN_WIDTH, NARROW_WIDTH, MEDIUM_WIDTH, VERSION, + config_path, session_payload_path, sessions_dir, version_file, +) from yas.info import SessionView from yas.layout import build_narrow, build_medium, build_wide, render_layout from yas.renderer import Renderer @@ -27,7 +30,7 @@ def record_tick(session: SessionInfo, usage: TranscriptUsage) -> TickRecord: def resolve_theme(cli_name: str | None) -> Theme: """Layered theme selection: CLI -> YAS_THEME -> CLAUDE_STATUSLINE_THEME - -> [appearance].theme -> statusline-theme file -> CLAUDE_DARK. + -> [appearance].theme -> CLAUDE_DARK. Resolves live (fresh Config.load) so callers see the current environment and CLAUDE_DIR; the import-time CONFIG singleton is for the module constants.""" @@ -78,24 +81,31 @@ def main(t0: float | None = None) -> None: # already UTF-8 (most Unix systems since Python 3.7). if hasattr(sys.stdout, 'reconfigure'): sys.stdout.reconfigure(encoding='utf-8') + # Lazy one-time migration to the yas/{cache,state}/ layout. The `stat()` + # here is the whole steady-state cost once migrated; the import stays + # inside the guard so it's never paid on the hot path after that. + # REMOVE AFTER 0.11.0 + if not version_file().exists(): + from yas.migrate import migrate + migrate() # Resolve config live so a freshly-set env var (e.g. YAS_FULL_WIDTH) or an # edited yas.toml takes effect on this invocation; CLI flags are top priority. - cfg = Config.load(argv=sys.argv[1:], config_dir=CLAUDE_DIR) + cfg = Config.load(argv=sys.argv[1:], config_dir=config_path().parent) bg_shift = cfg.bg_shift theme = THEMES.get(cfg.theme, CLAUDE_DARK) info = json.loads(sys.stdin.read()) # Write payload so the multi-session observer can index it. Keyed by - # session_id and overwritten in place, so the dir holds one file per - # session rather than one per render tick. The observer already collapses - # to the newest payload per session (mon/discovery.index_payloads_by_session), - # so the old timestamped filenames only ever accumulated dead weight. + # session_id and overwritten in place under yas/state/sessions/, so the + # dir holds one file per session rather than one per render tick. The + # observer already collapses to the newest payload per session + # (mon/discovery.index_payloads_by_session), so the old timestamped + # filenames only ever accumulated dead weight. session_id = _as_str(info.get('session_id')) or 'unknown' try: - out_dir = CLAUDE_DIR / 'statusline-output' - out_dir.mkdir(parents=True, exist_ok=True) - (out_dir / f'statusline.{session_id}.json').write_text(json.dumps(info)) + sessions_dir().mkdir(parents=True, exist_ok=True) + session_payload_path(session_id).write_text(json.dumps(info)) except OSError: pass diff --git a/claude/yas/config.py b/claude/yas/config.py index e4c1caf..766540b 100644 --- a/claude/yas/config.py +++ b/claude/yas/config.py @@ -3,6 +3,10 @@ Every configurable knob resolves through one fixed chain: CLI flag → canonical YAS_* env → legacy-alias env → yas.toml → default +(theme specifically: CLI → env → yas.toml → default — the legacy +statusline-theme file is retired; the installer folds any existing value into +yas.toml once and migration deletes the file.) + A higher-precedence source that is present and valid wins; an absent or invalid source falls through to the next. Only yas.toml-sourced rejections are surfaced in the visible error row (the row is titled "yas.toml"); every @@ -19,7 +23,6 @@ from typing import TYPE_CHECKING, TypeVar from yas.constants import ( - CLAUDE_DIR, DEFAULT_CONTEXT_LABELS, DEFAULT_CONTEXT_STATE, DEFAULT_CONTEXT_THRESHOLDS, @@ -32,6 +35,7 @@ DEFAULT_THEME, DEFAULT_SHOW_DAY_STATS, DEFAULT_SHOW_TOOL_USES, + config_path, ) from yas.themes import THEMES @@ -150,15 +154,6 @@ def _resolve( return default -def _legacy_theme_sources(config_dir: Path) -> list[tuple[str, object]]: - """The deprecated ~/.claude/statusline-theme file, lowest priority.""" - try: - name = (config_dir / 'statusline-theme').read_text().strip() - except OSError: - return [] - return [('legacy', name)] if name else [] - - def _parse_argv(argv: Sequence[str]) -> dict[str, str]: """Extract --theme / --bg-shift overrides from a CLI argv slice.""" out: dict[str, str] = {} @@ -225,6 +220,7 @@ def _write_toml_cache(cache_path: Path, mtime_ns: int, size: int, data: dict[str import marshal tmp = cache_path.with_name(f'{cache_path.name}.{os.getpid()}.tmp') try: + cache_path.parent.mkdir(parents=True, exist_ok=True) blob = marshal.dumps((CACHE_VERSION, mtime_ns, size, data)) with open(tmp, 'wb') as fh: fh.write(blob) @@ -243,14 +239,18 @@ def _load_toml(config_dir: Path) -> tuple[dict[str, object], str | None]: On Python 3.10 (no stdlib tomllib) the tomli backport is used instead, so TOML is still parsed. A parse failure → ({}, "yas.toml: parse error"). - A binary (marshal) cache of the parsed dict lives at yas.toml.cache next to - the source. On a warm, unchanged file the dict is returned straight from the - cache, skipping BOTH `import tomllib` and the read+parse. Any cache miss/ - staleness/corruption falls through to the live parse below, which then - refreshes the cache. + A binary (marshal) cache of the parsed dict lives under + config_dir/yas/cache/config.toml.cache, no longer beside the source file. + Derived from config_dir (not the module-global constants.toml_cache_path()) + so a caller that passes a sandboxed config_dir (e.g. tests using tmp_path) + never touches the real ~/.claude/yas/cache/ — the cache always lives next + to the yas.toml it was parsed from. On a warm, unchanged file the dict is + returned straight from the cache, skipping BOTH `import tomllib` and the + read+parse. Any cache miss/staleness/corruption falls through to the live + parse below, which then refreshes the cache. """ toml_path = config_dir / 'yas.toml' - cache_path = config_dir / 'yas.toml.cache' + cache_path = config_dir / 'yas' / 'cache' / 'config.toml.cache' try: st = toml_path.stat() except OSError: @@ -453,7 +453,7 @@ def load( if env is None: env = dict(os.environ) if config_dir is None: - config_dir = CLAUDE_DIR + config_dir = config_path().parent errors: list[str] = [] debug: list[str] = [] @@ -502,8 +502,7 @@ def cli_src(name: str) -> list[tuple[str, object]]: 'theme', cli_src('theme') + _env_sources(env, 'YAS_THEME', 'CLAUDE_STATUSLINE_THEME') - + toml_src(appearance, 'theme') - + _legacy_theme_sources(config_dir), + + toml_src(appearance, 'theme'), _parse_theme, DEFAULT_THEME, errors, debug) bg_shift = _resolve( 'bg_shift', diff --git a/claude/yas/constants.py b/claude/yas/constants.py index c4570ea..ac1f323 100644 --- a/claude/yas/constants.py +++ b/claude/yas/constants.py @@ -8,10 +8,96 @@ # Keep in sync with pyproject.toml's [project] version — pyproject isn't # shipped with the runtime copy under ~/.claude, so the value lives here too. -VERSION = '0.7.1' +VERSION = '0.8.0' +# Bumped by any future on-disk relayout under yas/; stamped into +# state/version.json by yas.migrate so a future migration can detect and +# convert an older layout. +LAYOUT_SCHEMA_VERSION = 1 HOME = Path(os.path.expanduser('~')) CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude'))) + +# --- YAS on-disk layout --- +# No module outside this one may import CLAUDE_DIR directly (i.e. +# `from yas.constants import CLAUDE_DIR`) — always go through the helpers +# below so a test's `monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp)` +# reaches every path. None of these helpers may be evaluated at import time +# (including as a function default argument) — call them at use time only. + + +def yas_root() -> Path: + """Root of the YAS on-disk layout under CLAUDE_DIR: yas/cache/, + yas/state/, yas/state/runtime/, + yas/state/signals/, yas/state/sessions/. `yas.toml` itself lives directly + under CLAUDE_DIR and is deliberately excluded from this tree — it is + user-authored config, not YAS-managed runtime/cache state.""" + return CLAUDE_DIR / 'yas' + + +def cache_dir() -> Path: + return yas_root() / 'cache' + + +def state_dir() -> Path: + return yas_root() / 'state' + + +def runtime_dir() -> Path: + return state_dir() / 'runtime' + + +def signals_dir() -> Path: + return state_dir() / 'signals' + + +def sessions_dir() -> Path: + return state_dir() / 'sessions' + + +def version_file() -> Path: + return state_dir() / 'version.json' + + +def config_path() -> Path: + return CLAUDE_DIR / 'yas.toml' + + +def toml_cache_path() -> Path: + return cache_dir() / 'config.toml.cache' + + +def tokens_log() -> Path: + return runtime_dir() / 'tokens.log' + + +def token_rate_log() -> Path: + return runtime_dir() / 'token-rate.log' + + +def render_log() -> Path: + return runtime_dir() / 'render.log' + + +def last_prompt_path() -> Path: + return signals_dir() / 'last-prompt.json' + + +def terminal_width_path() -> Path: + return signals_dir() / 'terminal-width' + + +def session_payload_path(session_id: str) -> Path: + return sessions_dir() / f'{session_id}.json' + + +def projects_dir() -> Path: + return CLAUDE_DIR / 'projects' + + +def settings_path() -> Path: + return CLAUDE_DIR / 'settings.json' + + MIN_WIDTH = 40 DEFAULT_MAX_WIDTH = 140 # Repo-levels (not path segments) the OpenSpec downward scan descends below diff --git a/claude/yas/info/subagents.py b/claude/yas/info/subagents.py index dc00f3b..f4dd2c6 100644 --- a/claude/yas/info/subagents.py +++ b/claude/yas/info/subagents.py @@ -9,19 +9,19 @@ from pathlib import Path from typing import NamedTuple -from yas.constants import CLAUDE_DIR, _sanitize, subagent_is_terminal, subagent_status +from yas.constants import _sanitize, last_prompt_path, projects_dir, subagent_is_terminal, subagent_status def read_last_prompt_ts(session_id: str) -> float | None: '''Return the last UserPromptSubmit timestamp for session_id, or None. - Reads the yas-last-prompt.json state file (a JSON map of session_id → - float epoch seconds) from CLAUDE_DIR. Returns None when the file is - missing, unreadable, contains invalid JSON, or does not include an entry - for session_id. Never raises. + Reads the last-prompt.json signal file (a JSON map of session_id → + float epoch seconds) at yas.constants.last_prompt_path(). Returns None + when the file is missing, unreadable, contains invalid JSON, or does not + include an entry for session_id. Never raises. ''' try: - state = CLAUDE_DIR / 'yas-last-prompt.json' + state = last_prompt_path() text = state.read_text() data = json.loads(text) if not isinstance(data, dict): @@ -956,7 +956,7 @@ def from_session(cls, session_id: str, project_dir: str, now: float | None = Non # Unix; on Windows paths start with a drive letter (no leading '-' # in CC's dir name) so the f-string prefix gave a wrong path. project_slug = re.sub(r'[^A-Za-z0-9]', '-', project_dir) - session_dir = CLAUDE_DIR / 'projects' / project_slug / session_id + session_dir = projects_dir() / project_slug / session_id subagents_dir = session_dir / 'subagents' if not subagents_dir.is_dir(): return cls() @@ -964,7 +964,7 @@ def from_session(cls, session_id: str, project_dir: str, now: float | None = Non # session .jsonl AND every subagents/agent-*.jsonl for structured # records, keyed by task-id == agent-.jsonl # filename stem minus the "agent-" prefix. See _collect_task_notifications. - session_jsonl = CLAUDE_DIR / 'projects' / project_slug / f'{session_id}.jsonl' + session_jsonl = projects_dir() / project_slug / f'{session_id}.jsonl' notif_map = _collect_task_notifications(session_jsonl, subagents_dir) subagents: list[RunningSubagent] = [] try: diff --git a/claude/yas/info/workflows.py b/claude/yas/info/workflows.py index 1e49c8d..cfa000d 100644 --- a/claude/yas/info/workflows.py +++ b/claude/yas/info/workflows.py @@ -17,9 +17,9 @@ from pathlib import Path from yas.constants import ( - CLAUDE_DIR, WORKFLOW_LIVENESS_SECONDS, _sanitize, + projects_dir, ) from yas.info.subagents import RunningSubagent, parse_transcript from yas.render.text import _middle_ellipsis @@ -183,7 +183,7 @@ def from_session(cls, session_id: str, project_dir: str) -> RunningWorkflows: # Same projects/ dir convention as RunningSubagents.from_session: every # non-alphanumeric char becomes '-' (Unix and Windows safe). project_slug = re.sub(r'[^A-Za-z0-9]', '-', project_dir) - session_dir = CLAUDE_DIR / 'projects' / project_slug / session_id + session_dir = projects_dir() / project_slug / session_id runs_dir = session_dir / 'subagents' / 'workflows' if not runs_dir.is_dir(): return cls() diff --git a/claude/yas/migrate.py b/claude/yas/migrate.py new file mode 100644 index 0000000..3620cc5 --- /dev/null +++ b/claude/yas/migrate.py @@ -0,0 +1,173 @@ +"""One-shot migration from the pre-0.9 flat ~/.claude layout to the yas/ +cache+state layout defined in yas.constants. + +# REMOVE AFTER 0.11.0 +This module (and the `app.main` startup guard that calls it) exists only to +carry users forward from the flat pre-0.9 layout onto the new yas/cache, +yas/state tree. Once a few releases have passed and the flat layout is no +longer expected in the wild, delete this module and its call site. +""" + +from __future__ import annotations +import json +import os +import re +import shutil +import sys +import tempfile +import time +from collections.abc import Callable +from pathlib import Path + +from yas import constants +from yas.constants import ( + LAYOUT_SCHEMA_VERSION, + VERSION, + cache_dir, + last_prompt_path, + runtime_dir, + sessions_dir, + signals_dir, + state_dir, + terminal_width_path, + tokens_log, + version_file, +) + +# Legacy basename -> new-path callable (not a precomputed Path) so a patched +# constants.CLAUDE_DIR is honoured both for the legacy source (resolved at +# call time in migrate() below) and the destination. +_MOVES: tuple[tuple[str, Callable[[], Path]], ...] = ( + ('statusline-tokens.log', tokens_log), + ('yas-last-prompt.json', last_prompt_path), + ('terminal-width', terminal_width_path), +) + +# Legacy files with no new-layout home — deleted outright. Note: +# 'statusline-theme' is handled separately below (guarded on yas.toml already +# carrying the folded value): folding its value into yas.toml is the +# installer's job, not this module's, and this module must never delete the +# only copy of a theme choice that failed to fold. +_DELETE_FILES: tuple[str, ...] = ( + 'statusline-token-rate.log', + 'statusline-render.log', + 'yas.toml.cache', +) + +_DELETE_DIRS: tuple[str, ...] = ( + 'statusline-output', +) + + +def _move(src: Path, dst: Path, *, verbose: bool = False) -> None: + """Move src to dst, skipping (never clobbering) when dst already exists.""" + if dst.exists(): + return + if not src.exists(): + return + os.rename(src, dst) + if verbose: + try: + rel_dst = dst.relative_to(constants.CLAUDE_DIR) + except ValueError: + rel_dst = dst + print(f' moved {src.name} -> {rel_dst}') + + +def migrate(verbose: bool = False) -> bool: + """Convert a pre-0.9 flat ~/.claude layout into the yas/cache, yas/state + tree. Every step is individually idempotent (mkdir exist_ok, moves skip + an existing destination, deletes tolerate a missing source), so this is + safe to call on every startup. Returns True only if every step succeeded; + on any OSError, version.json is left unwritten so the next run retries. + + When verbose is True, each move/delete that actually acts on an existing + legacy path prints a one-line summary to stdout; no-ops (legacy path + absent) stay silent. Defaults to False so the lazy first-render call in + app.py never spams a normal user's statusline. + """ + ok = True + + for d in (cache_dir(), state_dir(), + runtime_dir(), signals_dir(), sessions_dir()): + try: + d.mkdir(parents=True, exist_ok=True) + except OSError: + ok = False + + # Legacy sources are resolved from constants.CLAUDE_DIR at call time + # (rather than a precomputed module-level Path) so this module respects + # test/tooling patches of constants.CLAUDE_DIR the same way the new-path + # callables above do — the sanctioned exception to the "no module + # imports CLAUDE_DIR" rule, since this is a call-time attribute read. + for name, dst_fn in _MOVES: + try: + _move(constants.CLAUDE_DIR / name, dst_fn(), verbose=verbose) + except OSError: + ok = False + + for name in _DELETE_FILES: + src = constants.CLAUDE_DIR / name + try: + existed = src.exists() + src.unlink(missing_ok=True) + if verbose and existed: + print(f' removed {name}') + except OSError: + ok = False + + for name in _DELETE_DIRS: + src = constants.CLAUDE_DIR / name + try: + existed = src.exists() + shutil.rmtree(src, ignore_errors=True) + if verbose and existed: + print(f' removed {name}') + except OSError: + ok = False + + # Only retire the legacy statusline-theme file once yas.toml already + # carries a `theme =` line — ops/install.sh's fold_legacy_theme() writes + # that line before this runs. If yas.toml has no theme line (fold failed + # its parse-validation, or no yas.toml exists), keep the legacy file in + # place rather than losing the user's theme choice outright. + toml_path = constants.CLAUDE_DIR / 'yas.toml' + try: + has_theme = toml_path.exists() and bool( + re.search(r'(?m)^[ \t]*theme[ \t]*=', toml_path.read_text()) + ) + except OSError: + has_theme = False + if has_theme: + theme_path = constants.CLAUDE_DIR / 'statusline-theme' + try: + existed = theme_path.exists() + theme_path.unlink(missing_ok=True) + if verbose and existed: + print(' removed statusline-theme') + except OSError: + ok = False + + if ok: + payload = json.dumps({ + 'schema_version': LAYOUT_SCHEMA_VERSION, + 'yas_version': VERSION, + 'migrated_at': time.time(), + }) + tmp_path = None + try: + fd, tmp_path = tempfile.mkstemp(dir=state_dir(), prefix='.version-', suffix='.tmp') + with os.fdopen(fd, 'w') as f: + f.write(payload) + os.replace(tmp_path, version_file()) + except OSError: + ok = False + if tmp_path is not None: + Path(tmp_path).unlink(missing_ok=True) + + return ok + + +if __name__ == '__main__': + _verbose = '--verbose' in sys.argv[1:] or os.environ.get('YAS_MIGRATE_VERBOSE') == '1' + raise SystemExit(0 if migrate(verbose=_verbose) else 1) diff --git a/claude/yas/render/text.py b/claude/yas/render/text.py index 6cea012..2a7420a 100644 --- a/claude/yas/render/text.py +++ b/claude/yas/render/text.py @@ -7,7 +7,6 @@ from yas.constants import ( _ANSI_RE, ASCII_TRANSLATE, - CLAUDE_DIR, DEFAULT_MAX_WIDTH, ELLIPSIS, GITHUB_TRANSLATE, @@ -15,6 +14,7 @@ STRIKE, UNICODE_TRANSLATE, UNSTRIKE, + terminal_width_path, ) @@ -33,7 +33,7 @@ def terminal_width() -> int: pass try: - w = int((CLAUDE_DIR / 'terminal-width').read_text().strip()) + w = int(terminal_width_path().read_text().strip()) if w > 0: return w except (OSError, ValueError): diff --git a/claude/yas/session.py b/claude/yas/session.py index e7f40a0..a050e2c 100644 --- a/claude/yas/session.py +++ b/claude/yas/session.py @@ -1,7 +1,9 @@ """Session data-classes and parser helpers. -The only package import is the leaf-level `_sanitize` from yas.constants (a -stdlib-only module, so no import cycle); everything else is stdlib. +The only package import is the leaf-level `_sanitize`/`settings_path` from +yas.constants (a stdlib-only module, so no import cycle); everything else is +stdlib. HOME (the user's home dir, distinct from CLAUDE_DIR) stays local to +this module for short_pwd's `~` collapsing. TokenAccounting (used by Model.cost_rates) is imported lazily inside the property so this module can be loaded before tokens.py exists; it will resolve once task 4.1 creates claude/yas/tokens.py. @@ -15,11 +17,10 @@ from pathlib import Path from typing import NamedTuple -from yas.constants import _sanitize +from yas.constants import _sanitize, settings_path -HOME = Path(os.path.expanduser('~')) -CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude'))) +HOME = Path(os.path.expanduser('~')) # helpers --------------------------------------- @@ -168,7 +169,7 @@ def plugins(self) -> str: # Only the user's own config dir is read. project_dir/.claude/settings.json # is attacker-controlled for a cloned repo — reading it was both an # unexpected trust-boundary read and an escape-injection sink (SEC-2). - candidates = [CLAUDE_DIR / 'settings.json'] + candidates = [settings_path()] for sf in candidates: if not sf.is_file(): continue diff --git a/claude/yas/tokens.py b/claude/yas/tokens.py index 9355163..3de955c 100644 --- a/claude/yas/tokens.py +++ b/claude/yas/tokens.py @@ -2,7 +2,7 @@ Imports: - yas.session for Model and usage types - - yas.constants for CLAUDE_DIR + - yas.constants for the runtime/log path helpers (tokens_log, token_rate_log, render_log) """ from __future__ import annotations @@ -11,7 +11,7 @@ import time from typing import Any, TYPE_CHECKING -from yas.constants import CLAUDE_DIR +from yas.constants import tokens_log, token_rate_log, render_log from yas.session import Model if TYPE_CHECKING: @@ -113,7 +113,7 @@ def __repr__(self) -> str: @classmethod def update(cls, session_id: str, today: str, total_in: int, cache_read: int, total_out: int) -> TokenLog: - log = CLAUDE_DIR / 'statusline-tokens.log' + log = tokens_log() lines = [] if log.exists(): for ln in log.read_text().splitlines(): @@ -168,7 +168,7 @@ class TokenRate: def update(cls, session_id: str, total_in: int, total_out: int) -> int: if not session_id: return 0 - log = CLAUDE_DIR / 'statusline-token-rate.log' + log = token_rate_log() now = time.time() rows: list[tuple[float, str, int, int]] = [] if log.exists(): @@ -204,7 +204,7 @@ def update(cls, session_id: str, total_in: int, total_out: int) -> int: def history(cls, session_id: str, n_buckets: int, window: float) -> list[int]: if n_buckets <= 0 or not session_id: return [] - log = CLAUDE_DIR / 'statusline-token-rate.log' + log = token_rate_log() now = time.time() samples: list[tuple[float, int, int]] = [] if log.exists(): @@ -245,7 +245,7 @@ def recently_active(cls, session_id: str, window: float = 10.0) -> tuple[bool, b """Return (in_active, out_active) — True if that count grew in the last `window` seconds.""" if not session_id: return False, False - log = CLAUDE_DIR / 'statusline-token-rate.log' + log = token_rate_log() if not log.exists(): return False, False now = time.time() @@ -289,7 +289,7 @@ class RenderTiming: def read(cls, session_id: str) -> float | None: if not session_id: return None - log = CLAUDE_DIR / 'statusline-render.log' + log = render_log() if not log.exists(): return None try: @@ -305,7 +305,7 @@ def read(cls, session_id: str) -> float | None: def write(cls, session_id: str, ms: float) -> None: if not session_id: return - log = CLAUDE_DIR / 'statusline-render.log' + log = render_log() now = time.time() rows: list[str] = [] if log.exists(): diff --git a/hooks/yas-prompt-hook.py b/hooks/yas-prompt-hook.py index 53dcd24..f0f7da0 100755 --- a/hooks/yas-prompt-hook.py +++ b/hooks/yas-prompt-hook.py @@ -3,9 +3,9 @@ Claude Code invokes this script on every UserPromptSubmit event, passing a JSON payload on stdin. The script reads session_id from the payload, then -atomically updates ~/.claude/yas-last-prompt.json (or -$CLAUDE_CONFIG_DIR/yas-last-prompt.json) with the current epoch timestamp for -that session. All other sessions' entries are preserved. +atomically updates /yas/state/signals/last-prompt.json (where +config_dir is $CLAUDE_CONFIG_DIR, defaulting to ~/.claude) with the current +epoch timestamp for that session. All other sessions' entries are preserved. Never raises — any failure is silently swallowed and the process exits 0. ''' @@ -18,10 +18,13 @@ def _state_path() -> Path: + # NOTE: this duplicates yas.constants.last_prompt_path() on purpose. Claude + # Code runs this hook as a bare script, without the `yas` package on + # sys.path, so it cannot import the helper. yas.constants remains the + # source of truth for the layout: keep the two in sync when it changes. config_dir = os.environ.get('CLAUDE_CONFIG_DIR', '') - if config_dir: - return Path(config_dir) / 'yas-last-prompt.json' - return Path.home() / '.claude' / 'yas-last-prompt.json' + base = Path(config_dir) if config_dir else Path.home() / '.claude' + return base / 'yas' / 'state' / 'signals' / 'last-prompt.json' def main() -> None: diff --git a/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml b/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml new file mode 100644 index 0000000..f161d5c --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-08-16 diff --git a/openspec/changes/consolidate-claude-dir-layout/design.md b/openspec/changes/consolidate-claude-dir-layout/design.md new file mode 100644 index 0000000..3a93535 --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/design.md @@ -0,0 +1,187 @@ +## Context + +Every YAS path today is composed ad hoc at its use site from a module-level `CLAUDE_DIR` constant: + +| Legacy path | Code | +|---|---| +| `statusline-tokens.log` | `tokens.py:116` | +| `statusline-token-rate.log` | `tokens.py:171, 207, 248` | +| `statusline-render.log` | `tokens.py:292, 308` | +| `statusline-output/statusline..json` | write `app.py:100-102`, read `mon/discovery.py:45` | +| `yas.toml.cache` | `config.py:254` | +| `statusline-theme` | `config.py:154-160` | +| `yas-last-prompt.json` | read `info/subagents.py:36`, write `hooks/yas-prompt-hook.py:23-24` | +| `terminal-width` | read `render/text.py:36`, write `ops/alacritty.py:26` (hardcoded `$HOME`) | + +`CLAUDE_DIR = Path(os.environ.get('CLAUDE_CONFIG_DIR', str(HOME / '.claude')))` is declared at `constants.py:14` and duplicated verbatim at `session.py:21-22`. Because it is a module-level constant frozen at import, `test/conftest.py:72-86`'s `tmp_home` fixture must monkeypatch it in **eight** modules; every new module that touches disk has to be added there or its tests write to the real `~/.claude`. + +Constraints: +- The statusline runs on every render tick; the hot path must not pay for migration after the first run. +- `hooks/yas-prompt-hook.py` is executed standalone by Claude Code with no `yas` package on `sys.path` — it cannot import `constants`. +- `ops/install.sh` is POSIX-ish bash and already owns legacy cleanup (`:825-882` install, `:966-979` uninstall) and the `yas.toml` writer (`:1144-1240`). +- Demo (`ops/demo.py:1670`) and installer preview (`install.sh:1114`) already point `CLAUDE_CONFIG_DIR` at scratch dirs, so they get the new layout for free. + +## Goals / Non-Goals + +**Goals:** +- One `yas/` subtree under `$CLAUDE_CONFIG_DIR` holding everything YAS owns except `yas.toml`. +- A visible cache/state split so `yas/cache/` is documented as safe to `rm -rf` at any time. +- Existing users migrated automatically, with no data the user cares about lost. +- One patch point for tests; one place in source that knows a path. +- A real uninstall that leaves no YAS files behind except the user's `yas.toml`. + +**Non-Goals:** +- XDG base directories (`$XDG_STATE_HOME` etc.). Rejected: Claude Code itself centralises on `$CLAUDE_CONFIG_DIR`, and a second root would make "where are my files" worse, not better. +- Changing any rendered output, config key, or knob semantics. +- Migrating or relocating Claude Code's own files (`settings.json`, `projects/`, `plugins/`). +- Backwards-compatible dual reads (read new, fall back to old at every call site). The migration makes them unnecessary. +- File locking or cross-process coordination. + +## Decisions + +### 1. Root is `$CLAUDE_CONFIG_DIR/yas/`; `yas.toml` stays put + +Target layout: + +``` +$CLAUDE_DIR/ + yas.toml # user config — NOT moved + yas/ + cache/ # regenerable; safe to delete at any time + config.toml.cache # was yas.toml.cache + state/ + version.json # {schema_version, yas_version, migrated_at} + runtime/ # yas writes and reads these + tokens.log # was statusline-tokens.log + token-rate.log # was statusline-token-rate.log + render.log # was statusline-render.log + signals/ # written by external processes, read by yas + last-prompt.json # was yas-last-prompt.json (UserPromptSubmit hook) + terminal-width # was terminal-width (ops/alacritty.py) + sessions/.json # was statusline-output/statusline..json +``` + +`yas.toml` stays at `$CLAUDE_DIR/yas.toml` because it is the one path users type, document, and share; moving it would break every existing README, blog post, and dotfile repo. Its *cache*, being ours and regenerable, does move (and is renamed `config.toml.cache` since it no longer sits beside the file it caches). + +`signals/` is separated from `runtime/` because those two files are the only ones written by a process that is not the renderer (the prompt hook, and a terminal integration script); the boundary documents which paths a third party may write. + +### 2. Central path API in `constants.py`, resolved at call time + +No new module — the paths join the existing constants. Each path is a **function** that reads the module-global `CLAUDE_DIR` when called: + +```python +def yas_root() -> Path: return CLAUDE_DIR / 'yas' +def cache_dir() -> Path: return yas_root() / 'cache' +def state_dir() -> Path: return yas_root() / 'state' +def runtime_dir() -> Path: return state_dir() / 'runtime' +def signals_dir() -> Path: return state_dir() / 'signals' +def sessions_dir() -> Path: return state_dir() / 'sessions' +def version_file() -> Path: return state_dir() / 'version.json' +def config_path() -> Path: return CLAUDE_DIR / 'yas.toml' +def toml_cache_path() -> Path: return cache_dir() / 'config.toml.cache' +def tokens_log() -> Path: return runtime_dir() / 'tokens.log' +def token_rate_log() -> Path: return runtime_dir() / 'token-rate.log' +def render_log() -> Path: return runtime_dir() / 'render.log' +def last_prompt_path() -> Path: return signals_dir() / 'last-prompt.json' +def terminal_width_path() -> Path: return signals_dir() / 'terminal-width' +def session_payload_path(session_id: str) -> Path: return sessions_dir() / f'{session_id}.json' +def projects_dir() -> Path: return CLAUDE_DIR / 'projects' +def settings_path() -> Path: return CLAUDE_DIR / 'settings.json' +``` + +Functions over module-level `Path` constants: a constant would freeze at import exactly like today's `CLAUDE_DIR` and re-create the eight-module patching problem. Functions close over `constants.__dict__`, so patching `constants.CLAUDE_DIR` alone redirects every path in the process regardless of which module imported which helper. `conftest.tmp_home` therefore drops to a single `monkeypatch.setattr(_sl_constants, 'CLAUDE_DIR', claude_dir)`. + +Consequence: **no module outside `constants.py` may import `CLAUDE_DIR`.** `projects_dir()` and `settings_path()` exist purely so `subagents.py`, `workflows.py`, and `session.py` have no reason to. `session.py:21-22`'s duplicate declaration is deleted. + +Second consequence: default arguments evaluated at import (`mon/discovery.py:23,45`) must become `None` sentinels resolved inside the function body, otherwise a patched `CLAUDE_DIR` is ignored. + +`hooks/yas-prompt-hook.py` is the documented exception — it runs without the package on `sys.path`, so it keeps its self-contained resolution and hardcodes `/yas/state/signals/last-prompt.json`, with a comment pointing at `constants.last_prompt_path()` as the source of truth. Same for `ops/alacritty.py`, which additionally starts honouring `CLAUDE_CONFIG_DIR` instead of hardcoding `$HOME` (a latent bug: a `CLAUDE_CONFIG_DIR` user's width signal was written where nothing reads it). + +### 3. Per-file disposition: move what's durable, delete what regenerates + +| Legacy | Disposition | Rationale | +|---|---|---| +| `statusline-tokens.log` | **MOVE** → `state/runtime/tokens.log` | Day totals; losing it resets today's figure. | +| `yas-last-prompt.json` | **MOVE** → `state/signals/last-prompt.json` | Cross-process handshake; a stale-free rebuild needs a new user prompt. | +| `terminal-width` | **MOVE** → `state/signals/terminal-width` | Written by an external script that may not run again soon. | +| `statusline-token-rate.log` | **DELETE** | 300 s rolling window (`tokens.py:167`); self-heals in five minutes. | +| `statusline-render.log` | **DELETE** | 300 s rolling window; cosmetic, off by default. | +| `yas.toml.cache` | **DELETE** | Pure parse cache; regenerates on next render. | +| `statusline-output/` (whole dir) | **DELETE** | Payloads are rewritten every render tick; `mon` recovers within one tick per live session. | +| `statusline-theme` | **FOLD, then DELETE** | See decision 4. | + +Deleting beats moving wherever the file regenerates within one render tick or one rolling window: a move is more code, more failure modes, and buys at most minutes of history. + +### 4. Retire the legacy `statusline-theme` file + +`config._legacy_theme_sources` (`config.py:154-160`) is deleted along with its entry in the theme-precedence chain. The migration preserves user intent instead: if `statusline-theme` exists, is non-empty, and `yas.toml` does **not** already set `[appearance] theme`, its value is written into `yas.toml`; then the file is deleted either way. + +The `yas.toml` write is owned exclusively by `ops/install.sh` (which already has an atomic, validating TOML writer at `:1144-1240`). The runtime migration never edits `yas.toml` — a render tick must not rewrite the user's config file, and a renderer that can only be reached through a working `yas.toml` read path has no business writing one. So a user who never re-runs the installer loses the deprecated theme file's value: acceptable, given it has been documented as deprecated (`README.md:123`) and the theme is a one-line re-set in `yas.toml`. + +### 5. Migration runs lazily at render time and eagerly at install time + +**Lazy (runtime).** `app.main` calls a single guard before anything else touches disk: + +```python +if not version_file().exists(): + from yas.migrate import migrate # imported only on the cold path + migrate() +``` + +Cost on the steady-state path is one `stat()` — well inside the render budget — and the `yas.migrate` import is not paid at all once migrated. The guard and module are marked in-source as removable a few releases after ship (the deletion is a two-line diff plus one file). + +**Eager (installer).** `ops/install.sh do_wire` runs the same migration through the resolved interpreter (`"$PYTHON_BIN" -c 'from yas.migrate import migrate; migrate()'` with `PYTHONPATH="$PLUGIN_ROOT/claude"`), immediately after the existing legacy `statusline-info-*` sweep. The installer additionally performs the `statusline-theme` → `yas.toml` fold (decision 4) *before* invoking the migration, so the migration only has to delete the file. A migration failure inside the installer is reported and non-fatal — the lazy path will retry on the next render. + +Both paths, rather than one: the installer gives a clean, observable, one-shot migration for the common upgrade route, while the lazy path covers users who update the plugin through `claude plugin update` without ever running `install.sh`. + +### 6. `version.json` is the completion marker, written last and atomically + +```json +{"schema_version": 1, "yas_version": "0.8.0", "migrated_at": 1770000000.0} +``` + +Written via `mkstemp` in `state/` + `os.replace`, as the final step of `migrate()`. `schema_version` is the layout contract version (bumped by any future relayout); `yas_version` is `constants.VERSION` at migration time, for support/debugging; `migrated_at` is epoch seconds. + +Since it is written last, a crash at any earlier point leaves the marker absent and the next run re-runs the whole migration — which is safe because every step is idempotent (decision 7). Presence of the file is the *only* thing the runtime guard checks; its contents are never parsed on the hot path. + +### 7. Concurrency: idempotent, per-file atomic, no locks + +Every step is one of: +- `mkdir(parents=True, exist_ok=True)` for the six directories, +- a move that is skipped when the destination already exists, otherwise `os.rename(src, dst)` — atomic within a filesystem, and never clobbering, +- a delete that tolerates `FileNotFoundError` (`unlink(missing_ok=True)` / `shutil.rmtree(..., ignore_errors=True)`). + +Every individual step is wrapped so that an `OSError` is swallowed and recorded but does not abort the remaining steps — except that any failure means `version.json` is **not** written, so the migration retries next run. + +Two processes racing (a render tick and the installer, or two sessions) is therefore harmless: the loser's rename fails with the destination already present and is skipped, and both write the same marker content. No lock file, no lock-file staleness problem. + +Accepted trade-off: during the upgrade window an *old* renderer process may still be writing `statusline-tokens.log` while a new one writes `state/runtime/tokens.log`. The worst case is a day-total figure that undercounts a handful of ticks, and it resolves the moment every process is on the new code. Not worth a compatibility shim. + +### 8. Uninstall removes the subtree and every legacy path + +`ops/install.sh do_uninstall` extends the existing `statusline-info-*` sweep to remove: +- `$CLAUDE_CONFIG_DIR/yas/` (recursive), and +- each legacy path from decision 3's table (both MOVE and DELETE rows, plus `statusline-theme`), + +and explicitly **never** touches `$CLAUDE_CONFIG_DIR/yas.toml` or anything owned by Claude Code. Under `--dry-run` each existing target is printed as `Would remove ` and nothing is deleted, matching the existing dry-run idiom at `:966-979`. + +## Risks / Trade-offs + +- **A user's day-total token count is lost if the tokens.log move fails** → the move is a same-filesystem `os.rename` (both paths are under `$CLAUDE_CONFIG_DIR`), the only realistic failure is a permissions problem that would equally break writing the new file; the counter self-heals from the next tick. +- **`mon` shows "(no active sessions)" briefly after upgrade** → `statusline-output/` is deleted, not moved, so payloads are absent until each session's next render tick (sub-second for an active session). Documented in the proposal as an accepted, self-healing regression. +- **Deprecated theme file silently stops working for non-installer upgraders** → mitigated by the installer fold; residual risk accepted per decision 4, and `README.md`/`CONTEXT.md` gain an explicit "removed in this release, set `[appearance] theme` in yas.toml" note. +- **The lazy guard is dead weight forever if nobody removes it** → the guard, the `yas/migrate.py` module, and the legacy-path table carry an in-source `# REMOVE AFTER ` marker naming the release, and a task in this change adds that marker. +- **A test that forgets the `tmp_home` fixture now writes to the real `~/.claude/yas/`** → unchanged in kind from today, but the blast radius is smaller (one subtree) and a session-scoped autouse safety net is out of scope here. + +## Migration Plan + +1. Ship the new path API and rewire every reader/writer in one release (no dual-read period). +2. On first render after upgrade the lazy guard fires; on `install.sh` re-run the eager path fires first. +3. `version.json` marks completion; subsequent runs pay one `stat()`. +4. A later release deletes `yas/migrate.py`, the guard in `app.main`, and the legacy tables in `install.sh`. Users who skip that many releases can re-run `install.sh`, or lose only regenerable data. + +Rollback: downgrading to a pre-change release makes YAS write the legacy paths again from scratch (all of them are created on demand); the `yas/` subtree is left orphaned and can be deleted by hand. + +## Open Questions + +- Whether the day-total tokens log should be pruned to the current day during the move (it is a whole-file rewrite per render anyway). Deferred: this change moves bytes, it does not change formats. diff --git a/openspec/changes/consolidate-claude-dir-layout/proposal.md b/openspec/changes/consolidate-claude-dir-layout/proposal.md new file mode 100644 index 0000000..c3b8ca6 --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/proposal.md @@ -0,0 +1,37 @@ +## Why + +YAS scatters seven files and one directory directly across `$CLAUDE_CONFIG_DIR` (`~/.claude` by default): `statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, and `statusline-output/`. They sit next to Claude Code's own `settings.json`, `projects/`, and `plugins/`, with no naming discipline, no separation of regenerable cache from durable state, and — apart from a one-line `statusline-info-*` sweep — no cleanup on uninstall. A user cannot tell which files are ours, which are safe to delete, or how to remove YAS's footprint. + +## What Changes + +- All YAS-owned files move under a single `$CLAUDE_CONFIG_DIR/yas/` subtree, split into `cache/` (regenerable, delete-anytime) and `state/` (`runtime/` yas-written logs, `signals/` externally-written inputs, `sessions/` render payloads). `yas.toml` stays at `$CLAUDE_CONFIG_DIR/yas.toml`. +- A one-shot, idempotent migration moves or deletes every legacy path. It runs in two places: lazily at render time (guarded by a single stat of `yas/state/version.json`) and eagerly from `ops/install.sh`. +- `yas/state/version.json` (`{schema_version, yas_version, migrated_at}`) is written last and atomically, and is the migration's completion marker. +- **BREAKING (internal paths)**: `statusline-token-rate.log`, `statusline-render.log`, `yas.toml.cache`, and `statusline-output/` are deleted rather than moved — the first two are 300-second rolling windows, the last two are regenerable caches/payloads. +- **BREAKING (deprecated feature removal)**: the legacy `statusline-theme` file is retired. Migration folds its value into `yas.toml` (only when `yas.toml` sets no theme), deletes the file, and `config._legacy_theme_sources` is removed from the theme-precedence chain. +- All YAS paths are defined centrally in `claude/yas/constants.py` as call-time path functions, so `test/conftest.py` patches one symbol (`constants.CLAUDE_DIR`) instead of eight module-local copies. `session.py`'s duplicate `CLAUDE_DIR` declaration is removed. +- `ops/alacritty.py` is fixed to honour `CLAUDE_CONFIG_DIR` (it currently hardcodes `$HOME/.claude`) and writes the new signals path. +- `ops/install.sh uninstall` removes the whole `yas/` subtree plus every legacy path, preserving `yas.toml`; `--dry-run` lists what it would remove. +- `claude/mon/discovery.py` reads render payloads from the new `yas/state/sessions/` directory. + +## Capabilities + +### New Capabilities + +- `claude-dir-layout`: the canonical on-disk layout of YAS-owned files under `$CLAUDE_CONFIG_DIR`, the central path API in `constants.py`, and the uninstall contract. +- `layout-migration`: the one-shot, idempotent, crash-safe migration from the legacy flat layout to the `yas/` subtree, its completion marker, and its two trigger points. + +### Modified Capabilities + +*(none — no existing spec under `openspec/specs/` owns these paths; the behaviour users observe from the statusline is unchanged.)* + +## Impact + +- `claude/yas/constants.py` — new path API (`yas_root()`, `cache_dir()`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`, and per-file helpers). +- `claude/yas/app.py` (`statusline-output` write, `Config.load(config_dir=…)`, migration hook), `claude/yas/tokens.py` (three log paths), `claude/yas/config.py` (toml cache path, legacy theme removal), `claude/yas/session.py` (duplicate `CLAUDE_DIR`), `claude/yas/info/subagents.py` (last-prompt read + projects paths), `claude/yas/info/workflows.py` (projects path), `claude/yas/render/text.py` (terminal-width read). +- New module-free migration code in `claude/yas/` (single function, imported lazily by `app.main`). +- `claude/mon/discovery.py` — payloads root default. +- `hooks/yas-prompt-hook.py` — new signals path (stays self-contained, no yas import). +- `ops/install.sh` — eager migration + theme fold + uninstall sweep; `ops/alacritty.py` — config-dir resolution + new path. +- `test/conftest.py` — `tmp_home` patches one symbol; `test/test_mon_discovery.py`, token-log, parse-cache, config-theme, and subagent tests follow the new paths. New tests for migration. +- Docs: `README.md` (theme file deprecation at ~:123, terminal-width at ~:230), `CONTEXT.md` (:21, :24, :35, :78, :112). diff --git a/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md b/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md new file mode 100644 index 0000000..6b0c064 --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/specs/claude-dir-layout/spec.md @@ -0,0 +1,109 @@ +## ADDED Requirements + +### Requirement: All YAS-owned files live under a single `yas/` subtree + +Every file YAS creates under `$CLAUDE_CONFIG_DIR` SHALL live under `$CLAUDE_CONFIG_DIR/yas/`, with the sole exception of the user config file `yas.toml`, which SHALL remain at `$CLAUDE_CONFIG_DIR/yas.toml`. The subtree SHALL use this layout: + +``` +yas/cache/config.toml.cache +yas/state/version.json +yas/state/runtime/tokens.log +yas/state/runtime/token-rate.log +yas/state/runtime/render.log +yas/state/signals/last-prompt.json +yas/state/signals/terminal-width +yas/state/sessions/.json +``` + +YAS SHALL NOT create or write any other file directly in `$CLAUDE_CONFIG_DIR`, and SHALL NOT modify files owned by Claude Code (`settings.json` outside the installer, `projects/`, `plugins/`). + +#### Scenario: Renderer writes only inside the subtree + +- **WHEN** a full render tick runs against an empty `$CLAUDE_CONFIG_DIR` +- **THEN** every file created is under `$CLAUDE_CONFIG_DIR/yas/` +- **AND** no `statusline-*` file, `statusline-output/` directory, `terminal-width` file, or `yas.toml.cache` is created at the top level of `$CLAUDE_CONFIG_DIR` + +#### Scenario: Config file is not relocated + +- **WHEN** the user's config lives at `$CLAUDE_CONFIG_DIR/yas.toml` +- **THEN** YAS reads it from that exact path +- **AND** never creates `$CLAUDE_CONFIG_DIR/yas/yas.toml` + +#### Scenario: Missing directories are created on demand + +- **WHEN** a writer (token log, session payload, toml cache) runs and its parent directory does not exist +- **THEN** the directory is created with `parents=True, exist_ok=True` and the write succeeds + +### Requirement: Cache directory is safe to delete at any time + +`$CLAUDE_CONFIG_DIR/yas/cache/` SHALL contain only regenerable data (the parsed-config cache and per-session transcript parse caches). Deleting the directory, or any file in it, SHALL NOT change rendered output, only performance. + +#### Scenario: Cache deleted between renders + +- **WHEN** `yas/cache/` is removed entirely and a render tick runs +- **THEN** the rendered output is byte-identical to the same render with the cache present +- **AND** the cache files are recreated + +### Requirement: Central path API resolved at call time + +All YAS paths SHALL be defined by functions in `claude/yas/constants.py` that read the module-global `CLAUDE_DIR` at call time. No module other than `constants.py` SHALL import or hold its own copy of `CLAUDE_DIR`, and no module SHALL evaluate a YAS path at import time (including as a default argument value). + +#### Scenario: One patch point redirects every path + +- **WHEN** a test patches only `yas.constants.CLAUDE_DIR` to a temporary directory +- **THEN** every subsequent read and write from the renderer, the token logs, the caches, the session payloads, and the `mon` observer resolves under that temporary directory +- **AND** no file is created under the real `~/.claude` + +#### Scenario: No duplicated root constants + +- **WHEN** the tree is searched for `CLAUDE_CONFIG_DIR` / `.claude` root resolution in Python source +- **THEN** the only occurrences are `claude/yas/constants.py`, `hooks/yas-prompt-hook.py`, and `ops/alacritty.py` (both standalone scripts that cannot import the package) + +### Requirement: Standalone writers honour CLAUDE_CONFIG_DIR and the new paths + +The `UserPromptSubmit` hook (`hooks/yas-prompt-hook.py`) SHALL write `$CLAUDE_CONFIG_DIR/yas/state/signals/last-prompt.json`, and the terminal-width helper (`ops/alacritty.py`) SHALL write `$CLAUDE_CONFIG_DIR/yas/state/signals/terminal-width`. Both SHALL resolve `$CLAUDE_CONFIG_DIR` from the environment, falling back to `~/.claude`, and SHALL create the `signals/` directory if absent. + +#### Scenario: Hook writes where the renderer reads + +- **WHEN** the prompt hook runs with `CLAUDE_CONFIG_DIR` set to a temporary directory and stamps a session +- **THEN** the renderer's subagent-cohort code reads that timestamp from `yas/state/signals/last-prompt.json` under the same directory + +#### Scenario: Width helper honours a custom config dir + +- **WHEN** `CLAUDE_CONFIG_DIR=/custom/claude` is set and `ops/alacritty.py` writes a width +- **THEN** the file is written to `/custom/claude/yas/state/signals/terminal-width` +- **AND** not to `$HOME/.claude/terminal-width` + +### Requirement: Observer reads session payloads from the new sessions directory + +The `mon` observer SHALL index render payloads from `$CLAUDE_CONFIG_DIR/yas/state/sessions/`, where each file is named `.json`, and SHALL resolve that root at call time rather than at import. + +#### Scenario: Payload round-trip + +- **WHEN** a render tick writes a payload for session `abc` and `mon` then indexes payloads +- **THEN** `mon` finds the payload for session `abc` from `yas/state/sessions/abc.json` + +### Requirement: Legacy theme file is no longer read + +The deprecated `$CLAUDE_CONFIG_DIR/statusline-theme` file SHALL NOT be consulted as a theme source. The theme precedence chain SHALL be CLI flag, then environment, then `yas.toml` `[appearance] theme`, then the built-in default. + +#### Scenario: Legacy theme file is ignored + +- **WHEN** `statusline-theme` exists containing a valid theme name and `yas.toml` sets no theme +- **THEN** the rendered theme is the built-in default, not the file's value + +### Requirement: Uninstall leaves no YAS files behind except yas.toml + +`ops/install.sh uninstall` SHALL remove `$CLAUDE_CONFIG_DIR/yas/` recursively and every legacy top-level path (`statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, `statusline-output/`, `statusline-info-*`). It SHALL NOT remove `$CLAUDE_CONFIG_DIR/yas.toml` or any Claude Code-owned file. + +#### Scenario: Full sweep + +- **WHEN** `install.sh uninstall` runs against a config dir containing both a populated `yas/` subtree and every legacy path +- **THEN** all of them are gone afterwards +- **AND** `yas.toml`, `settings.json`, `projects/`, and `plugins/` are untouched apart from the existing `statusLine`/hook key removal in `settings.json` + +#### Scenario: Dry run removes nothing + +- **WHEN** `install.sh uninstall --dry-run` runs against the same config dir +- **THEN** each existing target is listed as "Would remove" +- **AND** every file still exists afterwards diff --git a/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md b/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md new file mode 100644 index 0000000..a8dd1a3 --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/specs/layout-migration/spec.md @@ -0,0 +1,106 @@ +## ADDED Requirements + +### Requirement: One-shot migration from the legacy flat layout + +YAS SHALL provide a single migration routine that converts a legacy `$CLAUDE_CONFIG_DIR` to the `yas/` subtree layout. The routine SHALL create `yas/cache/`, `yas/state/`, `yas/state/runtime/`, `yas/state/signals/`, and `yas/state/sessions/`, and then apply exactly these dispositions: + +| Legacy path | Disposition | +|---|---| +| `statusline-tokens.log` | move → `yas/state/runtime/tokens.log` | +| `yas-last-prompt.json` | move → `yas/state/signals/last-prompt.json` | +| `terminal-width` | move → `yas/state/signals/terminal-width` | +| `statusline-token-rate.log` | delete | +| `statusline-render.log` | delete | +| `yas.toml.cache` | delete | +| `statusline-output/` (recursive) | delete | +| `statusline-theme` | delete (after the installer's fold, see below) | + +#### Scenario: Full legacy layout is migrated + +- **WHEN** the migration runs against a config dir containing all nine legacy paths +- **THEN** the three moved files exist at their new paths with their original contents +- **AND** the six deleted paths no longer exist +- **AND** all six new directories exist + +#### Scenario: Nothing to migrate + +- **WHEN** the migration runs against a config dir with no legacy paths at all +- **THEN** it completes without error +- **AND** the directory skeleton and `version.json` are created + +### Requirement: Migration is idempotent and never clobbers a destination + +Every migration step SHALL be safe to repeat. Directory creation SHALL use `exist_ok=True`. A move SHALL be skipped when the destination already exists, and otherwise performed with a single atomic rename. A delete SHALL tolerate an already-absent target. An `OSError` in any individual step SHALL be swallowed so the remaining steps still run, but SHALL prevent the completion marker from being written. + +#### Scenario: Second run is a no-op + +- **WHEN** the migration runs twice in a row against the same config dir +- **THEN** the second run changes no file contents and raises no error + +#### Scenario: Destination already populated + +- **WHEN** `statusline-tokens.log` exists AND `yas/state/runtime/tokens.log` already exists with different contents +- **THEN** the new file's contents are left untouched +- **AND** no exception propagates + +#### Scenario: Concurrent migrations + +- **WHEN** two processes run the migration simultaneously against the same config dir +- **THEN** each moved file ends up exactly once at its destination with intact contents +- **AND** neither process raises + +### Requirement: Completion marker written last and atomically + +On successful completion the migration SHALL write `$CLAUDE_CONFIG_DIR/yas/state/version.json` as its final action, via a temporary file in the same directory replaced into place with `os.replace`. Its contents SHALL be a JSON object with `schema_version` (integer, `1` for this layout), `yas_version` (the string from `constants.VERSION`), and `migrated_at` (epoch seconds). + +#### Scenario: Marker contents + +- **WHEN** the migration completes +- **THEN** `yas/state/version.json` parses as JSON with `schema_version == 1`, `yas_version` equal to `constants.VERSION`, and a numeric `migrated_at` + +#### Scenario: Crash before completion re-runs cleanly + +- **WHEN** the migration is interrupted after moving some files but before writing `version.json` +- **THEN** the marker is absent +- **AND** a subsequent run completes the remaining steps and writes the marker, leaving already-moved files intact + +### Requirement: Lazy migration at render time behind a single stat + +`app.main` SHALL check for the existence of `yas/state/version.json` before any other filesystem access, and SHALL invoke the migration only when the marker is absent. The migration module SHALL be imported lazily inside that branch, so a migrated installation pays one `stat()` and no import cost. The guard and its module SHALL carry an in-source marker identifying them as removable in a later release. + +#### Scenario: Migrated installation skips the migration + +- **WHEN** `version.json` exists and a render tick runs +- **THEN** the migration routine is not invoked and the migration module is not imported + +#### Scenario: Un-migrated installation migrates then renders + +- **WHEN** `version.json` is absent, legacy files are present, and a render tick runs +- **THEN** the migration runs first, the marker is written, and the render output is identical to the same render on an already-migrated dir + +### Requirement: Eager migration and theme fold at install time + +`ops/install.sh` SHALL run the migration during `do_wire`, using the interpreter and plugin root it has already resolved. Before invoking it, the installer SHALL fold the deprecated `statusline-theme` file into `yas.toml`: when that file exists and is non-empty AND `yas.toml` does not already set `[appearance] theme`, the installer SHALL write that theme name into `yas.toml` using its existing atomic, validating TOML write path. A migration failure SHALL be reported but SHALL NOT abort the install. + +#### Scenario: Theme folded into yas.toml + +- **WHEN** `statusline-theme` contains `gruvbox`, `yas.toml` exists and sets no theme, and the installer runs +- **THEN** `yas.toml` afterwards sets `[appearance] theme = "gruvbox"` and still parses +- **AND** `statusline-theme` no longer exists + +#### Scenario: Existing yas.toml theme wins + +- **WHEN** `statusline-theme` contains `gruvbox` and `yas.toml` already sets `theme = "claude-dark"` +- **THEN** `yas.toml` is left unchanged +- **AND** `statusline-theme` is still deleted + +#### Scenario: Runtime migration never writes yas.toml + +- **WHEN** the lazy render-time migration runs with a `statusline-theme` file present +- **THEN** `yas.toml` is not created or modified +- **AND** `statusline-theme` is deleted + +#### Scenario: Migration failure does not fail the install + +- **WHEN** the migration invocation exits non-zero during `do_wire` +- **THEN** the installer prints a warning and continues to the settings.json wiring step diff --git a/openspec/changes/consolidate-claude-dir-layout/tasks.md b/openspec/changes/consolidate-claude-dir-layout/tasks.md new file mode 100644 index 0000000..005c15d --- /dev/null +++ b/openspec/changes/consolidate-claude-dir-layout/tasks.md @@ -0,0 +1,65 @@ + + +## 1. Central path API in constants.py + +- [x] 1.1 In `claude/yas/constants.py`, directly below `CLAUDE_DIR` (line 14), add a `# --- YAS on-disk layout ---` block of path helper **functions** (not constants, so a patched `constants.CLAUDE_DIR` redirects everything): `yas_root()`, `cache_dir()`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`, `version_file()`. Each returns a `Path` built from the module-global `CLAUDE_DIR` at call time; add a docstring on `yas_root()` naming the full layout and stating `yas.toml` is deliberately excluded. +- [x] 1.2 Add the per-file helpers in the same block: `config_path()` → `CLAUDE_DIR/'yas.toml'`; `toml_cache_path()` → `cache_dir()/'config.toml.cache'`; `tokens_log()`, `token_rate_log()`, `render_log()` → `runtime_dir()/{'tokens.log','token-rate.log','render.log'}`; `last_prompt_path()` → `signals_dir()/'last-prompt.json'`; `terminal_width_path()` → `signals_dir()/'terminal-width'`; `session_payload_path(session_id: str)` → `sessions_dir()/f'{session_id}.json'`; `projects_dir()` → `CLAUDE_DIR/'projects'`; `settings_path()` → `CLAUDE_DIR/'settings.json'`. +- [x] 1.3 Add `LAYOUT_SCHEMA_VERSION = 1` to `constants.py` next to `VERSION` (line 11), with a comment that it is bumped by any future relayout and stamped into `state/version.json`. +- [x] 1.4 Add a module-level comment above the block stating the invariant: no module outside `constants.py` may import `CLAUDE_DIR`, and no YAS path may be evaluated at import time (including as a default argument). + +## 2. Migration module + +- [x] 2.1 Create `claude/yas/migrate.py` with a module docstring carrying a `# REMOVE AFTER 0.11.0` marker explaining that the module and its `app.main` guard exist only to convert pre-0.9 flat layouts and are deletable a few releases after ship. +- [x] 2.2 Define the legacy disposition tables as module constants: `_MOVES: tuple[tuple[str, Callable[[], Path]], ...]` = `('statusline-tokens.log', tokens_log)`, `('yas-last-prompt.json', last_prompt_path)`, `('terminal-width', terminal_width_path)`; `_DELETE_FILES` = `('statusline-token-rate.log', 'statusline-render.log', 'yas.toml.cache', 'statusline-theme')`; `_DELETE_DIRS` = `('statusline-output',)`. Export them so `test/` and any future tooling read one list. +- [x] 2.3 Implement `migrate() -> bool`: create the six directories (`cache_dir()`, `cache_dir()/'transcripts'`, `state_dir()`, `runtime_dir()`, `signals_dir()`, `sessions_dir()`) with `mkdir(parents=True, exist_ok=True)`; then apply `_MOVES` via a `_move(src, dst)` helper that returns early when `dst.exists()` and otherwise calls `os.rename(src, dst)`; then `Path.unlink(missing_ok=True)` each `_DELETE_FILES` entry and `shutil.rmtree(..., ignore_errors=True)` each `_DELETE_DIRS` entry. Wrap each individual step in `try/except OSError`, tracking a local `ok` flag. +- [x] 2.4 As the final step, when `ok` is still true, write `version_file()` atomically: `json.dumps({'schema_version': LAYOUT_SCHEMA_VERSION, 'yas_version': VERSION, 'migrated_at': time.time()})` to a `tempfile.mkstemp(dir=state_dir(), prefix='.version-', suffix='.tmp')` file, then `os.replace`. Unlink the temp file on failure. Return `ok`. +- [x] 2.5 Add a `main()` entry (`if __name__ == '__main__': raise SystemExit(0 if migrate() else 1)`) so `ops/install.sh` can invoke it as `python -m yas.migrate` with a meaningful exit code. + +## 3. Rewire every reader/writer to the new API + +- [x] 3.1 `claude/yas/app.py`: replace the `CLAUDE_DIR` import (line 9) with `config_path`/`session_payload_path`/`sessions_dir`/`version_file` imports from `yas.constants`. At the top of `main()` — before `Config.load` and before any other disk access — add the lazy guard: `if not version_file().exists(): from yas.migrate import migrate; migrate()`, with a `# REMOVE AFTER 0.11.0` comment. +- [x] 3.2 `claude/yas/app.py:87`: pass `config_dir=config_path().parent` (or keep `Config.load(config_dir=...)`'s signature and pass `constants.CLAUDE_DIR` via a new `config_dir()` accessor — do NOT re-introduce a module-level `CLAUDE_DIR` import). +- [x] 3.3 `claude/yas/app.py:99-104`: replace the `CLAUDE_DIR / 'statusline-output'` block with `sessions_dir().mkdir(parents=True, exist_ok=True)` + `session_payload_path(session_id).write_text(json.dumps(info))`, keeping the surrounding `try/except OSError: pass`. Update the comment above it (lines 93-97) to name the new path. +- [x] 3.4 `claude/yas/tokens.py`: drop the `CLAUDE_DIR` import (line 14) for `tokens_log`, `token_rate_log`, `render_log`; replace the six path expressions at lines 116, 171, 207, 248, 292, 308 with the helper calls. Ensure each writer `mkdir(parents=True, exist_ok=True)`s `runtime_dir()` before its first write (`TokenLog.update` at :116 and the rate/render writers) so a fresh install works before migration has ever run. +- [x] 3.5 `claude/yas/config.py`: delete `_legacy_theme_sources` (lines 154-160) entirely and remove its call site from the theme precedence chain; update the chain's docstring/comment to read CLI → env → yas.toml → default. +- [x] 3.6 `claude/yas/config.py:240-259` (`_load_toml`): keep `toml_path = config_dir / 'yas.toml'` but source the cache path from `constants.toml_cache_path()` instead of `config_dir / 'yas.toml.cache'`, and `mkdir(parents=True, exist_ok=True)` the cache dir inside `_write_toml_cache` before the temp write (line 218-232). Update the docstring at :250-251 which currently says the cache "lives next to the source". +- [x] 3.7 `claude/yas/session.py`: delete the duplicated `HOME`/`CLAUDE_DIR` declaration (lines 21-22) and any now-unused `os`/`Path` imports; change line 171's `candidates = [CLAUDE_DIR / 'settings.json']` to use `settings_path()` imported from `yas.constants` (session.py already imports `_sanitize` from there — extend that import). +- [x] 3.9 `claude/yas/info/subagents.py`: replace the `CLAUDE_DIR` import (:21) with `last_prompt_path` and `projects_dir`; update the last-prompt read at :36, the docstring at :30-31, and the two projects paths at :1172 and :1180. +- [x] 3.10 `claude/yas/info/workflows.py`: replace the `CLAUDE_DIR` import (:20) with `projects_dir` and update the session-dir expression at :186. +- [x] 3.11 `claude/yas/render/text.py`: replace the `CLAUDE_DIR` import (:10) with `terminal_width_path` and update the read at :36. +- [x] 3.12 `claude/mon/discovery.py`: change the `projects_root` (:23) and `payloads_root` (:45) default arguments to `None` and resolve them in the function body via `projects_dir()` / `sessions_dir()` — import-time defaults would ignore a patched `CLAUDE_DIR`. Update the payload glob/filename handling for the new `.json` naming (was `statusline..json`) in `index_payloads_by_session`. +- [x] 3.13 `hooks/yas-prompt-hook.py:21-24`: point the self-contained resolver at `/yas/state/signals/last-prompt.json`, `mkdir(parents=True, exist_ok=True)` the parent before the `mkstemp` write at :54, and add a comment naming `yas.constants.last_prompt_path()` as the source of truth this file deliberately duplicates (it runs without the package on `sys.path`). Update the module docstring at :6-7. +- [x] 3.14 `ops/alacritty.py:26`: resolve `os.environ.get('CLAUDE_CONFIG_DIR', os.path.expanduser('~/.claude'))` instead of hardcoding `$HOME`, write to `/yas/state/signals/terminal-width`, and create the parent dir first. +- [x] 3.15 Run `grep -rn "CLAUDE_DIR" claude/ hooks/ ops/` and confirm the only Python hits are inside `claude/yas/constants.py`; grep for the nine legacy basenames across `claude/` and confirm the only hits are in `claude/yas/migrate.py`. + +## 4. Installer: eager migration, theme fold, uninstall sweep + +- [x] 4.1 `ops/install.sh do_wire`, immediately after the existing `statusline-info-*` legacy sweep (~:880-882) and after `PYTHON_BIN` is resolved (~:895-905): add a `migrate_layout()` step invoking `PYTHONPATH="$PLUGIN_ROOT/claude" "$PYTHON_BIN" -m yas.migrate`. On non-zero exit print a `fail`-style warning and continue (never `exit`). Honour `DRY_RUN=1` by printing "Would migrate layout" and skipping. +- [x] 4.2 Add a `fold_legacy_theme()` shell function called from `do_wire` **before** `migrate_layout()`: if `$CLAUDE_CONFIG_DIR/statusline-theme` exists and is non-empty, and `yas.toml` either doesn't exist or sets no `theme` key, write the theme name into `$CLAUDE_CONFIG_DIR/yas.toml` reusing the existing atomic `mktemp "${toml_path}.XXXXXXXXXX"` + parse-validate + `mv` pattern from the yas.toml generator (~:1144-1240). Print what it did. Do not delete the file here — `migrate()` owns that. +- [x] 4.3 `ops/install.sh do_uninstall` (~:966-979): extend the legacy sweep to `rm -rf "$CLAUDE_CONFIG_DIR/yas"` plus each of the eight legacy paths (`statusline-tokens.log`, `statusline-token-rate.log`, `statusline-render.log`, `statusline-theme`, `terminal-width`, `yas-last-prompt.json`, `yas.toml.cache`, `statusline-output/`) alongside the existing `statusline-info-*` glob. Keep the existing `DRY_RUN` "Would remove " idiom for every target, and add an explicit comment that `$CLAUDE_CONFIG_DIR/yas.toml` is deliberately preserved. +- [x] 4.4 Confirm the installer preview block (~:1114-1133) still works: it sets `CLAUDE_CONFIG_DIR="$scratch"`, so the preview render now creates `$scratch/yas/` — verify the `rm -rf "$scratch"` cleanup covers it (it should, unchanged). + +## 5. Tests + +- [x] 5.1 `test/conftest.py:71-86`: reduce `tmp_home` to a single `monkeypatch.setattr(_sl_constants, 'CLAUDE_DIR', claude_dir)`, delete the seven other `setattr` calls and any now-unused module imports at the top of the file, and rewrite the comment to explain the call-time-function design that makes one patch sufficient. +- [x] 5.2 Add `test/test_migrate.py` covering: full nine-path migration (moves land with contents, deletes are gone, six dirs exist); idempotent second run; move skipped when destination exists (destination contents preserved, no raise); `version.json` shape (`schema_version == 1`, `yas_version == constants.VERSION`, numeric `migrated_at`); crash-resume (delete `version.json` after a run, re-run, everything still intact); empty config dir (marker written, no error). +- [x] 5.3 Add a test that `app.main` does not import `yas.migrate` when `version.json` exists (e.g. assert on `sys.modules` after popping it, or monkeypatch a sentinel) and does migrate when it is absent. +- [x] 5.4 Add a layout-containment test: run a render tick against a fresh `tmp_home` and assert the set of entries created directly in `$CLAUDE_CONFIG_DIR` is a subset of `{'yas', 'yas.toml'}`. +- [x] 5.5 Update existing tests that reference old paths: token-log tests (`statusline-tokens.log` / `-token-rate.log` / `-render.log`), `test_mon_discovery.py` (`statusline-output/statusline..json` → `yas/state/sessions/.json`), any config test asserting `yas.toml.cache` placement, and any theme test exercising the legacy `statusline-theme` source (that one is deleted or inverted to assert the file is ignored). +- [x] 5.6 Add a hook test asserting `hooks/yas-prompt-hook.py` writes to `yas/state/signals/last-prompt.json` under a temp `CLAUDE_CONFIG_DIR`, and that `subagents.last_prompt_ts` reads it back. + +## 6. Docs + +- [x] 6.1 `CONTEXT.md`: update the path references at :21 (tokens log), :24 (token-rate log), :78 (legacy theme file — now removed), :112 (last-prompt file), and add a short "on-disk layout" block showing the `yas/` tree with the cache-is-disposable note. +- [x] 6.2 `README.md`: update ~:230 (terminal-width helper path) and replace the ~:123 deprecated `statusline-theme` section with a note that the file is no longer read, that the installer folds its value into `yas.toml` once, and how to set `[appearance] theme` by hand. +- [x] 6.3 Add a short migration note to the README/CHANGELOG-facing text: rate-limit history, render timings, and `mon`'s payloads are regenerated rather than moved, so a brief post-upgrade cold start is expected. + +## 7. Verify + +- [x] 7.1 Via the `verifier` agent: `uv run pytest -q` full suite green, and `uv run ruff check` clean. +- [x] 7.2 Via the `verifier` agent: `make demo/img` + `.claude/skills/yas-demo-text/scripts/demo-text.sh`, diff `demo/text/*.txt` — expect **zero** rendered-output change; any diff is a bug in this change. +- [x] 7.3 Manual smoke: in a scratch dir, `CLAUDE_CONFIG_DIR=$scratch` with a hand-built legacy layout, run one render, and confirm the tree matches the target layout and `version.json` is present; then run `ops/install.sh uninstall --dry-run` and confirm the listed targets, then the real uninstall and confirm only `yas.toml` remains. diff --git a/ops/alacritty.py b/ops/alacritty.py index c3783e1..8d02d21 100755 --- a/ops/alacritty.py +++ b/ops/alacritty.py @@ -23,7 +23,13 @@ if new_columns != columns: print('terminal width changed! new columns:', new_columns, file=sys.stderr) columns = new_columns - with open(f'{os.environ["HOME"]}/.claude/terminal-width', 'w') as f: + # NOTE: duplicates yas.constants.terminal_width_path() on purpose — + # this script runs standalone, without the `yas` package on + # sys.path. yas.constants is the source of truth for the layout. + config_dir = os.environ.get('CLAUDE_CONFIG_DIR', os.path.expanduser('~/.claude')) + width_path = os.path.join(config_dir, 'yas', 'state', 'signals', 'terminal-width') + os.makedirs(os.path.dirname(width_path), exist_ok = True) + with open(width_path, 'w') as f: f.write(str(columns)) try: r = subprocess.run(['pgrep', '-f', 'claude'], capture_output=True, text=True) diff --git a/ops/demo.py b/ops/demo.py index c871963..4c0fda9 100644 --- a/ops/demo.py +++ b/ops/demo.py @@ -119,6 +119,31 @@ def task_state_for(pct: float) -> list[tuple[str, str, str]]: return out +def _seed_yas_version(claude_dir: Path) -> None: + """Pre-write yas/state/version.json so the renderer's lazy yas.migrate() + sees an already-migrated layout and skips entirely — otherwise migrate() + deletes the legacy statusline-token-rate.log (and would fold/relocate + statusline-tokens.log) before the first render, dropping the sparkline + data this demo seeds below. Schema mirrors yas.migrate.migrate()'s payload. + """ + # Local import: only main()'s --snapshots path adds claude/ to sys.path, + # and this is reached from other call sites (the animate path) too, so + # import lazily and fall back to a hardcoded schema version if yas isn't + # importable yet rather than hard-failing the demo. + try: + sys.path.insert(0, str(REPO_ROOT / 'claude')) + from yas.constants import LAYOUT_SCHEMA_VERSION, VERSION + except ImportError: + LAYOUT_SCHEMA_VERSION, VERSION = 1, '0.0.0-demo' + version_file = claude_dir / 'yas' / 'state' / 'version.json' + version_file.parent.mkdir(parents=True, exist_ok=True) + version_file.write_text(json.dumps({ + 'schema_version': LAYOUT_SCHEMA_VERSION, + 'yas_version': VERSION, + 'migrated_at': time.time(), + })) + + def build_synthetic_env(tmpdir: Path, session_id: str) -> None: claude = tmpdir / '.claude' project = tmpdir / 'my-project' @@ -158,9 +183,12 @@ def _git(*args: str) -> None: write_settings(claude, []) write_transcript(claude / 'projects' / session_id / f'{session_id}.jsonl', [], 0, 0, 0, 0) today = datetime.now().strftime('%Y-%m-%d') - (claude / 'statusline-tokens.log').write_text( + tokens_log = claude / 'yas' / 'state' / 'runtime' / 'tokens.log' + tokens_log.parent.mkdir(parents=True, exist_ok=True) + tokens_log.write_text( f'{today} demo-prior-session 8200000 215000000 1450000\n' ) + _seed_yas_version(claude) def _subagent_content_block(spec: object) -> dict[str, object] | None: @@ -792,7 +820,8 @@ def animate(env: dict[str, str], raw: dict[str, object], tmpdir: Path, session_i claude = tmpdir / '.claude' project = tmpdir / 'my-project' transcript_p = claude / 'projects' / session_id / f'{session_id}.jsonl' - rate_log = claude / 'statusline-token-rate.log' + rate_log = claude / 'yas' / 'state' / 'runtime' / 'token-rate.log' + rate_log.parent.mkdir(parents=True, exist_ok=True) KEEP = max(300.0, DEMO_TOKEN_WINDOW * 4) @@ -1569,7 +1598,9 @@ def render_scenario( claude = tmpdir / '.claude' project = tmpdir / 'my-project' transcript_p = claude / 'projects' / session_id / f'{session_id}.jsonl' - rate_log = claude / 'statusline-token-rate.log' + rate_log = claude / 'yas' / 'state' / 'runtime' / 'token-rate.log' + rate_log.parent.mkdir(parents=True, exist_ok=True) + _seed_yas_version(claude) ctx_size = 200_000 total_in = int(ctx_size * cfg.context_pct * 0.88) diff --git a/ops/install.sh b/ops/install.sh index 2450cb1..0dd6ec4 100755 --- a/ops/install.sh +++ b/ops/install.sh @@ -820,6 +820,96 @@ ensure_plugin() { fi } +# fold_legacy_theme ----------------------------- +# The legacy $CLAUDE_CONFIG_DIR/statusline-theme file held a bare theme name. +# That knob now lives in yas.toml under [appearance] theme. Fold the legacy +# value in BEFORE migrate_layout() runs, but only when yas.toml does not already +# express a theme — an explicit config always wins. The legacy file itself is +# NOT deleted here: migrate() (python -m yas.migrate) owns retiring it. +fold_legacy_theme() { + local theme_file="$CLAUDE_CONFIG_DIR/statusline-theme" + local toml_path="$CLAUDE_CONFIG_DIR/yas.toml" + + [ -s "$theme_file" ] || return 0 + + local theme + theme=$(tr -d '\r' < "$theme_file" | head -1 | tr -d '[:space:]') + [ -n "$theme" ] || return 0 + + # Existing explicit theme = nothing to fold. + if [ -f "$toml_path" ] && grep -Eq '^[[:space:]]*theme[[:space:]]*=' "$toml_path"; then + printf '%b yas.toml already sets a theme — leaving legacy statusline-theme unused%b\n' "$C_DIM" "$C_RESET" + return 0 + fi + + if [ "$DRY_RUN" = "1" ]; then + printf '%b Would fold legacy theme "%s" into %s%b\n' "$C_DIM" "$theme" "$toml_path" "$C_RESET" + return 0 + fi + + # Build the new content: fold the theme into an *existing* [appearance] + # table when present (a second [appearance] header is a tomllib "Cannot + # declare ('appearance',) twice" parse error), append a fresh + # [appearance] stanza when the file exists but has no such table, or + # write a minimal file when yas.toml doesn't exist yet. + local content + if [ -f "$toml_path" ] && grep -Eq '^[[:space:]]*\[appearance\]' "$toml_path"; then + content=$(awk -v theme="$theme" ' + { print } + !done && /^[[:space:]]*\[appearance\]/ { + print "# Folded in from the legacy statusline-theme file by ops/install.sh." + print "theme = \"" theme "\"" + done = 1 + } + ' "$toml_path") + elif [ -f "$toml_path" ]; then + content=$(cat "$toml_path"; printf '\n[appearance]\n# Folded in from the legacy statusline-theme file by ops/install.sh.\ntheme = "%s"\n' "$theme") + else + content=$(printf '# yas.toml — yet-another-statusline configuration\n\n[appearance]\n# Folded in from the legacy statusline-theme file by ops/install.sh.\ntheme = "%s"\n' "$theme") + fi + + # Atomic write (mktemp + mv) with a parse check, mirroring run_wizard. + local tmp + tmp=$(mktemp "${toml_path}.XXXXXXXXXX") || { fail '! could not create temp file — yas.toml unchanged'; return 0; } + printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; fail '! write failed — yas.toml unchanged'; return 0; } + + if [ -n "${PYTHON_BIN:-}" ]; then + if ! "$PYTHON_BIN" -c ' +try: + import tomllib +except ImportError: + import tomli as tomllib +import sys +tomllib.load(open(sys.argv[1], "rb")) +' "$tmp" 2>/dev/null; then + rm -f "$tmp" + fail '! folding legacy theme produced invalid yas.toml — leaving existing file untouched' + return 0 + fi + fi + + mv "$tmp" "$toml_path" + printf '%b Folded legacy theme "%s" into %s%b\n' "$C_GREEN" "$theme" "$toml_path" "$C_RESET" +} + +# migrate_layout -------------------------------- +# Move any legacy top-level state files/dirs into the consolidated +# $CLAUDE_CONFIG_DIR/yas/ layout. Delegates entirely to the packaged migrator so +# the layout is described in exactly one place. Failure is reported and NON-fatal: +# the renderer retries the migration lazily on its next render. +migrate_layout() { + if [ "$DRY_RUN" = "1" ]; then + printf '%b Would migrate layout → %s/yas/%b\n' "$C_DIM" "$CLAUDE_CONFIG_DIR" "$C_RESET" + return 0 + fi + + if PYTHONPATH="$PLUGIN_ROOT/claude" "$PYTHON_BIN" -m yas.migrate --verbose; then + printf '%b Layout migrated → %s/yas/%b\n' "$C_DIM" "$CLAUDE_CONFIG_DIR" "$C_RESET" + else + fail "! layout migration failed — continuing; the statusline will retry on next render" + fi +} + # do_wire --------------------------------------- # — discover renderer # - clean up legacy files @@ -896,6 +986,12 @@ do_wire() { fail "! Python 3.10+ not found — install Python 3.10+ (or uv) and re-run"; exit 1 fi + # Consolidated-layout migration. fold_legacy_theme() must run FIRST: it reads + # the legacy statusline-theme file that migrate() then retires. Both need + # PYTHON_BIN (toml parse check / the migrator itself), so they sit here. + fold_legacy_theme + migrate_layout + # Interactive config wizard: runs AFTER provisioning (previews need the # interpreter + shipped assets) and BEFORE the settings write. Guarded behind # INTERACTIVE + RUN_WIZARD so non-interactive / wire-only paths write no @@ -970,13 +1066,29 @@ do_uninstall() { heading "Settings" - # Legacy cleanup (always, even if settings has nothing to remove) - for f in "$CLAUDE_CONFIG_DIR"/statusline-info-*; do + # State cleanup (always, even if settings has nothing to remove). + # + # NOTE: $CLAUDE_CONFIG_DIR/yas.toml is DELIBERATELY PRESERVED — it is the + # user's own configuration, not YAS-generated state. Everything else YAS + # owns goes: the consolidated yas/ tree plus the nine retired legacy paths + # (for users uninstalling before ever running the migration). Nothing owned + # by Claude Code (settings.json beyond the statusLine key, projects/, + # plugins/) is touched. + for f in "$CLAUDE_CONFIG_DIR"/statusline-info-* \ + "$CLAUDE_CONFIG_DIR/yas" \ + "$CLAUDE_CONFIG_DIR/statusline-tokens.log" \ + "$CLAUDE_CONFIG_DIR/statusline-token-rate.log" \ + "$CLAUDE_CONFIG_DIR/statusline-render.log" \ + "$CLAUDE_CONFIG_DIR/statusline-theme" \ + "$CLAUDE_CONFIG_DIR/terminal-width" \ + "$CLAUDE_CONFIG_DIR/yas-last-prompt.json" \ + "$CLAUDE_CONFIG_DIR/yas.toml.cache" \ + "$CLAUDE_CONFIG_DIR/statusline-output"; do [ -e "$f" ] || continue if [ "$DRY_RUN" = "1" ]; then - printf '%b Would remove legacy %s%b\n' "$C_DIM" "$(basename "$f")" "$C_RESET" + printf '%b Would remove %s%b\n' "$C_DIM" "$(basename "$f")" "$C_RESET" else - rm -f "$f" && printf '%b Removed legacy %s%b\n' "$C_DIM" "$(basename "$f")" "$C_RESET" + rm -rf "$f" && printf '%b Removed %s%b\n' "$C_DIM" "$(basename "$f")" "$C_RESET" fi done @@ -1231,7 +1343,14 @@ run_wizard() { printf '%s\n' "$content" > "$tmp" || { rm -f "$tmp"; fail '! write failed — yas.toml unchanged' > /dev/tty; return 0; } if [ -n "${PYTHON_BIN:-}" ]; then - if ! "$PYTHON_BIN" -c 'import tomllib,sys; tomllib.load(open(sys.argv[1],"rb"))' "$tmp" 2>/dev/null; then + if ! "$PYTHON_BIN" -c ' +try: + import tomllib +except ImportError: + import tomli as tomllib +import sys +tomllib.load(open(sys.argv[1], "rb")) +' "$tmp" 2>/dev/null; then rm -f "$tmp" fail '! generated yas.toml failed to parse — leaving existing file untouched' > /dev/tty return 0 diff --git a/pyproject.toml b/pyproject.toml index 7b73003..3bbcbe0 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "yet-another-statusline" -version = "0.7.1" +version = "0.8.0" description = "Claude Code statusline showing info at a glance: tokens, context, model, subagents, burn rate, skills, plugins, OpenSpec specs, task lists, and more" readme = "README.md" diff --git a/skills/uninstall/SKILL.md b/skills/uninstall/SKILL.md index dd25c97..8db3b79 100644 --- a/skills/uninstall/SKILL.md +++ b/skills/uninstall/SKILL.md @@ -12,7 +12,7 @@ Reverse `/yas:init`. Remove the `statusLine` block from `settings.json` in `$CLA `claude plugin uninstall yas@yet-another-statusline` only deletes the plugin *cache* — Claude Code keeps reading `statusLine.command` from settings.json and tries to run the now-missing script. This skill clears that config so the statusline actually stops. -Leaves untouched: a custom (non-yas) statusLine, the `settings.json.bak-yas-*` backups, the user's `statusline-theme` / `terminal-width` config files, and any `make install` dev symlinks. +Leaves untouched: a custom (non-yas) statusLine, the `settings.json.bak-yas-*` backups, the user's `yas.toml` config file, and any `make install` dev symlinks. @@ -77,17 +77,24 @@ fi ## Step 3: Delete runtime state -The renderer writes these as it runs. Safe to remove; they regenerate if the statusline is ever reinstalled. +The renderer writes these as it runs. Safe to remove; they regenerate if the statusline is ever reinstalled. `yas.toml` is deliberately preserved — it is the user's own configuration, not YAS-generated state. ```bash -for f in statusline-tokens.log statusline-token-rate.log; do - if [ -f "$CONFIG_DIR/$f" ]; then - rm -f "$CONFIG_DIR/$f" && printf " Removed %s\n" "$f" +# Consolidated state tree (current versions) +if [ -d "$CONFIG_DIR/yas" ]; then + rm -rf "$CONFIG_DIR/yas" && printf " Removed yas/\n" +fi + +# Legacy flat paths (older versions, pre-migration) +for f in statusline-tokens.log statusline-token-rate.log statusline-render.log \ + statusline-theme terminal-width yas-last-prompt.json yas.toml.cache; do + if [ -e "$CONFIG_DIR/$f" ]; then + rm -f "$CONFIG_DIR/$f" && printf " Removed legacy %s\n" "$f" fi done if [ -d "$CONFIG_DIR/statusline-output" ]; then - rm -rf "$CONFIG_DIR/statusline-output" && printf " Removed statusline-output/\n" + rm -rf "$CONFIG_DIR/statusline-output" && printf " Removed legacy statusline-output/\n" fi # Legacy: older versions wrote per-session info files here @@ -115,7 +122,7 @@ Then print a summary: Still installed (this skill does not remove these): • the plugin itself — run: claude plugin uninstall yas@yet-another-statusline - • your theme/width prefs — statusline-theme, terminal-width + • your config — yas.toml • backups — settings.json.bak-yas-* (delete by hand if you want) Reload Claude Code to clear the statusline. diff --git a/test/conftest.py b/test/conftest.py index 4126cf8..88ab689 100644 --- a/test/conftest.py +++ b/test/conftest.py @@ -8,15 +8,9 @@ import pytest from helper import strip_ansi as _strip_ansi -import yas.app as _sl_app -import yas.config as _sl_config import yas.constants as _sl_constants import yas.renderer as _sl_renderer import yas.render.gradient as _sl_gradient -import yas.session as _sl_session -import yas.info.subagents as _sl_subagents -import yas.info.workflows as _sl_workflows -import yas.tokens as _sl_tokens _SRC = Path(__file__).resolve().parent.parent / 'claude' / 'statusline_command.py' @@ -69,16 +63,14 @@ def frozen_clock(monkeypatch: pytest.MonkeyPatch) -> float: @pytest.fixture def tmp_home(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Path: - # CLAUDE_DIR is a module-level constant frozen at import as HOME/'.claude'. - # Patching HOME alone leaves it pointing at the real ~/.claude, so source - # reads (token logs, subagents, theme, settings) would escape the sandbox. - # Patch each package module that carries its own copy. + # One patch point, by design: every YAS path is a *function* in + # yas.constants (tokens_log(), config_path(), ...) that reads the + # module-global CLAUDE_DIR when called, not a Path frozen at import. Modules + # import those helpers rather than CLAUDE_DIR itself, so rebinding the single + # constant here redirects every read and write in the process — including + # modules imported long before this fixture runs. Adding a new module that + # touches disk no longer requires touching this fixture; it only has to go + # through yas.constants like everything else. claude_dir = tmp_path / '.claude' - monkeypatch.setattr(_sl_app, 'CLAUDE_DIR', claude_dir) - monkeypatch.setattr(_sl_config, 'CLAUDE_DIR', claude_dir) monkeypatch.setattr(_sl_constants, 'CLAUDE_DIR', claude_dir) - monkeypatch.setattr(_sl_session, 'CLAUDE_DIR', claude_dir) - monkeypatch.setattr(_sl_subagents, 'CLAUDE_DIR', claude_dir) - monkeypatch.setattr(_sl_workflows, 'CLAUDE_DIR', claude_dir) - monkeypatch.setattr(_sl_tokens, 'CLAUDE_DIR', claude_dir) return tmp_path diff --git a/test/test_app_migrate_guard.py b/test/test_app_migrate_guard.py new file mode 100644 index 0000000..131adc1 --- /dev/null +++ b/test/test_app_migrate_guard.py @@ -0,0 +1,51 @@ +'''Pins the cold-path/hot-path split in yas.app.main's migration guard: +`yas.migrate` is imported only when version.json is absent, never on the +steady-state path once migrated. +''' +import io +import json +import sys + +import yas.app as app +from yas.constants import version_file + + +def _stdin_for(info: dict) -> io.StringIO: + return io.StringIO(json.dumps(info)) + + +def _example_info() -> dict: + from pathlib import Path + example = Path(__file__).resolve().parent.parent / 'ops' / 'session-info-example.json' + return json.loads(example.read_text()) + + +def test_main_does_not_import_migrate_when_version_file_exists(tmp_home, monkeypatch): + sys.modules.pop('yas.migrate', None) # start from a clean slate + + vf = version_file() + vf.parent.mkdir(parents=True, exist_ok=True) + vf.write_text(json.dumps({'schema_version': 1, 'yas_version': '0.0.0', 'migrated_at': 0})) + + monkeypatch.setattr(app, 'terminal_width', lambda: 200) + monkeypatch.setattr(app.sys, 'stdout', io.StringIO()) + monkeypatch.setattr(app.sys, 'stdin', _stdin_for(_example_info())) + + app.main() + + assert 'yas.migrate' not in sys.modules, 'migrate imported on the hot path' + + +def test_main_imports_migrate_when_version_file_absent(tmp_home, monkeypatch): + sys.modules.pop('yas.migrate', None) # start from a clean slate + + assert not version_file().exists() + + monkeypatch.setattr(app, 'terminal_width', lambda: 200) + monkeypatch.setattr(app.sys, 'stdout', io.StringIO()) + monkeypatch.setattr(app.sys, 'stdin', _stdin_for(_example_info())) + + app.main() + + assert 'yas.migrate' in sys.modules, 'migrate not imported on the cold path' + assert version_file().exists() diff --git a/test/test_config.py b/test/test_config.py index 97b965d..3ba35ac 100644 --- a/test/test_config.py +++ b/test/test_config.py @@ -812,12 +812,19 @@ def assert_blocked() -> None: @requires_tomllib -def test_cache_written_on_first_parse(tmp_path: Path) -> None: +def test_cache_written_on_first_parse(tmp_home: Path, monkeypatch: pytest.MonkeyPatch) -> None: + # yas.toml itself still lives directly under config_dir; its marshal + # cache lives under yas/cache/ (constants.toml_cache_path()) — point + # CLAUDE_DIR at the same tmp_home the toml is written into so the two + # resolve to the same tree. + tmp_path = tmp_home / '.claude' + tmp_path.mkdir(parents=True, exist_ok=True) (tmp_path / 'yas.toml').write_text('[layout]\nmax_width = 200\n') - assert not (tmp_path / 'yas.toml.cache').exists() + from yas.constants import toml_cache_path + assert not toml_cache_path().exists() cfg = config.Config.load(env={}, config_dir=tmp_path) assert cfg.max_width == 200 - assert (tmp_path / 'yas.toml.cache').exists() + assert toml_cache_path().exists() @requires_tomllib @@ -863,15 +870,19 @@ def test_cache_invalidated_on_backwards_mtime(tmp_path: Path, monkeypatch: pytes @requires_tomllib -def test_corrupt_cache_falls_back_to_live_parse(tmp_path: Path) -> None: +def test_corrupt_cache_falls_back_to_live_parse(tmp_home: Path) -> None: + tmp_path = tmp_home / '.claude' + tmp_path.mkdir(parents=True, exist_ok=True) toml = tmp_path / 'yas.toml' toml.write_text('[layout]\nmax_width = 200\n') - (tmp_path / 'yas.toml.cache').write_bytes(b'\x00not-valid-marshal\xff') + from yas.constants import toml_cache_path + toml_cache_path().parent.mkdir(parents=True, exist_ok=True) + toml_cache_path().write_bytes(b'\x00not-valid-marshal\xff') cfg = config.Config.load(env={}, config_dir=tmp_path) assert cfg.max_width == 200 # corruption swallowed, parsed live assert not cfg.errors # corruption is NOT surfaced as a parse error # The bad cache should have been overwritten with a valid one. - assert marshal.loads((tmp_path / 'yas.toml.cache').read_bytes())[0] == config.CACHE_VERSION + assert marshal.loads(toml_cache_path().read_bytes())[0] == config.CACHE_VERSION @requires_tomllib @@ -881,7 +892,9 @@ def test_stale_version_cache_reparsed(tmp_path: Path) -> None: st = toml.stat() bad = marshal.dumps((config.CACHE_VERSION + 99, st.st_mtime_ns, st.st_size, {'layout': {'max_width': 999}})) - (tmp_path / 'yas.toml.cache').write_bytes(bad) + cache_path = tmp_path / 'yas' / 'cache' / 'config.toml.cache' + cache_path.parent.mkdir(parents=True, exist_ok=True) + cache_path.write_bytes(bad) cfg = config.Config.load(env={}, config_dir=tmp_path) assert cfg.max_width == 200 # version mismatch → reparse from source @@ -891,13 +904,14 @@ def test_parse_error_writes_no_cache(tmp_path: Path) -> None: (tmp_path / 'yas.toml').write_text('this is = = not toml\n') cfg = config.Config.load(env={}, config_dir=tmp_path) assert any('parse error' in e for e in cfg.errors) - assert not (tmp_path / 'yas.toml.cache').exists() # no cache for a bad parse + # no cache for a bad parse + assert not (tmp_path / 'yas' / 'cache' / 'config.toml.cache').exists() def test_missing_toml_writes_no_cache(tmp_path: Path) -> None: cfg = config.Config.load(env={}, config_dir=tmp_path) assert cfg.errors == () - assert not (tmp_path / 'yas.toml.cache').exists() + assert not (tmp_path / 'yas' / 'cache' / 'config.toml.cache').exists() @requires_tomllib @@ -905,10 +919,11 @@ def test_readonly_dir_cache_write_swallowed(tmp_path: Path) -> None: import os toml = tmp_path / 'yas.toml' toml.write_text('[layout]\nmax_width = 200\n') - os.chmod(tmp_path, 0o500) # read+exec, no write + os.chmod(tmp_path, 0o500) # read+exec, no write — blocks mkdir of yas/cache/ try: cfg = config.Config.load(env={}, config_dir=tmp_path) assert cfg.max_width == 200 # parse still works; write failure swallowed assert cfg.errors == () + assert not (tmp_path / 'yas' / 'cache' / 'config.toml.cache').exists() finally: os.chmod(tmp_path, 0o700) diff --git a/test/test_install_script.py b/test/test_install_script.py index 68b5ee9..7295d80 100644 --- a/test/test_install_script.py +++ b/test/test_install_script.py @@ -9,6 +9,7 @@ import json import os import subprocess +import sys from pathlib import Path import pytest @@ -522,6 +523,91 @@ def _source_build_yas_toml() -> str: return '\n'.join(out) +def _source_bash_func(name: str) -> str: + """Extract a single top-level bash function body (by its `name() {` opener + and matching bare `}` closer) from install.sh, for hermetic unit tests of + pure logic that the live installer can't otherwise be driven through in CI.""" + lines = INSTALL_SH.read_text().splitlines() + out: list[str] = [] + capturing = False + for line in lines: + if line.startswith(f'{name}() {{'): + capturing = True + if capturing: + out.append(line) + if line == '}': + break + assert out and out[-1] == '}', f'{name} not found in install.sh' + return '\n'.join(out) + + +def _run_fold_legacy_theme(tmp_path: Path, existing_toml: str | None, legacy_theme: str) -> Path: + """Source fold_legacy_theme with minimal stand-ins for its dependencies + (fail(), the C_* color vars, DRY_RUN, PYTHON_BIN) and run it against a + scratch CLAUDE_CONFIG_DIR. Returns the resulting yas.toml path.""" + config_dir = tmp_path / 'claude_config' + config_dir.mkdir() + (config_dir / 'statusline-theme').write_text(legacy_theme + '\n') + toml_path = config_dir / 'yas.toml' + if existing_toml is not None: + toml_path.write_text(existing_toml) + + func = _source_bash_func('fold_legacy_theme') + script = f''' +fail() {{ printf 'FAIL: %s\\n' "$*" >&2; return 1; }} +C_DIM='' +C_GREEN='' +C_RESET='' +DRY_RUN=0 +CLAUDE_CONFIG_DIR="{config_dir}" +PYTHON_BIN="{sys.executable}" +{func} +fold_legacy_theme +''' + result = subprocess.run(['bash', '-c', script], capture_output=True, text=True) + assert result.returncode == 0, result.stderr + return toml_path + + +def test_fold_legacy_theme_inserts_into_existing_appearance_table(tmp_path: Path): + """F1(a) — folding into a yas.toml that already has an [appearance] table + (but no theme=) must insert the theme line inside that table, not append + a second [appearance] header (which tomllib rejects as a duplicate table).""" + tomllib = pytest.importorskip('tomllib') + toml_path = _run_fold_legacy_theme( + tmp_path, + existing_toml='[appearance]\nglyphs = "nerd"\n\n[layout]\nmax_width = 200\n', + legacy_theme='dracula', + ) + text = toml_path.read_text() + assert text.count('[appearance]') == 1 + data = tomllib.loads(text) + assert data['appearance']['theme'] == 'dracula' + assert data['appearance']['glyphs'] == 'nerd' + assert data['layout']['max_width'] == 200 + + +def test_fold_legacy_theme_appends_stanza_when_no_appearance_table(tmp_path: Path): + tomllib = pytest.importorskip('tomllib') + toml_path = _run_fold_legacy_theme( + tmp_path, + existing_toml='[layout]\nmax_width = 200\n', + legacy_theme='dracula', + ) + text = toml_path.read_text() + assert text.count('[appearance]') == 1 + data = tomllib.loads(text) + assert data['appearance']['theme'] == 'dracula' + assert data['layout']['max_width'] == 200 + + +def test_fold_legacy_theme_creates_new_file_when_none_exists(tmp_path: Path): + tomllib = pytest.importorskip('tomllib') + toml_path = _run_fold_legacy_theme(tmp_path, existing_toml=None, legacy_theme='dracula') + data = tomllib.loads(toml_path.read_text()) + assert data['appearance']['theme'] == 'dracula' + + def test_build_yas_toml_carries_four_values_and_parses(): """Task 10.5 — the generated yas.toml carries the four chosen values and is valid TOML (parsed with tomllib).""" diff --git a/test/test_layout_containment.py b/test/test_layout_containment.py new file mode 100644 index 0000000..1cc3d90 --- /dev/null +++ b/test/test_layout_containment.py @@ -0,0 +1,30 @@ +'''Guards the new-layout invariant: a render tick must never scatter files +directly into $CLAUDE_CONFIG_DIR beyond the yas/ subtree and yas.toml. +''' +import json +from pathlib import Path + +from yas.app import render + +_EXAMPLE = Path(__file__).resolve().parent.parent / 'ops' / 'session-info-example.json' + + +def _load_example() -> dict: + return json.loads(_EXAMPLE.read_text()) + + +def test_render_tick_only_touches_yas_and_yas_toml(tmp_home): + claude_dir = tmp_home / '.claude' + info = _load_example() + + result = render(info, 160) + assert isinstance(result, str) + assert len(result) > 0 + + # claude_dir may not even exist yet if the render wrote nothing to disk; + # that's still a trivially-satisfied containment. + if not claude_dir.exists(): + return + + entries = {p.name for p in claude_dir.iterdir()} + assert entries <= {'yas', 'yas.toml'}, f'stray entries directly under $CLAUDE_CONFIG_DIR: {entries}' diff --git a/test/test_migrate.py b/test/test_migrate.py new file mode 100644 index 0000000..53ab0ba --- /dev/null +++ b/test/test_migrate.py @@ -0,0 +1,226 @@ +'''Tests for the pre-0.9 -> yas/{cache,state} layout migration. + +Covers the full nine-path migration, idempotency, move-skips-existing-dest, +version.json shape, crash-resume, and the empty-config-dir case. +''' +import json +import time + +from yas.constants import ( + LAYOUT_SCHEMA_VERSION, VERSION, + cache_dir, runtime_dir, sessions_dir, signals_dir, state_dir, + version_file, +) +from yas.migrate import _DELETE_DIRS, _DELETE_FILES, _MOVES, migrate + + +def _seed_legacy(claude_dir): + '''Populate claude_dir with all nine legacy paths this module knows about.''' + claude_dir.mkdir(parents=True, exist_ok=True) + for name, _dst_fn in _MOVES: + (claude_dir / name).write_text(f'content-of-{name}') + for name in _DELETE_FILES: + (claude_dir / name).write_text(f'content-of-{name}') + for name in _DELETE_DIRS: + d = claude_dir / name + d.mkdir(parents=True, exist_ok=True) + (d / 'marker').write_text('dead weight') + + +def test_full_migration_moves_land_with_contents(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + + for name, dst_fn in _MOVES: + dst = dst_fn() + assert dst.exists(), f'{name} did not land at {dst}' + assert dst.read_text() == f'content-of-{name}' + assert not (claude_dir / name).exists() + + +def test_full_migration_deletes_are_gone(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + + for name in _DELETE_FILES: + assert not (claude_dir / name).exists() + for name in _DELETE_DIRS: + assert not (claude_dir / name).exists() + + +def test_full_migration_creates_six_dirs(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + + for d in (cache_dir(), state_dir(), + runtime_dir(), signals_dir(), sessions_dir()): + assert d.is_dir(), f'{d} was not created' + + +def test_migration_is_idempotent(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + assert migrate() is True # second run: nothing left to move/delete, still succeeds + + for name, dst_fn in _MOVES: + dst = dst_fn() + assert dst.exists() + assert dst.read_text() == f'content-of-{name}' + + +def test_move_skipped_when_destination_exists(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + # Pre-create every destination with sentinel content so a clobber is + # observable, then confirm the legacy source content never lands. + for name, dst_fn in _MOVES: + dst = dst_fn() + dst.parent.mkdir(parents=True, exist_ok=True) + dst.write_text(f'preexisting-{name}') + + assert migrate() is True # must not raise on an existing destination + + for name, dst_fn in _MOVES: + dst = dst_fn() + assert dst.read_text() == f'preexisting-{name}', 'destination was clobbered' + + +def test_version_file_shape(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + before = time.time() + assert migrate() is True + after = time.time() + + payload = json.loads(version_file().read_text()) + assert payload['schema_version'] == LAYOUT_SCHEMA_VERSION + assert payload['schema_version'] == 1 + assert payload['yas_version'] == VERSION + assert isinstance(payload['migrated_at'], (int, float)) + assert before <= payload['migrated_at'] <= after + + +def test_crash_resume(tmp_home): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + version_file().unlink() # simulate a crash between the writes and the version stamp + + assert migrate() is True + assert version_file().exists() + + for name, dst_fn in _MOVES: + dst = dst_fn() + assert dst.exists() + assert dst.read_text() == f'content-of-{name}' + for name in _DELETE_FILES: + assert not (claude_dir / name).exists() + for name in _DELETE_DIRS: + assert not (claude_dir / name).exists() + + +def test_statusline_theme_kept_when_toml_has_no_theme_line(tmp_home): + # yas.toml doesn't fold the legacy theme (missing entirely, or present + # but without a `theme =` line) — migrate() must not delete the only + # remaining copy of the user's theme choice. + claude_dir = tmp_home / '.claude' + claude_dir.mkdir(parents=True, exist_ok=True) + theme_file = claude_dir / 'statusline-theme' + theme_file.write_text('dracula') + + assert migrate() is True + assert theme_file.exists() + assert theme_file.read_text() == 'dracula' + + +def test_statusline_theme_kept_when_yas_toml_missing(tmp_home): + claude_dir = tmp_home / '.claude' + claude_dir.mkdir(parents=True, exist_ok=True) + theme_file = claude_dir / 'statusline-theme' + theme_file.write_text('dracula') + assert not (claude_dir / 'yas.toml').exists() + + assert migrate() is True + assert theme_file.exists() + + +def test_statusline_theme_deleted_once_folded_into_toml(tmp_home): + # ops/install.sh's fold_legacy_theme() writes the `theme =` line into + # yas.toml before migrate() runs — once it's there, the legacy file is + # safe to retire. + claude_dir = tmp_home / '.claude' + claude_dir.mkdir(parents=True, exist_ok=True) + theme_file = claude_dir / 'statusline-theme' + theme_file.write_text('dracula') + (claude_dir / 'yas.toml').write_text('[appearance]\ntheme = "dracula"\n') + + assert migrate() is True + assert not theme_file.exists() + + +def test_mkstemp_oserror_does_not_raise(tmp_home, monkeypatch): + # tempfile.mkstemp() itself can raise OSError (e.g. state_dir() vanishes, + # permissions); it must be caught by the same try/except as the rest of + # the version.json write, not raise out of migrate(). + import tempfile as tempfile_mod + import yas.migrate as migrate_mod + + def boom(*args, **kwargs): + raise OSError('simulated mkstemp failure') + + monkeypatch.setattr(tempfile_mod, 'mkstemp', boom) + monkeypatch.setattr(migrate_mod, 'tempfile', tempfile_mod) + + assert migrate() is False # version.json write failed, but no exception + + +def test_empty_config_dir_writes_marker_without_error(tmp_home): + # No legacy files at all — every move/delete is a no-op, but the dirs + # still get created and version.json still gets written. + assert migrate() is True + assert version_file().exists() + + for d in (cache_dir(), state_dir(), + runtime_dir(), signals_dir(), sessions_dir()): + assert d.is_dir() + + +def test_migrate_silent_by_default(tmp_home, capsys): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate() is True + assert capsys.readouterr().out == '' + + +def test_migrate_verbose_prints_each_move_and_delete(tmp_home, capsys): + claude_dir = tmp_home / '.claude' + _seed_legacy(claude_dir) + + assert migrate(verbose=True) is True + out = capsys.readouterr().out + + for name, _dst_fn in _MOVES: + assert f'moved {name} ->' in out + for name in _DELETE_FILES: + assert f'removed {name}' in out + for name in _DELETE_DIRS: + assert f'removed {name}' in out + + +def test_migrate_verbose_skips_noop_lines(tmp_home, capsys): + # No legacy files present — verbose mode must stay silent since nothing + # actually moved or was deleted. + assert migrate(verbose=True) is True + assert capsys.readouterr().out == '' diff --git a/test/test_mon_discovery.py b/test/test_mon_discovery.py index 550420d..7b4cec9 100644 --- a/test/test_mon_discovery.py +++ b/test/test_mon_discovery.py @@ -28,13 +28,14 @@ def _make_payloads_root(tmp_path: Path) -> Path: def _write_jsonl(projects_root: Path, project: str, session_id: str) -> Path: proj_dir = projects_root / project - proj_dir.mkdir(exist_ok=True) + proj_dir.mkdir(parents=True, exist_ok=True) p = proj_dir / f'{session_id}.jsonl' p.write_text('') return p def _write_payload(payloads_root: Path, name: str, data: dict) -> Path: + payloads_root.mkdir(parents=True, exist_ok=True) p = payloads_root / f'{name}.json' p.write_text(json.dumps(data)) return p @@ -228,36 +229,34 @@ def test_multiple_sessions_each_indexed(self, tmp_path: Path) -> None: class TestClaudeConfigDirRespected: - """Confirm that discovery functions derive default roots from CLAUDE_DIR. - - Default arguments are evaluated once at import time from the CLAUDE_DIR - constant. We cannot re-evaluate them at runtime, so these tests verify - the wiring statically (default == CLAUDE_DIR / subdir) and the live - end-to-end path by reloading the module after pointing CLAUDE_CONFIG_DIR - at a temp directory. + """Confirm that discovery functions derive default roots from CLAUDE_DIR + at call time via yas.constants.projects_dir()/sessions_dir(), rather than + a value frozen at import time (a `None`-sentinel default resolved in the + function body, per yas.constants' call-time-only helper contract). """ - def test_find_active_jsonls_default_projects_root_matches_claude_dir(self) -> None: - # The default argument for projects_root must be CLAUDE_DIR / 'projects', - # not a hardcoded Path.home() / '.claude' / 'projects'. - import inspect - import claude.mon.discovery as _disc + def test_find_active_jsonls_default_projects_root_matches_projects_dir( + self, tmp_home: Path, + ) -> None: + from yas.constants import projects_dir + now = datetime(2024, 1, 1, 12, 0, 0) + jsonl = _write_jsonl(projects_dir(), 'proj-a', 'sess-1') + _set_mtime(jsonl, now.timestamp() - 300) - sig = inspect.signature(_disc.find_active_jsonls) - default_projects_root = sig.parameters['projects_root'].default + result = find_active_jsonls(timedelta(minutes=10), now) - assert default_projects_root == _disc.CLAUDE_DIR / 'projects' + assert result == [(jsonl, now.timestamp() - 300)] - def test_index_payloads_default_payloads_root_matches_claude_dir(self) -> None: - # The default argument for payloads_root must be CLAUDE_DIR / 'statusline-output', - # not a hardcoded Path.home() / '.claude' / 'statusline-output'. - import inspect - import claude.mon.discovery as _disc + def test_index_payloads_default_payloads_root_matches_sessions_dir( + self, tmp_home: Path, + ) -> None: + from yas.constants import sessions_dir + data = {'session_id': 'sess-abc', 'cwd': '/home/user/project'} + pfile = _write_payload(sessions_dir(), 'sess-abc', data) - sig = inspect.signature(_disc.index_payloads_by_session) - default_payloads_root = sig.parameters['payloads_root'].default + result = index_payloads_by_session() - assert default_payloads_root == _disc.CLAUDE_DIR / 'statusline-output' + assert result['sess-abc'][0] == pfile class TestDiscover: diff --git a/test/test_prompt_hook.py b/test/test_prompt_hook.py index de5a7a6..21a18a8 100644 --- a/test/test_prompt_hook.py +++ b/test/test_prompt_hook.py @@ -16,6 +16,7 @@ import pytest +from yas.constants import last_prompt_path from yas.info.subagents import read_last_prompt_ts _INSTALL_SH = Path(__file__).resolve().parent.parent / 'ops' / 'install.sh' @@ -60,11 +61,13 @@ def _run_json_py(op: str, path: Path, *rest: str) -> str: _HOOK_SCRIPT = Path(__file__).resolve().parent.parent / 'hooks' / 'yas-prompt-hook.py' -def _run_hook_logic(session_id: str, state_file: Path) -> None: - '''Invoke the hook's core logic directly against a given state file path. +def _run_hook_logic(session_id: str, config_dir: Path) -> None: + '''Invoke the hook's core logic directly against a given CLAUDE_CONFIG_DIR. We import the hook module once (or reuse the cached import) and call main() - with stdin and CLAUDE_CONFIG_DIR patched to point at our temp directory. + with stdin and CLAUDE_CONFIG_DIR patched to point at our temp directory. The + hook itself derives its state file as + /yas/state/signals/last-prompt.json (see _state_path). ''' # Import the hook module (cache it so subsequent calls reuse it). mod_name = '_yas_prompt_hook' @@ -80,7 +83,7 @@ def _run_hook_logic(session_id: str, state_file: Path) -> None: payload = json.dumps({'session_id': session_id}) env_backup = os.environ.copy() try: - os.environ['CLAUDE_CONFIG_DIR'] = str(state_file.parent) + os.environ['CLAUDE_CONFIG_DIR'] = str(config_dir) old_stdin = sys.stdin sys.stdin = io.StringIO(payload) try: @@ -108,7 +111,7 @@ def test_missing_state_file_returns_none(tmp_home: Path) -> None: def test_invalid_json_returns_none(tmp_home: Path) -> None: '''Truncated/invalid JSON → None, no raise.''' - state = tmp_home / '.claude' / 'yas-last-prompt.json' + state = last_prompt_path() state.parent.mkdir(parents=True, exist_ok=True) state.write_text('{ "sess": 12345') # truncated JSON @@ -118,7 +121,7 @@ def test_invalid_json_returns_none(tmp_home: Path) -> None: def test_empty_file_returns_none(tmp_home: Path) -> None: '''Empty file → None, no raise.''' - state = tmp_home / '.claude' / 'yas-last-prompt.json' + state = last_prompt_path() state.parent.mkdir(parents=True, exist_ok=True) state.write_text('') @@ -128,7 +131,7 @@ def test_empty_file_returns_none(tmp_home: Path) -> None: def test_session_not_in_map_returns_none(tmp_home: Path) -> None: '''State file exists but session not in map → None.''' - state = tmp_home / '.claude' / 'yas-last-prompt.json' + state = last_prompt_path() state.parent.mkdir(parents=True, exist_ok=True) state.write_text(json.dumps({'other-session': 1234567890.0})) @@ -138,7 +141,7 @@ def test_session_not_in_map_returns_none(tmp_home: Path) -> None: def test_session_present_returns_float(tmp_home: Path) -> None: '''Session in map → correct float returned.''' - state = tmp_home / '.claude' / 'yas-last-prompt.json' + state = last_prompt_path() state.parent.mkdir(parents=True, exist_ok=True) ts = 1700000000.5 state.write_text(json.dumps({'my-session': ts})) @@ -149,7 +152,7 @@ def test_session_present_returns_float(tmp_home: Path) -> None: def test_non_dict_json_returns_none(tmp_home: Path) -> None: '''JSON that is not a dict (e.g. a list) → None.''' - state = tmp_home / '.claude' / 'yas-last-prompt.json' + state = last_prompt_path() state.parent.mkdir(parents=True, exist_ok=True) state.write_text(json.dumps([1, 2, 3])) @@ -163,9 +166,10 @@ def test_non_dict_json_returns_none(tmp_home: Path) -> None: def test_hook_writes_single_session(tmp_path: Path) -> None: '''Hook creates the state file and records a timestamp for the session.''' - state = tmp_path / 'yas-last-prompt.json' + state = tmp_path / 'yas' / 'state' / 'signals' / 'last-prompt.json' + state.parent.mkdir(parents=True, exist_ok=True) before = time.time() - _run_hook_logic('sess-a', state) + _run_hook_logic('sess-a', tmp_path) after = time.time() assert state.is_file() @@ -176,14 +180,15 @@ def test_hook_writes_single_session(tmp_path: Path) -> None: def test_hook_two_session_concurrent_write_preserves_both(tmp_path: Path) -> None: '''Two calls with different session IDs both persist in the state file.''' - state = tmp_path / 'yas-last-prompt.json' + state = tmp_path / 'yas' / 'state' / 'signals' / 'last-prompt.json' + state.parent.mkdir(parents=True, exist_ok=True) before_a = time.time() - _run_hook_logic('sess-alpha', state) + _run_hook_logic('sess-alpha', tmp_path) after_a = time.time() before_b = time.time() - _run_hook_logic('sess-beta', state) + _run_hook_logic('sess-beta', tmp_path) after_b = time.time() data = json.loads(state.read_text()) @@ -195,13 +200,14 @@ def test_hook_two_session_concurrent_write_preserves_both(tmp_path: Path) -> Non def test_hook_overwrites_same_session(tmp_path: Path) -> None: '''Calling the hook twice for the same session updates the timestamp.''' - state = tmp_path / 'yas-last-prompt.json' + state = tmp_path / 'yas' / 'state' / 'signals' / 'last-prompt.json' + state.parent.mkdir(parents=True, exist_ok=True) - _run_hook_logic('sess-x', state) + _run_hook_logic('sess-x', tmp_path) ts1 = json.loads(state.read_text())['sess-x'] time.sleep(0.01) # ensure clock advances - _run_hook_logic('sess-x', state) + _run_hook_logic('sess-x', tmp_path) ts2 = json.loads(state.read_text())['sess-x'] assert ts2 >= ts1 @@ -209,10 +215,11 @@ def test_hook_overwrites_same_session(tmp_path: Path) -> None: def test_hook_corrupt_file_recovers(tmp_path: Path) -> None: '''Hook tolerates corrupt existing file and writes fresh data.''' - state = tmp_path / 'yas-last-prompt.json' + state = tmp_path / 'yas' / 'state' / 'signals' / 'last-prompt.json' + state.parent.mkdir(parents=True, exist_ok=True) state.write_text('{bad json!!!') - _run_hook_logic('sess-recover', state) + _run_hook_logic('sess-recover', tmp_path) data = json.loads(state.read_text()) assert 'sess-recover' in data @@ -229,7 +236,8 @@ def test_hook_missing_session_id_does_not_crash(tmp_path: Path) -> None: sys.modules['_yas_prompt_hook'] = mod spec.loader.exec_module(mod) - state = tmp_path / 'yas-last-prompt.json' + state = tmp_path / 'yas' / 'state' / 'signals' / 'last-prompt.json' + state.parent.mkdir(parents=True, exist_ok=True) env_backup = os.environ.copy() try: os.environ['CLAUDE_CONFIG_DIR'] = str(tmp_path) diff --git a/test/test_prompt_hook_layout.py b/test/test_prompt_hook_layout.py new file mode 100644 index 0000000..a16108a --- /dev/null +++ b/test/test_prompt_hook_layout.py @@ -0,0 +1,71 @@ +'''Confirms hooks/yas-prompt-hook.py writes under the new yas/state/signals/ +layout, and that yas.info.subagents.read_last_prompt_ts reads the same file +back via yas.constants.last_prompt_path(). +''' +import importlib.util +import io +import json +import os +import sys +from pathlib import Path + +from yas.constants import last_prompt_path +from yas.info.subagents import read_last_prompt_ts + +_HOOK_SCRIPT = Path(__file__).resolve().parent.parent / 'hooks' / 'yas-prompt-hook.py' + + +def _load_hook_module(): + mod_name = '_yas_prompt_hook_layout' + if mod_name not in sys.modules: + spec = importlib.util.spec_from_file_location(mod_name, _HOOK_SCRIPT) + assert spec is not None and spec.loader is not None + mod = importlib.util.module_from_spec(spec) + sys.modules[mod_name] = mod + spec.loader.exec_module(mod) + return sys.modules[mod_name] + + +def _run_hook(session_id: str, config_dir: Path) -> None: + mod = _load_hook_module() + payload = json.dumps({'session_id': session_id}) + env_backup = os.environ.copy() + try: + os.environ['CLAUDE_CONFIG_DIR'] = str(config_dir) + old_stdin = sys.stdin + sys.stdin = io.StringIO(payload) + try: + mod.main() + finally: + sys.stdin = old_stdin + finally: + for k in list(os.environ.keys()): + if k not in env_backup: + del os.environ[k] + for k, v in env_backup.items(): + os.environ[k] = v + + +def test_hook_writes_new_layout_signals_path(tmp_home): + claude_dir = tmp_home / '.claude' + _run_hook('sess-layout', claude_dir) + + expected = claude_dir / 'yas' / 'state' / 'signals' / 'last-prompt.json' + assert expected.is_file() + data = json.loads(expected.read_text()) + assert 'sess-layout' in data + + +def test_read_last_prompt_ts_reads_new_layout_path_back(tmp_home): + claude_dir = tmp_home / '.claude' + _run_hook('sess-roundtrip', claude_dir) + + # last_prompt_path() reads yas.constants.CLAUDE_DIR (patched by tmp_home) + # and must resolve to the exact file the hook just wrote. + expected = claude_dir / 'yas' / 'state' / 'signals' / 'last-prompt.json' + assert last_prompt_path() == expected + + ts = read_last_prompt_ts('sess-roundtrip') + assert ts is not None + on_disk = json.loads(expected.read_text())['sess-roundtrip'] + assert ts == on_disk diff --git a/test/test_render_callable.py b/test/test_render_callable.py index 20d7326..ed95878 100644 --- a/test/test_render_callable.py +++ b/test/test_render_callable.py @@ -4,6 +4,7 @@ from pathlib import Path import yas.app as app +import yas.constants as constants _EXAMPLE = Path(__file__).resolve().parent.parent / 'ops' / 'session-info-example.json' _SCRIPT = Path(__file__).resolve().parent.parent / 'claude' / 'statusline_command.py' @@ -48,7 +49,7 @@ def test_yas_full_width_fills_terminal(tmp_path, monkeypatch, capsys): fake_tw = 200 # wider than DEFAULT_MAX_WIDTH so capping is observable monkeypatch.setattr(app, 'terminal_width', lambda: fake_tw) - monkeypatch.setattr(app, 'CLAUDE_DIR', tmp_path / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_path / '.claude') # Isolate from any YAS_* env vars set in the host shell (e.g. YAS_MAX_WIDTH=40). monkeypatch.delenv('YAS_MAX_WIDTH', raising=False) monkeypatch.delenv('YAS_FULL_WIDTH', raising=False) @@ -79,7 +80,7 @@ def _first_line_width(env_extra): def _run_main(info, tmp_path, monkeypatch, env_extra): import io monkeypatch.setattr(app, 'terminal_width', lambda: 200) - monkeypatch.setattr(app, 'CLAUDE_DIR', tmp_path / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_path / '.claude') for k in ('YAS_MAX_WIDTH', 'YAS_FULL_WIDTH', 'YAS_SHOW_RENDER_TIME'): monkeypatch.delenv(k, raising=False) for k, v in env_extra.items(): diff --git a/test/test_subagent_rows.py b/test/test_subagent_rows.py index 983db67..c387caf 100644 --- a/test/test_subagent_rows.py +++ b/test/test_subagent_rows.py @@ -11,6 +11,7 @@ import yas.info.subagents as subagents_mod from yas.config import Config +import yas.constants as constants from yas.constants import ( BOLD, BOX_H, @@ -1018,7 +1019,7 @@ def _make_tree_sub(agent_id: str, parent_id: str = '', ts_off: float = 0.0, **kw def test_meta_parent_extraction(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: # parentAgentId/spawnDepth in the meta.json land on the parsed subagent; # a meta without them falls back to top-level ('' / 0). - monkeypatch.setattr(subagents_mod, 'CLAUDE_DIR', tmp_path) + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_path) sub_dir = tmp_path / 'projects' / '-proj' / 'sess-1' / 'subagents' sub_dir.mkdir(parents=True) line = json.dumps({'type': 'assistant', 'timestamp': '2026-01-01T00:00:00Z', diff --git a/test/test_terminal_width.py b/test/test_terminal_width.py index 421a909..f8a8ad1 100644 --- a/test/test_terminal_width.py +++ b/test/test_terminal_width.py @@ -4,6 +4,7 @@ import subprocess from unittest.mock import MagicMock, patch +import yas.constants as constants from yas.render.text import terminal_width @@ -55,11 +56,12 @@ def test_tmux_timeout_falls_through_without_raising(monkeypatch, tmp_path): monkeypatch.setenv('TMUX_PANE', '%1') # Patch CLAUDE_DIR so the file fallback reads a known value. - width_file = tmp_path / 'terminal-width' - width_file.write_text('88\n') + with patch('yas.constants.CLAUDE_DIR', tmp_path): + width_file = constants.terminal_width_path() + width_file.parent.mkdir(parents=True, exist_ok=True) + width_file.write_text('88\n') - with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['tmux'], 0.2)): - with patch('yas.render.text.CLAUDE_DIR', tmp_path): + with patch('subprocess.run', side_effect=subprocess.TimeoutExpired(['tmux'], 0.2)): result = terminal_width() # Must not raise; should fall through to the file fallback. @@ -76,10 +78,11 @@ def test_columns_zero_falls_through(monkeypatch, tmp_path): # Make the tmux probe fail with KeyError (no TMUX_PANE) and the file # fallback return a value so we can confirm fall-through happened. - width_file = tmp_path / 'terminal-width' - width_file.write_text('77\n') + with patch('yas.constants.CLAUDE_DIR', tmp_path): + width_file = constants.terminal_width_path() + width_file.parent.mkdir(parents=True, exist_ok=True) + width_file.write_text('77\n') - with patch('yas.render.text.CLAUDE_DIR', tmp_path): result = terminal_width() assert result == 77 diff --git a/test/test_themes.py b/test/test_themes.py index b040ec5..f2b949f 100644 --- a/test/test_themes.py +++ b/test/test_themes.py @@ -103,13 +103,17 @@ def test_resolve_theme_env_beats_file( assert app.resolve_theme(None) is THEMES['dracula'] -def test_resolve_theme_file_used_when_no_env( +def test_resolve_theme_legacy_statusline_theme_file_is_ignored( monkeypatch: pytest.MonkeyPatch, tmp_home: Path, ) -> None: + # The legacy `statusline-theme` file source was retired along with + # config._legacy_theme_sources; precedence is now CLI -> env -> + # yas.toml [appearance].theme -> default, so a stray legacy file must + # be silently ignored rather than resolved. monkeypatch.delenv('CLAUDE_STATUSLINE_THEME', raising=False) (tmp_home / '.claude').mkdir(parents=True, exist_ok=True) (tmp_home / '.claude' / 'statusline-theme').write_text('claude-light') - assert app.resolve_theme(None) is THEMES['claude-light'] + assert app.resolve_theme(None) is CLAUDE_DARK def test_resolve_theme_unknown_name_falls_through( diff --git a/test/test_token_log.py b/test/test_token_log.py index 7c271d3..dde75c6 100644 --- a/test/test_token_log.py +++ b/test/test_token_log.py @@ -2,6 +2,7 @@ from pathlib import Path import yas.tokens as tokens +from yas.constants import tokens_log # Repoint symbols to the new module TokenLog = tokens.TokenLog @@ -12,7 +13,7 @@ def _log_path(tmp_home: Path) -> Path: - return tmp_home / '.claude' / 'statusline-tokens.log' + return tokens_log() def test_empty_log_first_write(tmp_home: Path) -> None: diff --git a/test/test_token_rate.py b/test/test_token_rate.py index 3e26553..81138d6 100644 --- a/test/test_token_rate.py +++ b/test/test_token_rate.py @@ -4,6 +4,7 @@ import pytest import yas.tokens as tokens +from yas.constants import token_rate_log NOW = 1_000_000.0 # fixed "now" for all tests @@ -19,7 +20,7 @@ def time() -> float: def _log_path(tmp_home: Path) -> Path: - return tmp_home / '.claude' / 'statusline-token-rate.log' + return token_rate_log() def _write_row(path: Path, ts: float, session_id: str, total_in: int, total_out: int) -> None: diff --git a/test/test_workspace_plugins.py b/test/test_workspace_plugins.py index 0c4477e..91a876c 100644 --- a/test/test_workspace_plugins.py +++ b/test/test_workspace_plugins.py @@ -2,6 +2,7 @@ import json from pathlib import Path +import yas.constants as constants import yas.session as session @@ -13,7 +14,7 @@ def _write_settings(path: Path, plugins: dict[str, bool]) -> None: def test_plugins_read_from_home_only(tmp_home: Path, tmp_path: Path, monkeypatch) -> None: """SEC-2: only the user's own settings drive plugins; project_dir's settings (attacker-controlled for a cloned repo) are never read.""" - monkeypatch.setattr(session, 'CLAUDE_DIR', tmp_home / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_home / '.claude') _write_settings(tmp_home / '.claude' / 'settings.json', {'foo@1.0': True}) project_dir = tmp_path / 'myproject' _write_settings(project_dir / '.claude' / 'settings.json', {'bar@2.0': True}) @@ -26,7 +27,7 @@ def test_plugins_read_from_home_only(tmp_home: Path, tmp_path: Path, monkeypatch def test_false_values_excluded(tmp_home: Path, monkeypatch) -> None: """False values are excluded from plugins.""" - monkeypatch.setattr(session, 'CLAUDE_DIR', tmp_home / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_home / '.claude') _write_settings(tmp_home / '.claude' / 'settings.json', {'foo@1.0': False}) ws = session.Workspace() @@ -36,7 +37,7 @@ def test_false_values_excluded(tmp_home: Path, monkeypatch) -> None: def test_duplicates_collapsed_first_seen_order(tmp_home: Path, tmp_path: Path, monkeypatch) -> None: """Duplicates collapsed; first-seen order preserved.""" - monkeypatch.setattr(session, 'CLAUDE_DIR', tmp_home / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_home / '.claude') # foo appears in both home (first) and project (second) _write_settings(tmp_home / '.claude' / 'settings.json', {'foo@1.0': True}) project_dir = tmp_path / 'proj' @@ -49,7 +50,7 @@ def test_duplicates_collapsed_first_seen_order(tmp_home: Path, tmp_path: Path, m def test_malformed_json_silently_skipped(tmp_home: Path, tmp_path: Path, monkeypatch) -> None: """Malformed JSON in home settings is silently skipped (no crash, empty result).""" - monkeypatch.setattr(session, 'CLAUDE_DIR', tmp_home / '.claude') + monkeypatch.setattr(constants, 'CLAUDE_DIR', tmp_home / '.claude') # Write invalid JSON to home settings home_settings = tmp_home / '.claude' / 'settings.json' home_settings.parent.mkdir(parents=True, exist_ok=True) diff --git a/uv.lock b/uv.lock index 2a0501d..0ef84b6 100644 --- a/uv.lock +++ b/uv.lock @@ -421,7 +421,7 @@ wheels = [ [[package]] name = "yet-another-statusline" -version = "0.7.1" +version = "0.8.0" source = { virtual = "." } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" },