diff --git a/DEVELOPER.md b/DEVELOPER.md index 49aef4a..a64c0fc 100644 --- a/DEVELOPER.md +++ b/DEVELOPER.md @@ -32,7 +32,7 @@ Tunables read by the plugin at runtime. Most users don't need to touch these — | `EMBEDDING_PORT` | `8072` | Local embedding daemon port. Shared by Claude Code and Codex on the same machine. | | `CLAUDE_SMART_ENABLE_OPTIMIZER` | enabled unless set to `0` | Hook-side env var that controls shared skill optimization and rollups during `SessionStart`. Set it in Claude Code settings, not `~/.reflexio/.env`. | | `CLAUDE_SMART_CITATIONS` | `on` | Controls the final `✨ claude-smart rule applied` marker instruction. `on` (default) injects a compact instruction asking the assistant to cite only memories that materially changed the answer. `off` skips injection of the citation instruction entirely and strips any stray marker line from the assistant text before publishing. Legacy values `auto` and `marker-only` are accepted as enabled aliases. Set in Claude Code settings (hook env), not `~/.reflexio/.env`. | -| `CLAUDE_SMART_CITATION_LINK_STYLE` | `markdown` | Controls the visible links in the final `✨ claude-smart rule applied` marker. `markdown` asks the model to emit `[human title](dashboard URL)` links. `osc8` asks it to emit terminal-native OSC 8 hyperlinks so the human title can be clickable without showing the URL; unsupported terminals should fall back to markdown links. | +| `CLAUDE_SMART_CITATION_LINK_STYLE` | `markdown` | Controls the visible links in the final `✨ claude-smart rule applied` marker. `markdown` asks the model to emit `[human title](dashboard URL)` links and is the Codex default because Codex renders markdown links in assistant messages. `osc8` asks it to emit terminal-native OSC 8 hyperlinks so the human title can be clickable without showing the URL; unsupported terminals should fall back to markdown links. | | `CLAUDE_SMART_DASHBOARD_URL` | `http://localhost:3001` | Base URL used in injected citation targets and the final `✨ claude-smart rule applied` marker links. Override only if the local dashboard is exposed on another host or port. Model-facing citation links use short `/rules/{citationId}` resolver URLs; canonical direct links remain `/skills/project/{id}`, `/skills/shared/{id}`, and `/preferences/project/{id}`. `/preferences/{id}` remains a compatibility alias. | | `CLAUDE_SMART_CLI_PATH` | `claude` on Claude Code; Codex compatibility wrapper or `codex` on Codex | Override the executable Reflexio calls for local generation. | | `CLAUDE_SMART_STATE_DIR` | `~/.claude-smart/sessions/` | Where the per-session JSONL buffer lives. | diff --git a/MANAGED_REFLEXIO.md b/MANAGED_REFLEXIO.md index da85524..9e90655 100644 --- a/MANAGED_REFLEXIO.md +++ b/MANAGED_REFLEXIO.md @@ -232,6 +232,12 @@ Open the dashboard the same way as local mode: - Claude Code: `/claude-smart:dashboard` - Codex: `bash ~/.reflexio/plugin-root/scripts/dashboard-open.sh` +When managed citations include stored Reflexio IDs, claude-smart links directly +to the managed Reflexio profile or playbook page, for example +`https://www.reflexio.ai/profiles?profile_id=...` or +`https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=...`. +Items without stored IDs fall back to the managed list page. + ## Troubleshooting If `npx claude-smart setup` prints `unknown command 'setup'`, the machine is diff --git a/plugin/scripts/codex-hook.js b/plugin/scripts/codex-hook.js index f28015e..68e5ecc 100644 --- a/plugin/scripts/codex-hook.js +++ b/plugin/scripts/codex-hook.js @@ -535,7 +535,7 @@ function runHook(root, event) { REFLEXIO_URL: readBackendUrl(), CLAUDE_SMART_HOST: "codex", CLAUDE_SMART_CLI_PATH: process.env.CLAUDE_SMART_CLI_PATH || codexCompatPath(root), - CLAUDE_SMART_CITATION_LINK_STYLE: process.env.CLAUDE_SMART_CITATION_LINK_STYLE || "osc8", + CLAUDE_SMART_CITATION_LINK_STYLE: process.env.CLAUDE_SMART_CITATION_LINK_STYLE || "markdown", }, input, stdio: ["pipe", "pipe", "inherit"], diff --git a/plugin/scripts/hook_entry.sh b/plugin/scripts/hook_entry.sh index b37ddad..a70a2f9 100755 --- a/plugin/scripts/hook_entry.sh +++ b/plugin/scripts/hook_entry.sh @@ -21,7 +21,7 @@ case "$EVENT" in esac export CLAUDE_SMART_HOST="$HOST" if [ "$HOST" = "codex" ] && [ -z "${CLAUDE_SMART_CITATION_LINK_STYLE:-}" ]; then - export CLAUDE_SMART_CITATION_LINK_STYLE="osc8" + export CLAUDE_SMART_CITATION_LINK_STYLE="markdown" fi HERE="$(cd "$(dirname "$0")" && pwd)" @@ -59,8 +59,9 @@ if [ -f "$FAILURE_MARKER" ]; then if [ -z "$stored_fp" ] || [ "$stored_fp" != "$current_fp" ]; then rm -f "$FAILURE_MARKER" else - if [ "$EVENT" = "session-start" ] && command -v python3 >/dev/null 2>&1; then - python3 - "$FAILURE_MARKER" <<'PY' + failure_py="$(claude_smart_resolve_python 2>/dev/null || true)" + if [ "$EVENT" = "session-start" ] && [ -n "$failure_py" ]; then + "$failure_py" - "$FAILURE_MARKER" <<'PY' import json, pathlib, sys first = pathlib.Path(sys.argv[1]).read_text().splitlines() msg = (first[0].strip() if first else "") or "unknown error" diff --git a/plugin/src/claude_smart/context_format.py b/plugin/src/claude_smart/context_format.py index 5678a7a..d901f98 100644 --- a/plugin/src/claude_smart/context_format.py +++ b/plugin/src/claude_smart/context_format.py @@ -238,6 +238,11 @@ def render_inline_compact_with_registry( item = f"{content} (title: {title}" if rule_url: item += f"; open: {rule_url}" + marker_parts.append( + _markdown_link( + rule_url, _strip_trailing_sentence_punctuation(title) + ) + ) item += ")" if entry.get("kind") == "profile": preference_parts.append(item) @@ -280,6 +285,17 @@ def _compact_citation_instruction(marker_parts: list[str] | None = None) -> str: "the same linked memory text; keep the link, but do not show the " "URL. Skip when unrelated." ) + if marker_parts: + marker = f"✨ claude-smart rule applied: {' | '.join(marker_parts)}" + separator_instruction = ( + " Separate multiple linked memories with the visible ` | ` separator." + if len(marker_parts) > 1 + else "" + ) + return _remoteize_citation_instruction( + f"If used, copy this final marker exactly with markdown links: " + f"`{marker}`.{separator_instruction} Skip when unrelated." + ) return _remoteize_citation_instruction( "Only if a listed [cs:...] item materially changes your answer, end " "with one final marker like `✨ claude-smart rule applied: " @@ -368,7 +384,7 @@ def _append_playbook_bullet( item_id = cs_cite.rank_id("playbook", rank, real_id) title = _title_from_content(content) dashboard_url = _dashboard_url("playbook", real_id, source_kind) - rule_url = _rule_url(item_id, "playbook") + rule_url = _rule_url(item_id, "playbook", real_id, source_kind) bullet = f"- [cs:{item_id}] {content}" if trigger: bullet += f" _(when: {trigger})_" @@ -407,7 +423,7 @@ def _format_profiles( item_id = cs_cite.rank_id("profile", rank, real_id) title = _title_from_content(content) dashboard_url = _dashboard_url("profile", real_id) - rule_url = _rule_url(item_id, "profile") + rule_url = _rule_url(item_id, "profile", real_id) bullet = f"- [cs:{item_id}] {content}" if rule_url: bullet += f" _(open: {rule_url})_" @@ -427,7 +443,7 @@ def _format_profiles( def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> str: - remote_url = _remote_reflexio_page_url(kind) + remote_url = _remote_reflexio_item_url(kind, real_id, source_kind) if remote_url: return remote_url if real_id is None: @@ -442,8 +458,10 @@ def _dashboard_url(kind: str, real_id: Any, source_kind: str | None = None) -> s return "" -def _rule_url(item_id: str, kind: str) -> str: - remote_url = _remote_reflexio_page_url(kind) +def _rule_url( + item_id: str, kind: str, real_id: Any = None, source_kind: str | None = None +) -> str: + remote_url = _remote_reflexio_item_url(kind, real_id, source_kind) if remote_url: return remote_url if not item_id: @@ -453,6 +471,27 @@ def _rule_url(item_id: str, kind: str) -> str: return f"{base}/rules/{encoded_id}" +def _remote_reflexio_item_url( + kind: str, real_id: Any, source_kind: str | None = None +) -> str: + origin = _remote_reflexio_origin() + if not origin: + return "" + if real_id is None: + return _remote_reflexio_page_url(kind) + encoded_id = quote(str(real_id), safe="") + if kind == "profile": + return f"{origin}/profiles?profile_id={encoded_id}" + if kind == "playbook": + if source_kind == "user_playbook": + return ( + f"{origin}/playbooks?resource=user_playbook&" + f"user_playbook_id={encoded_id}" + ) + return f"{origin}/playbooks?agent_playbook_id={encoded_id}" + return "" + + def _remote_reflexio_page_url(kind: str) -> str: origin = _remote_reflexio_origin() if not origin: @@ -504,6 +543,12 @@ def _osc8_link(url: str, label: str) -> str: return f"\x1b]8;;{url}\x1b\\{label}\x1b]8;;\x1b\\" +def _markdown_link(url: str, label: str) -> str: + safe_label = label.replace("[", "\\[").replace("]", "\\]") + safe_url = url.replace(")", "%29") + return f"[{safe_label}]({safe_url})" + + def _strip_trailing_sentence_punctuation(text: str) -> str: return text.rstrip().rstrip(".!?") diff --git a/plugin/src/claude_smart/cs_cite.py b/plugin/src/claude_smart/cs_cite.py index 16968d3..5aaf615 100644 --- a/plugin/src/claude_smart/cs_cite.py +++ b/plugin/src/claude_smart/cs_cite.py @@ -39,7 +39,7 @@ import re from typing import Any -from urllib.parse import unquote, urlparse +from urllib.parse import parse_qs, unquote, urlparse _FINGERPRINT_LEN = 4 @@ -287,6 +287,17 @@ def dashboard_url_token(url: str) -> str: parsed = urlparse(url) path = parsed.path if parsed.scheme else url.split("?", 1)[0].split("#", 1)[0] parts = [unquote(part) for part in path.strip("/").split("/") if part] + query = parse_qs(parsed.query) + if len(parts) == 1 and parts[0] == "profiles": + profile_ids = query.get("profile_id") or [] + return f"route:profile:profile:{profile_ids[0]}" if profile_ids else "" + if len(parts) == 1 and parts[0] == "playbooks": + user_playbook_ids = query.get("user_playbook_id") or [] + if query.get("resource") == ["user_playbook"] and user_playbook_ids: + return f"route:playbook:user_playbook:{user_playbook_ids[0]}" + agent_playbook_ids = query.get("agent_playbook_id") or [] + if agent_playbook_ids: + return f"route:playbook:agent_playbook:{agent_playbook_ids[0]}" if len(parts) == 3 and parts[0] == "skills" and parts[1] in {"project", "shared"}: source_kind = "user_playbook" if parts[1] == "project" else "agent_playbook" return f"route:playbook:{source_kind}:{parts[2]}" diff --git a/tests/test_context_format.py b/tests/test_context_format.py index 258cf3e..c57fcab 100644 --- a/tests/test_context_format.py +++ b/tests/test_context_format.py @@ -247,7 +247,7 @@ def test_render_inline_with_registry_can_inject_osc8_instruction(monkeypatch) -> def test_render_inline_compact_with_registry_is_one_logical_line( monkeypatch, ) -> None: - monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8") + monkeypatch.delenv("CLAUDE_SMART_CITATION_LINK_STYLE", raising=False) md, registry = context_format.render_inline_compact_with_registry( project_id="demo", user_playbooks=[ @@ -270,26 +270,47 @@ def test_render_inline_compact_with_registry_is_one_logical_line( assert "[cs:" not in md assert "claude-smart: using relevant memory. Skill:" in md assert "Preference:" in md - assert "\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\" in md assert "Run uv sync after pyproject edits" in md assert "Then run an import smoke test before committing" in md assert "Run uv sync after pyproject edits: Run uv sync" not in md - assert "title:" not in md - assert "\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\" in md + assert "title: Run uv sync after pyproject edits" in md + assert "open: http://localhost:3001/rules/s1-17" in md assert "prefers concise answers" in md assert "✨ claude-smart rule applied:" in md assert md.count("✨ claude-smart rule applied:") == 1 - assert "preserving its hidden OSC 8 terminal link" in md + assert "copy this final marker exactly with markdown links" in md assert ( "✨ claude-smart rule applied: " - "\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\" - "Run uv sync after pyproject edits" - "\x1b]8;;\x1b\\ | " - "\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\" - "prefers concise answers" - "\x1b]8;;\x1b\\" + "[Run uv sync after pyproject edits](http://localhost:3001/rules/s1-17) | " + "[prefers concise answers](http://localhost:3001/rules/p1-pref)" ) in md assert "visible ` | ` separator" in md + assert "\x1b]8;;" not in md + assert {entry["id"] for entry in registry} == {"s1-17", "p1-pref"} + + +def test_render_inline_compact_with_registry_can_emit_osc8_when_requested( + monkeypatch, +) -> None: + monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8") + md, registry = context_format.render_inline_compact_with_registry( + project_id="demo", + user_playbooks=[ + { + "content": ( + "Run uv sync after pyproject edits. " + "Then run an import smoke test before committing." + ), + "user_playbook_id": 17, + } + ], + agent_playbooks=[], + profiles=[{"content": "prefers concise answers", "profile_id": "pref"}], + ) + + assert "\x1b]8;;http://localhost:3001/rules/s1-17\x1b\\" in md + assert "\x1b]8;;http://localhost:3001/rules/p1-pref\x1b\\" in md + assert "preserving its hidden OSC 8 terminal link" in md assert "open: http://localhost:3001/rules/s1-17" not in md assert {entry["id"] for entry in registry} == {"s1-17", "p1-pref"} @@ -333,7 +354,7 @@ def test_render_inline_with_registry_includes_dashboard_urls(monkeypatch) -> Non assert by_id["p1-pref"]["rule_url"] == "http://127.0.0.1:3333/rules/p1-pref" -def test_render_inline_with_registry_uses_remote_reflexio_list_pages( +def test_render_inline_with_registry_uses_remote_reflexio_item_pages( monkeypatch, ) -> None: monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/") @@ -344,19 +365,64 @@ def test_render_inline_with_registry_uses_remote_reflexio_list_pages( profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}], ) - assert "open: https://www.reflexio.ai/playbooks" in md - assert "open: https://www.reflexio.ai/profiles" in md + assert "open: https://www.reflexio.ai/playbooks?agent_playbook_id=42" in md + assert ( + "open: https://www.reflexio.ai/playbooks?" + "resource=user_playbook&user_playbook_id=17" + ) in md + assert "open: https://www.reflexio.ai/profiles?profile_id=pref%2Fone" in md assert "/rules/" not in md by_id = {e["id"]: e for e in registry} - assert by_id["s1-42"]["dashboard_url"] == "https://www.reflexio.ai/playbooks" - assert by_id["s1-42"]["rule_url"] == "https://www.reflexio.ai/playbooks" - assert by_id["s2-17"]["dashboard_url"] == "https://www.reflexio.ai/playbooks" - assert by_id["s2-17"]["rule_url"] == "https://www.reflexio.ai/playbooks" - assert by_id["p1-pref"]["dashboard_url"] == "https://www.reflexio.ai/profiles" - assert by_id["p1-pref"]["rule_url"] == "https://www.reflexio.ai/profiles" + assert ( + by_id["s1-42"]["dashboard_url"] + == "https://www.reflexio.ai/playbooks?agent_playbook_id=42" + ) + assert ( + by_id["s1-42"]["rule_url"] + == "https://www.reflexio.ai/playbooks?agent_playbook_id=42" + ) + assert ( + by_id["s2-17"]["dashboard_url"] + == "https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=17" + ) + assert ( + by_id["s2-17"]["rule_url"] + == "https://www.reflexio.ai/playbooks?resource=user_playbook&user_playbook_id=17" + ) + assert ( + by_id["p1-pref"]["dashboard_url"] + == "https://www.reflexio.ai/profiles?profile_id=pref%2Fone" + ) + assert ( + by_id["p1-pref"]["rule_url"] + == "https://www.reflexio.ai/profiles?profile_id=pref%2Fone" + ) -def test_render_inline_osc8_uses_remote_reflexio_list_pages(monkeypatch) -> None: +def test_render_inline_compact_uses_remote_reflexio_item_pages(monkeypatch) -> None: + monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/") + + md, _ = context_format.render_inline_compact_with_registry( + project_id="demo", + user_playbooks=[{"content": "use safe git flow", "user_playbook_id": 17}], + agent_playbooks=[{"content": "use shared flow", "agent_playbook_id": 42}], + profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}], + ) + + assert ( + "[use shared flow](https://www.reflexio.ai/playbooks?agent_playbook_id=42)" + ) in md + assert ( + "[use safe git flow](https://www.reflexio.ai/playbooks?" + "resource=user_playbook&user_playbook_id=17)" + ) in md + assert ( + "[prefers concise answers](https://www.reflexio.ai/profiles?profile_id=pref%2Fone)" + ) in md + assert "http://localhost:3001/rules/" not in md + + +def test_render_inline_osc8_uses_remote_reflexio_item_pages(monkeypatch) -> None: monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/") monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8") @@ -367,12 +433,33 @@ def test_render_inline_osc8_uses_remote_reflexio_list_pages(monkeypatch) -> None profiles=[{"content": "prefers concise answers", "profile_id": "pref/one"}], ) - assert "\x1b]8;;https://www.reflexio.ai/playbooks\x1b\\" in md - assert "\x1b]8;;https://www.reflexio.ai/profiles\x1b\\" in md + assert ( + "\x1b]8;;https://www.reflexio.ai/playbooks?" + "resource=user_playbook&user_playbook_id=17\x1b\\" + ) in md + assert "\x1b]8;;https://www.reflexio.ai/profiles?profile_id=pref%2Fone\x1b\\" in md assert "http://localhost:3001/rules/s1-17" not in md assert "http://localhost:3001/rules/p1-pref" not in md +def test_render_inline_with_registry_remote_items_without_real_ids_use_list_pages( + monkeypatch, +) -> None: + monkeypatch.setenv("REFLEXIO_URL", "https://www.reflexio.ai/") + md, registry = context_format.render_inline_with_registry( + project_id="demo", + user_playbooks=[{"content": "use safe git flow"}], + agent_playbooks=[], + profiles=[{"content": "prefers concise answers"}], + ) + + assert "open: https://www.reflexio.ai/playbooks" in md + assert "open: https://www.reflexio.ai/profiles" in md + by_id = {e["id"]: e for e in registry} + assert by_id["s1"]["rule_url"] == "https://www.reflexio.ai/playbooks" + assert by_id["p1"]["rule_url"] == "https://www.reflexio.ai/profiles" + + def test_render_inline_with_registry_off_omits_instruction(monkeypatch) -> None: monkeypatch.setenv("CLAUDE_SMART_CITATIONS", "off") md, _ = context_format.render_inline_with_registry( diff --git a/tests/test_cs_cite.py b/tests/test_cs_cite.py index 883ec82..22075d6 100644 --- a/tests/test_cs_cite.py +++ b/tests/test_cs_cite.py @@ -71,6 +71,22 @@ def test_parse_text_citations_accepts_direct_dashboard_links() -> None: ] +def test_parse_text_citations_accepts_managed_reflexio_item_links() -> None: + text = ( + "Done.\n\n" + "✨ claude-smart rules applied: " + "[shared skill](https://www.reflexio.ai/playbooks?agent_playbook_id=42), " + "[project skill](https://www.reflexio.ai/playbooks?" + "resource=user_playbook&user_playbook_id=7536), " + "[brief answers](https://www.reflexio.ai/profiles?profile_id=pref%2Fone)" + ) + assert cs_cite.parse_text_citations(text) == [ + "route:playbook:agent_playbook:42", + "route:playbook:user_playbook:7536", + "route:profile:profile:pref/one", + ] + + def test_parse_text_citations_keeps_old_applied_marker_compatible() -> None: text = "Done.\n\n✨ Applied: [git safety](http://localhost:3001/skills/project/7536)" assert cs_cite.parse_text_citations(text) == ["route:playbook:user_playbook:7536"] diff --git a/tests/test_events.py b/tests/test_events.py index f356540..a4a093c 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -1027,7 +1027,7 @@ def test_user_prompt_injects_compact_context_for_codex( ) -> None: """Codex receives a one-line context because the TUI displays it.""" monkeypatch.setenv("CLAUDE_SMART_HOST", "codex") - monkeypatch.setenv("CLAUDE_SMART_CITATION_LINK_STYLE", "osc8") + monkeypatch.delenv("CLAUDE_SMART_CITATION_LINK_STYLE", raising=False) _stub_user_prompt_adapter( monkeypatch, playbooks=[ @@ -1060,10 +1060,14 @@ def test_user_prompt_injects_compact_context_for_codex( assert "Then run an import smoke test before committing" in markdown assert "Run uv sync after pyproject edits: Run uv sync" not in markdown assert "prefers anyio over asyncio" in markdown - assert "\x1b]8;;http://localhost:3001/rules/s1\x1b\\" in markdown assert "✨ claude-smart rule applied:" in markdown - assert "preserving its hidden OSC 8 terminal link" in markdown - assert "open: http://localhost:3001/rules/s1" not in markdown + assert "copy this final marker exactly with markdown links" in markdown + assert ( + "✨ claude-smart rule applied: " + "[Run uv sync after pyproject edits](http://localhost:3001/rules/s1) | " + "[prefers anyio over asyncio](http://localhost:3001/rules/p1)" + ) in markdown + assert "\x1b]8;;" not in markdown def test_user_prompt_writes_nothing_when_search_empty(session_dir, monkeypatch) -> None: diff --git a/tests/test_install_scripts.py b/tests/test_install_scripts.py index bc8fd04..d4fe89f 100644 --- a/tests/test_install_scripts.py +++ b/tests/test_install_scripts.py @@ -879,7 +879,7 @@ def test_setup_local_dev_refreshes_claude_code_local_plugin() -> None: assert "write_local_reflexio_uv_source" in script assert "[tool.uv.sources]" in script assert ( - 'reflexio-ai = {{ path = {json.dumps(reflexio_path)}, editable = true }}' + "reflexio-ai = {{ path = {json.dumps(reflexio_path)}, editable = true }}" in script ) assert ( @@ -902,8 +902,7 @@ def test_setup_local_dev_prefers_workspace_reflexio_checkout() -> None: assert "expand_user_path()" in script assert 'reflexio_env_path="$(expand_user_path "$REFLEXIO_PATH")"' in script assert ( - 'reflexio_env_path="$(expand_user_path ' - '"$CLAUDE_SMART_LOCAL_REFLEXIO_PATH")"' + 'reflexio_env_path="$(expand_user_path "$CLAUDE_SMART_LOCAL_REFLEXIO_PATH")"' ) in script assert 'sibling_reflexio="$REPO_ROOT/../reflexio"' in script assert 'bundled_reflexio="$REPO_ROOT/reflexio"' not in script @@ -923,9 +922,9 @@ def test_use_local_reflexio_installs_into_plugin_venv() -> None: assert 'REFLEXIO_PATH="${REFLEXIO_PATH:-$REPO_ROOT/../reflexio}"' in script assert 'REFLEXIO_PATH="$(expand_user_path "$REFLEXIO_PATH")"' in script assert 'uv sync --project "$PLUGIN_ROOT"' in script - assert 'resolve_venv_python()' in script + assert "resolve_venv_python()" in script assert 'if ! PLUGIN_PYTHON="$(resolve_venv_python "$PLUGIN_ROOT")"; then' in script - assert '$venv_root/Scripts/python.exe' in script + assert "$venv_root/Scripts/python.exe" in script assert ( 'uv pip install --project "$PLUGIN_ROOT" --python "$PLUGIN_PYTHON" ' '-e "$REFLEXIO_PATH"' @@ -943,9 +942,9 @@ def test_reflexio_release_sync_has_strict_release_checks() -> None: release_script = RELEASE_WITH_REFLEXIO.read_text() assert "--release-checks" in sync_script - assert "fetch\", \"origin\", \"main\", \"--tags" in sync_script - assert "rev-parse\", \"origin/main" in sync_script - assert "tag\", \"--points-at\", \"HEAD" in sync_script + assert 'fetch", "origin", "main", "--tags' in sync_script + assert 'rev-parse", "origin/main' in sync_script + assert 'tag", "--points-at", "HEAD' in sync_script assert "v{version}" in sync_script assert "PyPI reflexio-ai dependency" in sync_script assert '"source": "pypi"' in sync_script @@ -965,14 +964,13 @@ def test_reflexio_vendor_release_uses_generated_bundle() -> None: gitignore = (REPO_ROOT / ".gitignore").read_text() assert "plugin/vendor/reflexio" in vendor_script - assert "git\", \"-C\", str(reflexio_path), \"archive\"" in vendor_script + assert 'git", "-C", str(reflexio_path), "archive"' in vendor_script assert '"source": "vendor"' in vendor_script assert '"vendor_path": str(VENDOR_PATH)' in vendor_script assert "package_include_paths" in vendor_script assert "only-include" in vendor_script assert ( - 'REFLEXIO_RELEASE_SOURCE="${REFLEXIO_RELEASE_SOURCE:-vendor}"' - in release_script + 'REFLEXIO_RELEASE_SOURCE="${REFLEXIO_RELEASE_SOURCE:-vendor}"' in release_script ) assert 'PYTHON_BIN="${PYTHON:-python3}"' in release_script assert '"$PYTHON_BIN" scripts/vendor-reflexio.py' in release_script @@ -1379,7 +1377,79 @@ def test_hook_entry_skips_cleanly_when_cached_package_missing(tmp_path: Path) -> assert "uv should not execute" not in result.stderr -def test_hook_entry_defaults_codex_citation_links_to_osc8(tmp_path: Path) -> None: +def test_hook_entry_failure_marker_uses_resolved_python_on_windows( + tmp_path: Path, +) -> None: + plugin_root = tmp_path / "plugin" + scripts = plugin_root / "scripts" + bin_dir = tmp_path / "bin" + scripts.mkdir(parents=True) + bin_dir.mkdir() + shutil.copy2(LIB, scripts / "_lib.sh") + shutil.copy2(HOOK_ENTRY, scripts / "hook_entry.sh") + (plugin_root / "pyproject.toml").write_text("[project]\nname='claude-smart'\n") + (plugin_root / "uv.lock").write_text("") + (bin_dir / "uname").write_text("#!/bin/sh\nprintf 'MINGW64_NT-10.0\\n'\n") + (bin_dir / "python3").write_text( + "#!/bin/sh\n" + "printf 'python3 stub should not be used\\n' >&2\n" + 'touch "$HOME/python3-called"\n' + "exit 99\n" + ) + (bin_dir / "python").write_text( + "#!/bin/sh\n" + "cat >/dev/null\n" + "printf '%s\\n' " + '\'{"hookSpecificOutput":{"hookEventName":"SessionStart",' + '"additionalContext":"> **claude-smart is not installed correctly:** ' + "cached env is broken\"}}}'\n" + ) + for executable in [ + scripts / "hook_entry.sh", + bin_dir / "uname", + bin_dir / "python", + bin_dir / "python3", + ]: + executable.chmod(executable.stat().st_mode | stat.S_IXUSR) + + env = _isolated_env(tmp_path) + env["PATH"] = f"{bin_dir}{os.pathsep}{env['PATH']}" + fingerprint = subprocess.run( + [ + "/bin/bash", + "--noprofile", + "--norc", + "-c", + ( + f'. "{scripts / "_lib.sh"}"; ' + f'claude_smart_install_fingerprint_hash "{plugin_root}" "{scripts}"' + ), + ], + env=env, + text=True, + capture_output=True, + check=True, + ).stdout.strip() + marker_dir = tmp_path / ".claude-smart" + marker_dir.mkdir() + (marker_dir / "install-failed").write_text( + f"cached env is broken\nfingerprint={fingerprint}\n" + ) + + result = subprocess.run( + [str(scripts / "hook_entry.sh"), "claude-code", "session-start"], + env=env, + text=True, + capture_output=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "claude-smart is not installed correctly" in result.stdout + assert not (tmp_path / "python3-called").exists() + + +def test_hook_entry_defaults_codex_citation_links_to_markdown(tmp_path: Path) -> None: bin_dir = tmp_path / "bin" bin_dir.mkdir() uv = bin_dir / "uv" @@ -1405,9 +1475,9 @@ def test_hook_entry_defaults_codex_citation_links_to_osc8(tmp_path: Path) -> Non assert result.returncode == 0, result.stderr parsed = json.loads(result.stdout) - assert parsed["hookSpecificOutput"]["additionalContext"] == "osc8" + assert parsed["hookSpecificOutput"]["additionalContext"] == "markdown" - env["CLAUDE_SMART_CITATION_LINK_STYLE"] = "markdown" + env["CLAUDE_SMART_CITATION_LINK_STYLE"] = "osc8" result = subprocess.run( [str(HOOK_ENTRY), "codex", "user-prompt"], env=env, @@ -1419,7 +1489,7 @@ def test_hook_entry_defaults_codex_citation_links_to_osc8(tmp_path: Path) -> Non assert result.returncode == 0, result.stderr parsed = json.loads(result.stdout) - assert parsed["hookSpecificOutput"]["additionalContext"] == "markdown" + assert parsed["hookSpecificOutput"]["additionalContext"] == "osc8" def test_node_installer_platform_preflight_messages() -> None: @@ -1597,20 +1667,17 @@ def test_node_installer_bootstraps_runtime_with_private_node_and_uv( {"command": 'bash "$_R/scripts/smart-install.sh"'}, { "command": ( - 'bash "$_R/scripts/ensure-plugin-root.sh" ' - '"$_R"' + 'bash "$_R/scripts/ensure-plugin-root.sh" "$_R"' ) }, { "command": ( - 'bash "$_R/scripts/backend-service.sh" ' - "start" + 'bash "$_R/scripts/backend-service.sh" start' ) }, { "command": ( - 'bash "$_R/scripts/dashboard-service.sh" ' - "start" + 'bash "$_R/scripts/dashboard-service.sh" start' ) }, { @@ -2145,7 +2212,7 @@ def test_codex_hook_normalizer_removes_suppress_output_for_hooks( assert json.loads(result.stdout) == {"continue": True} -def test_codex_hook_defaults_citation_links_to_osc8(tmp_path: Path) -> None: +def test_codex_hook_defaults_citation_links_to_markdown(tmp_path: Path) -> None: node = shutil.which("node") if not node: pytest.skip("node is required for codex hook wrapper tests") @@ -2176,7 +2243,7 @@ def test_codex_hook_defaults_citation_links_to_osc8(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr parsed = json.loads(result.stdout) - assert parsed["hookSpecificOutput"]["additionalContext"] == "osc8" + assert parsed["hookSpecificOutput"]["additionalContext"] == "markdown" assert parsed["continue"] is True