claude-smart 0.2.23 → 0.2.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/.agents/plugins/marketplace.json +20 -0
  2. package/README.md +76 -28
  3. package/bin/claude-smart.js +355 -11
  4. package/package.json +11 -1
  5. package/plugin/.claude-plugin/plugin.json +17 -0
  6. package/plugin/.codex-plugin/plugin.json +35 -0
  7. package/plugin/LICENSE +202 -0
  8. package/plugin/README.md +37 -0
  9. package/plugin/bin/cs-cite +77 -0
  10. package/plugin/commands/clear-all.md +8 -0
  11. package/plugin/commands/dashboard.md +8 -0
  12. package/plugin/commands/learn.md +12 -0
  13. package/plugin/commands/restart.md +8 -0
  14. package/plugin/commands/show.md +8 -0
  15. package/plugin/dashboard/AGENTS.md +6 -0
  16. package/plugin/dashboard/app/api/claude-settings/route.ts +19 -0
  17. package/plugin/dashboard/app/api/config/route.ts +16 -0
  18. package/plugin/dashboard/app/api/health/route.ts +10 -0
  19. package/plugin/dashboard/app/api/reflexio/[...path]/route.ts +63 -0
  20. package/plugin/dashboard/app/api/sessions/[id]/route.ts +28 -0
  21. package/plugin/dashboard/app/api/sessions/route.ts +14 -0
  22. package/plugin/dashboard/app/configure/env/page.tsx +318 -0
  23. package/plugin/dashboard/app/configure/layout.tsx +47 -0
  24. package/plugin/dashboard/app/configure/page.tsx +5 -0
  25. package/plugin/dashboard/app/configure/server/page.tsx +258 -0
  26. package/plugin/dashboard/app/dashboard/page.tsx +227 -0
  27. package/plugin/dashboard/app/globals.css +129 -0
  28. package/plugin/dashboard/app/icon.png +0 -0
  29. package/plugin/dashboard/app/layout.tsx +40 -0
  30. package/plugin/dashboard/app/page.tsx +5 -0
  31. package/plugin/dashboard/app/preferences/[id]/page.tsx +531 -0
  32. package/plugin/dashboard/app/preferences/page.tsx +126 -0
  33. package/plugin/dashboard/app/providers.tsx +12 -0
  34. package/plugin/dashboard/app/sessions/[sessionId]/page.tsx +321 -0
  35. package/plugin/dashboard/app/sessions/page.tsx +186 -0
  36. package/plugin/dashboard/app/skills/page.tsx +362 -0
  37. package/plugin/dashboard/app/skills/project/[id]/page.tsx +597 -0
  38. package/plugin/dashboard/app/skills/shared/[id]/page.tsx +830 -0
  39. package/plugin/dashboard/components/common/delete-all-button.tsx +45 -0
  40. package/plugin/dashboard/components/common/empty-state.tsx +34 -0
  41. package/plugin/dashboard/components/common/learnings-badge.tsx +34 -0
  42. package/plugin/dashboard/components/common/page-header.tsx +34 -0
  43. package/plugin/dashboard/components/common/page-tabs.tsx +115 -0
  44. package/plugin/dashboard/components/common/stat-card.tsx +38 -0
  45. package/plugin/dashboard/components/layout/nav-items.ts +22 -0
  46. package/plugin/dashboard/components/layout/sidebar.tsx +45 -0
  47. package/plugin/dashboard/components/layout/top-bar.tsx +64 -0
  48. package/plugin/dashboard/components/stall-banner.tsx +53 -0
  49. package/plugin/dashboard/components/ui/badge.tsx +52 -0
  50. package/plugin/dashboard/components/ui/button.tsx +60 -0
  51. package/plugin/dashboard/components/ui/collapsible.tsx +21 -0
  52. package/plugin/dashboard/components/ui/input.tsx +20 -0
  53. package/plugin/dashboard/components/ui/label.tsx +20 -0
  54. package/plugin/dashboard/components/ui/scroll-area.tsx +55 -0
  55. package/plugin/dashboard/components/ui/select.tsx +201 -0
  56. package/plugin/dashboard/components/ui/separator.tsx +25 -0
  57. package/plugin/dashboard/components/ui/sheet.tsx +135 -0
  58. package/plugin/dashboard/components/ui/switch.tsx +32 -0
  59. package/plugin/dashboard/components.json +25 -0
  60. package/plugin/dashboard/eslint.config.mjs +16 -0
  61. package/plugin/dashboard/hooks/use-settings.tsx +88 -0
  62. package/plugin/dashboard/hooks/use-stall-state.ts +59 -0
  63. package/plugin/dashboard/lib/claude-settings-file.ts +114 -0
  64. package/plugin/dashboard/lib/config-file.ts +131 -0
  65. package/plugin/dashboard/lib/format.ts +58 -0
  66. package/plugin/dashboard/lib/reflexio-client.ts +238 -0
  67. package/plugin/dashboard/lib/reflexio-url.ts +17 -0
  68. package/plugin/dashboard/lib/session-reader.ts +245 -0
  69. package/plugin/dashboard/lib/status.ts +24 -0
  70. package/plugin/dashboard/lib/types.ts +145 -0
  71. package/plugin/dashboard/lib/utils.ts +6 -0
  72. package/plugin/dashboard/next.config.ts +7 -0
  73. package/plugin/dashboard/package-lock.json +10275 -0
  74. package/plugin/dashboard/package.json +37 -0
  75. package/plugin/dashboard/postcss.config.mjs +7 -0
  76. package/plugin/dashboard/public/claude-smart-icon.png +0 -0
  77. package/plugin/dashboard/tsconfig.json +34 -0
  78. package/plugin/hooks/codex-hooks.json +67 -0
  79. package/plugin/hooks/hooks.json +111 -0
  80. package/plugin/pyproject.toml +49 -0
  81. package/plugin/scripts/_codex_env.sh +27 -0
  82. package/plugin/scripts/_lib.sh +325 -0
  83. package/plugin/scripts/backend-service.sh +208 -0
  84. package/plugin/scripts/cli.sh +40 -0
  85. package/plugin/scripts/dashboard-build.sh +139 -0
  86. package/plugin/scripts/dashboard-open.sh +107 -0
  87. package/plugin/scripts/dashboard-service.sh +195 -0
  88. package/plugin/scripts/ensure-plugin-root.sh +84 -0
  89. package/plugin/scripts/hook_entry.sh +70 -0
  90. package/plugin/scripts/smart-install.sh +411 -0
  91. package/plugin/src/claude_smart/__init__.py +3 -0
  92. package/plugin/src/claude_smart/cli.py +1342 -0
  93. package/plugin/src/claude_smart/context_format.py +277 -0
  94. package/plugin/src/claude_smart/context_inject.py +92 -0
  95. package/plugin/src/claude_smart/cs_cite.py +236 -0
  96. package/plugin/src/claude_smart/events/__init__.py +1 -0
  97. package/plugin/src/claude_smart/events/post_tool.py +148 -0
  98. package/plugin/src/claude_smart/events/pre_tool.py +52 -0
  99. package/plugin/src/claude_smart/events/session_end.py +20 -0
  100. package/plugin/src/claude_smart/events/session_start.py +119 -0
  101. package/plugin/src/claude_smart/events/stop.py +393 -0
  102. package/plugin/src/claude_smart/events/user_prompt.py +73 -0
  103. package/plugin/src/claude_smart/hook.py +114 -0
  104. package/plugin/src/claude_smart/ids.py +56 -0
  105. package/plugin/src/claude_smart/internal_call.py +89 -0
  106. package/plugin/src/claude_smart/optimizer_assistant.py +203 -0
  107. package/plugin/src/claude_smart/publish.py +71 -0
  108. package/plugin/src/claude_smart/query_compose.py +51 -0
  109. package/plugin/src/claude_smart/reflexio_adapter.py +403 -0
  110. package/plugin/src/claude_smart/runtime.py +52 -0
  111. package/plugin/src/claude_smart/stall_banner.py +61 -0
  112. package/plugin/src/claude_smart/state.py +276 -0
  113. package/plugin/uv.lock +3720 -0
@@ -0,0 +1,277 @@
1
+ """Render reflexio preferences + skills as markdown for display or injection."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, Iterable
6
+
7
+ from claude_smart import cs_cite
8
+
9
+
10
+ def _first_nonempty(*values: Any) -> str:
11
+ """Return the first truthy string value, or an empty string."""
12
+ for v in values:
13
+ if isinstance(v, str) and v.strip():
14
+ return v.strip()
15
+ return ""
16
+
17
+
18
+ def render(
19
+ *,
20
+ project_id: str,
21
+ user_playbooks: Iterable[Any],
22
+ agent_playbooks: Iterable[Any],
23
+ profiles: Iterable[Any],
24
+ ) -> str:
25
+ """Render skills + preferences as full audit markdown.
26
+
27
+ Empty sections are omitted. When all sections are empty, returns "".
28
+
29
+ Args:
30
+ project_id (str): Displayed in the header so the user can tell
31
+ which project is in effect.
32
+ user_playbooks (Iterable[Any]): Skill records scoped to this project
33
+ (``UserPlaybook`` objects or dicts with the same fields).
34
+ agent_playbooks (Iterable[Any]): Skill records shared across projects
35
+ (``AgentPlaybook`` objects or dicts with the same fields).
36
+ profiles (Iterable[Any]): Iterable of preference records
37
+ (``UserProfile`` objects or dicts).
38
+
39
+ Returns:
40
+ str: Markdown, or "" when there is nothing to show.
41
+ """
42
+ markdown, _ = render_with_registry(
43
+ project_id=project_id,
44
+ user_playbooks=user_playbooks,
45
+ agent_playbooks=agent_playbooks,
46
+ profiles=profiles,
47
+ )
48
+ return markdown
49
+
50
+
51
+ def render_with_registry(
52
+ *,
53
+ project_id: str,
54
+ user_playbooks: Iterable[Any],
55
+ agent_playbooks: Iterable[Any],
56
+ profiles: Iterable[Any],
57
+ ) -> tuple[str, list[dict[str, Any]]]:
58
+ """Variant of ``render`` that also returns the citation registry.
59
+
60
+ Every skill and preference bullet is tagged with a short ``[cs:ID]``
61
+ prefix. The registry maps those ids back to ``{id, kind, title,
62
+ content}`` entries so ``events.stop`` can resolve citations into
63
+ human-readable titles for the dashboard.
64
+
65
+ Agent playbooks (cross-project, distilled) are listed before user
66
+ playbooks (this project's lessons) under one ``### Project-specific
67
+ skills`` heading. The model doesn't need to reason about the split.
68
+
69
+ Args:
70
+ project_id (str): Displayed in the header so the user can tell
71
+ which project is in effect.
72
+ user_playbooks (Iterable[Any]): Skill records scoped to this project.
73
+ agent_playbooks (Iterable[Any]): Skill records shared across projects.
74
+ profiles (Iterable[Any]): Iterable of preference records
75
+ (``UserProfile`` objects or dicts).
76
+
77
+ Returns:
78
+ tuple[str, list[dict[str, Any]]]: ``(markdown, registry_entries)``.
79
+ When all input lists are empty the markdown is ``""`` and the
80
+ registry is ``[]``.
81
+ """
82
+ playbook_lines, playbook_entries = _format_combined_playbooks(
83
+ agent_playbooks=agent_playbooks, user_playbooks=user_playbooks
84
+ )
85
+ profile_lines, profile_entries = _format_profiles(profiles)
86
+ if not playbook_lines and not profile_lines:
87
+ return "", []
88
+
89
+ sections: list[str] = [f"## claude-smart — project `{project_id}`"]
90
+ if playbook_lines:
91
+ sections.append("### Project-specific skills")
92
+ sections.extend(playbook_lines)
93
+ if profile_lines:
94
+ sections.append("### Project preferences")
95
+ sections.extend(profile_lines)
96
+ sections.append(cs_cite.CITATION_INSTRUCTION)
97
+ return "\n".join(sections) + "\n", playbook_entries + profile_entries
98
+
99
+
100
+ def render_inline(
101
+ *,
102
+ project_id: str,
103
+ user_playbooks: Iterable[Any],
104
+ agent_playbooks: Iterable[Any],
105
+ profiles: Iterable[Any],
106
+ ) -> str:
107
+ """Render skills + preferences for mid-session injection.
108
+
109
+ Same bullet format as ``render`` but with no top-level project header.
110
+ This block is injected just-in-time alongside an in-flight user prompt or
111
+ tool call, so the caller already has project context.
112
+
113
+ Args:
114
+ project_id (str): Reserved for future use; currently unused.
115
+ user_playbooks (Iterable[Any]): Relevance-ranked project-scoped hits.
116
+ agent_playbooks (Iterable[Any]): Relevance-ranked global hits.
117
+ profiles (Iterable[Any]): Relevance-ranked preference hits.
118
+
119
+ Returns:
120
+ str: Markdown with ``### Relevant project-specific skills`` and/or
121
+ ``### Relevant project preferences`` sub-sections, or ``""``
122
+ when all inputs are empty.
123
+ """
124
+ markdown, _ = render_inline_with_registry(
125
+ project_id=project_id,
126
+ user_playbooks=user_playbooks,
127
+ agent_playbooks=agent_playbooks,
128
+ profiles=profiles,
129
+ )
130
+ return markdown
131
+
132
+
133
+ def render_inline_with_registry(
134
+ *,
135
+ project_id: str,
136
+ user_playbooks: Iterable[Any],
137
+ agent_playbooks: Iterable[Any],
138
+ profiles: Iterable[Any],
139
+ ) -> tuple[str, list[dict[str, Any]]]:
140
+ """Variant of ``render_inline`` that also returns the citation registry.
141
+
142
+ Args:
143
+ project_id (str): Reserved for future use; currently unused.
144
+ user_playbooks (Iterable[Any]): Relevance-ranked project-scoped hits.
145
+ agent_playbooks (Iterable[Any]): Relevance-ranked global hits.
146
+ profiles (Iterable[Any]): Relevance-ranked preference hits.
147
+
148
+ Returns:
149
+ tuple[str, list[dict[str, Any]]]: ``(markdown, registry_entries)``.
150
+ When all input lists are empty the markdown is ``""`` and the
151
+ registry is ``[]``.
152
+ """
153
+ del project_id # kept for symmetry with ``render_with_registry``.
154
+ playbook_lines, playbook_entries = _format_combined_playbooks(
155
+ agent_playbooks=agent_playbooks, user_playbooks=user_playbooks
156
+ )
157
+ profile_lines, profile_entries = _format_profiles(profiles)
158
+ if not playbook_lines and not profile_lines:
159
+ return "", []
160
+ sections: list[str] = []
161
+ if playbook_lines:
162
+ sections.append("### Relevant project-specific skills")
163
+ sections.extend(playbook_lines)
164
+ if profile_lines:
165
+ sections.append("### Relevant project preferences")
166
+ sections.extend(profile_lines)
167
+ sections.append(cs_cite.CITATION_INSTRUCTION)
168
+ return "\n".join(sections) + "\n", playbook_entries + profile_entries
169
+
170
+
171
+ def _format_combined_playbooks(
172
+ *,
173
+ agent_playbooks: Iterable[Any],
174
+ user_playbooks: Iterable[Any],
175
+ ) -> tuple[list[str], list[dict[str, Any]]]:
176
+ """Render agent playbooks first, then user playbooks, with one shared rank counter."""
177
+ lines: list[str] = []
178
+ entries: list[dict[str, Any]] = []
179
+ rank = 0
180
+ for pb in agent_playbooks:
181
+ rank = _append_playbook_bullet(
182
+ pb, "agent_playbook_id", "agent_playbook", rank, lines, entries
183
+ )
184
+ for pb in user_playbooks:
185
+ rank = _append_playbook_bullet(
186
+ pb, "user_playbook_id", "user_playbook", rank, lines, entries
187
+ )
188
+ return lines, entries
189
+
190
+
191
+ def _append_playbook_bullet(
192
+ pb: Any,
193
+ id_field: str,
194
+ source_kind: str,
195
+ rank: int,
196
+ lines: list[str],
197
+ entries: list[dict[str, Any]],
198
+ ) -> int:
199
+ content = _first_nonempty(_field(pb, "content"))
200
+ if not content:
201
+ return rank
202
+ rank += 1
203
+ trigger = _first_nonempty(_field(pb, "trigger"))
204
+ rationale = _first_nonempty(_field(pb, "rationale"))
205
+ real_id = _field(pb, id_field)
206
+ item_id = cs_cite.rank_id("playbook", rank, real_id)
207
+ title = _title_from_content(content)
208
+ bullet = f"- [cs:{item_id}] {content}"
209
+ if trigger:
210
+ bullet += f" _(when: {trigger})_"
211
+ if rationale:
212
+ bullet += f" — *why:* {rationale}"
213
+ lines.append(bullet)
214
+ entries.append(
215
+ {
216
+ "id": item_id,
217
+ "kind": "playbook",
218
+ "title": title,
219
+ "content": content,
220
+ "real_id": str(real_id) if real_id is not None else None,
221
+ "source_kind": source_kind,
222
+ }
223
+ )
224
+ return rank
225
+
226
+
227
+ def _format_profiles(
228
+ profiles: Iterable[Any],
229
+ ) -> tuple[list[str], list[dict[str, Any]]]:
230
+ lines: list[str] = []
231
+ entries: list[dict[str, Any]] = []
232
+ rank = 0
233
+ for p in profiles:
234
+ content = _first_nonempty(_field(p, "content"))
235
+ if not content:
236
+ continue
237
+ rank += 1
238
+ real_id = _field(p, "profile_id")
239
+ item_id = cs_cite.rank_id("profile", rank, real_id)
240
+ title = _title_from_content(content)
241
+ lines.append(f"- [cs:{item_id}] {content}")
242
+ entries.append(
243
+ {
244
+ "id": item_id,
245
+ "kind": "profile",
246
+ "title": title,
247
+ "content": content,
248
+ "real_id": str(real_id) if real_id is not None else None,
249
+ }
250
+ )
251
+ return lines, entries
252
+
253
+
254
+ def _title_from_content(content: str, limit: int = 80) -> str:
255
+ """Derive a compact human-readable title from a bullet's content.
256
+
257
+ Truncates at the first sentence boundary when one falls within the
258
+ character limit; otherwise hard-trims with an ellipsis. Used only for
259
+ dashboard display.
260
+ """
261
+ text = content.strip()
262
+ if not text:
263
+ return ""
264
+ for terminator in (". ", "\n"):
265
+ idx = text.find(terminator)
266
+ if 0 < idx <= limit:
267
+ return text[:idx].rstrip()
268
+ if len(text) <= limit:
269
+ return text
270
+ return text[: limit - 1].rstrip() + "…"
271
+
272
+
273
+ def _field(obj: Any, name: str) -> Any:
274
+ """Read ``name`` from either an attribute or a dict key."""
275
+ if isinstance(obj, dict):
276
+ return obj.get(name)
277
+ return getattr(obj, name, None)
@@ -0,0 +1,92 @@
1
+ """Shared "search reflexio, render markdown, emit hookSpecificOutput" pipeline.
2
+
3
+ PreToolUse and UserPromptSubmit both (a) run a query-aware reflexio
4
+ search, (b) render the hits with ``context_format.render_inline_with_registry``,
5
+ (c) persist the citation registry for the Stop hook to resolve, and
6
+ (d) emit a Claude Code ``hookSpecificOutput.additionalContext`` envelope
7
+ on stdout. This module owns that shared pipeline so the two hook
8
+ handlers keep exactly one source of truth for the injection contract —
9
+ the envelope shape, the registry schema, and the ordering of
10
+ ``ensure_installed`` / ``append_injected``.
11
+
12
+ The caller remains responsible for handler-specific framing (PreToolUse
13
+ needs ``hook.emit_continue()`` on the empty path; UserPromptSubmit wraps
14
+ the search in ``try/except`` so a failed reflexio never breaks a user's
15
+ turn) — see the two call sites for the small policy differences.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+ import sys
22
+ import time
23
+
24
+ from claude_smart import context_format, cs_cite, state
25
+ from claude_smart.reflexio_adapter import Adapter
26
+
27
+
28
+ def emit_context(
29
+ *,
30
+ session_id: str,
31
+ project_id: str,
32
+ query: str,
33
+ hook_event_name: str,
34
+ top_k: int,
35
+ adapter: Adapter | None = None,
36
+ ) -> bool:
37
+ """Search reflexio, render hits, emit ``additionalContext`` on stdout.
38
+
39
+ Args:
40
+ session_id (str): Claude Code session id; used to scope the
41
+ per-session citation registry.
42
+ project_id (str): reflexio ``user_id`` for this repo.
43
+ query (str): Free-text query routed to reflexio's unified
44
+ ``/api/search`` endpoint, which fans out to user playbooks
45
+ (project-scoped), agent playbooks (global), and preferences
46
+ (project-scoped) server-side.
47
+ hook_event_name (str): ``"PreToolUse"`` or ``"UserPromptSubmit"``;
48
+ echoed verbatim in the hook envelope so Claude Code attributes
49
+ the context to the right event.
50
+ top_k (int): Cap on hits per collection.
51
+ adapter (Adapter | None): Injection seam for tests. A fresh
52
+ ``Adapter()`` is used when ``None``.
53
+
54
+ Returns:
55
+ bool: ``True`` when markdown was emitted to stdout; ``False``
56
+ when the search returned nothing to inject.
57
+ """
58
+ user_playbooks, agent_playbooks, profiles = (adapter or Adapter()).search_all(
59
+ project_id=project_id,
60
+ query=query,
61
+ top_k=top_k,
62
+ )
63
+ markdown, registry = context_format.render_inline_with_registry(
64
+ project_id=project_id,
65
+ user_playbooks=user_playbooks,
66
+ agent_playbooks=agent_playbooks,
67
+ profiles=profiles,
68
+ )
69
+ if not markdown:
70
+ return False
71
+
72
+ cs_cite.ensure_installed()
73
+ state.append_injected(
74
+ session_id,
75
+ (dict(entry, ts=int(time.time())) for entry in registry),
76
+ )
77
+
78
+ sys.stdout.write(
79
+ json.dumps(
80
+ {
81
+ "hookSpecificOutput": {
82
+ "hookEventName": hook_event_name,
83
+ "additionalContext": markdown,
84
+ }
85
+ }
86
+ )
87
+ )
88
+ sys.stdout.write("\n")
89
+ return True
90
+
91
+
92
+ __all__ = ["emit_context"]
@@ -0,0 +1,236 @@
1
+ """Support helpers for claude-smart citation tracking.
2
+
3
+ Context injected by UserPromptSubmit / PreToolUse tags each skill and
4
+ preference bullet with a rank-based id fingerprinted by the underlying
5
+ real id (``[cs:s1-1a2b]`` for the first skill whose
6
+ ``user_playbook_id`` starts with ``1a2b``, ``[cs:p2-c3d4]`` for the
7
+ second preference). The injected instruction asks the assistant to end
8
+ impactful replies with a marker like::
9
+
10
+ ✨ 1 claude-smart learning applied [cs:s1-1a2b]
11
+
12
+ The Stop hook later scans the assistant text for those markers and resolves
13
+ the ids against a per-session registry persisted at
14
+ ``~/.claude-smart/sessions/<session_id>.injected.jsonl``. Legacy ``cs-cite``
15
+ Bash tool calls are still accepted as a fallback for older instructions.
16
+
17
+ Why rank + fingerprint: rank alone resets at every injection, so a
18
+ later injection's ``s1`` would silently overwrite an earlier entry in
19
+ the append-only registry — if Claude cited ``s1`` across a turn
20
+ boundary, the resolver would pick the wrong skill. Appending the
21
+ first four alphanumeric chars of the real id makes the id stable
22
+ across injections in the common case (distinct real ids → distinct
23
+ fingerprints), so cross-injection collisions become rare.
24
+
25
+ This module holds:
26
+
27
+ - ``rank_id``: ``p{n}-{fp}`` / ``s{n}-{fp}`` tag for a given
28
+ (kind, rank, real_id) tuple. Fingerprint is omitted when no real id
29
+ is available. ``p`` is preference, ``s`` is skill.
30
+ - ``CITATION_CMD_RE``: regex matching a valid legacy ``cs-cite`` command line.
31
+ - ``ensure_installed``: idempotent copy of ``plugin/bin/cs-cite`` to
32
+ ``~/.claude-smart/bin/cs-cite`` with the executable bit set.
33
+ - ``CITATION_INSTRUCTION``: the trailer text appended to injected context
34
+ so the assistant knows when and how to emit the citation marker.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import logging
40
+ import re
41
+ import shutil
42
+ import stat as stat_
43
+ from pathlib import Path
44
+ from typing import Any
45
+
46
+ _LOGGER = logging.getLogger(__name__)
47
+
48
+ _THIS_DIR = Path(__file__).resolve().parent
49
+ _PLUGIN_ROOT = _THIS_DIR.parents[1] # plugin/src/claude_smart/ -> plugin/
50
+ _SOURCE_SCRIPT = _PLUGIN_ROOT / "bin" / "cs-cite"
51
+ _INSTALL_DIR = Path.home() / ".claude-smart" / "bin"
52
+ INSTALL_PATH = _INSTALL_DIR / "cs-cite"
53
+
54
+ _FINGERPRINT_LEN = 4
55
+
56
+ # Match a bare `cs-cite <ids>` invocation. Ids are rank tokens of the
57
+ # form `p<N>` (preference) or `s<N>` (skill) with an optional
58
+ # `-<fp>` fingerprint (1-4 alphanumeric chars), optionally
59
+ # `cs:`-prefixed (since bullets render as `[cs:p1-ab12]` and the model
60
+ # often copies the tag verbatim). The `(?i:...)` inline flags make the
61
+ # prefix, kind letter, and fingerprint case-insensitive so `CS:P1-AB12`
62
+ # is accepted — matching the `re.IGNORECASE` used by the standalone
63
+ # `cs-cite` script. Tokens may be comma- and/or whitespace-separated.
64
+ # Chained commands (&&, |, ;) and extra trailing tokens remain rejected
65
+ # by the anchored `\s*$` terminator so accidental mentions don't
66
+ # register as citations.
67
+ _ID_TOKEN = r"(?i:cs:)?(?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?"
68
+ _ID_SEP = r"[,\s]+"
69
+ CITATION_CMD_RE = re.compile(
70
+ rf"^\s*(?:[^\s]*/)?cs-cite\s+({_ID_TOKEN}(?:{_ID_SEP}{_ID_TOKEN})*)\s*$"
71
+ )
72
+ _CLEAN_ID_RE = re.compile(r"^(?i:cs:)?((?i:[ps])\d+(?:-(?i:[a-z0-9]){1,4})?)$")
73
+ _SPLIT_RE = re.compile(_ID_SEP)
74
+ _TEXT_CITATION_LINE_RE = re.compile(
75
+ r"(?im)^\s*✨\s+\d+\s+claude-smart learning(?:s)? applied\s+"
76
+ r"\[cs:(?P<ids>[^\]]+)\]\s*$"
77
+ )
78
+
79
+ CITATION_INSTRUCTION = (
80
+ "_First, fully answer the user — citation does not change what or how "
81
+ "you reply. Then, as a final step, consider whether to cite: if — and "
82
+ "only if — an injected `[cs:…]` item materially changed your reply "
83
+ "(different wording, action, or conclusion than you would have produced "
84
+ "without it), append exactly one final citation line after your answer. "
85
+ "Do not call `cs-cite` or any other tool for citations. Ids come verbatim "
86
+ "from the `[cs:…]` tags — keep the leading `p` (preference) or `s` "
87
+ "(skill) and the `-<fp>` suffix. Use this exact format for one id: "
88
+ "`✨ 1 claude-smart learning applied [cs:s1-ab12]`. Use this exact format "
89
+ "for multiple ids: `✨ 2 claude-smart learnings applied [cs:s1-ab12,p2-cd34]`, "
90
+ "where the number is the count of ids in the brackets. "
91
+ "Never emit a standalone wrapper like `✨s1-ab12✨` or `✨abc123✨`; "
92
+ "those are not claude-smart citations and cannot be resolved. "
93
+ "Default is to skip. If an item is merely on-topic, confirms what you "
94
+ "already planned, or your reply would read the same without it, do not "
95
+ "cite — end the turn normally with your reply. When unsure, skip. Do "
96
+ "not add any other text, tool calls, or role markers after the final "
97
+ "citation line._"
98
+ )
99
+
100
+
101
+ def _fingerprint(real_id: Any) -> str:
102
+ """Return the first ``_FINGERPRINT_LEN`` alphanumeric chars of ``real_id``.
103
+
104
+ The fingerprint disambiguates rank ids across injections: two
105
+ injections both producing ``s1`` for different skills will still
106
+ yield distinct tags when their real ids have different prefixes.
107
+
108
+ Args:
109
+ real_id: The underlying ``user_playbook_id`` or ``profile_id``.
110
+ Accepts anything ``str()`` handles (int, UUID, etc.).
111
+ ``None`` yields an empty string.
112
+
113
+ Returns:
114
+ str: Up to 4 lowercase alphanumeric chars; empty when the real
115
+ id has no alphanumeric characters or is ``None``.
116
+ """
117
+ if real_id is None:
118
+ return ""
119
+ return "".join(c for c in str(real_id).lower() if c.isalnum())[:_FINGERPRINT_LEN]
120
+
121
+
122
+ def rank_id(kind: str, rank: int, real_id: Any = None) -> str:
123
+ """Return the citation id for a skill or preference item.
124
+
125
+ Format is ``{letter}{rank}-{fingerprint}`` where ``letter`` is ``p``
126
+ for preferences and ``s`` for skills, ``rank`` is the 1-based
127
+ position within the current retrieval batch, and ``fingerprint`` is
128
+ up to 4 alphanumeric chars derived from ``real_id``. The fingerprint
129
+ is omitted when no real id is available (falling back to the rank
130
+ form ``s1`` / ``p1``).
131
+
132
+ Args:
133
+ kind: ``"playbook"`` or ``"profile"``. Unknown values raise
134
+ ``ValueError`` — callers never build registry entries for
135
+ other kinds.
136
+ rank: 1-based position within the retrieval batch.
137
+ real_id: The underlying ``user_playbook_id`` or ``profile_id``
138
+ used to derive the fingerprint suffix. Optional.
139
+
140
+ Returns:
141
+ str: ``p<rank>-<fp>`` for preferences, ``s<rank>-<fp>`` for
142
+ skills. Suffix is omitted when the real id yields no
143
+ alphanumeric fingerprint.
144
+
145
+ Raises:
146
+ ValueError: If ``kind`` is not ``"profile"`` or ``"playbook"``.
147
+ """
148
+ if kind == "profile":
149
+ prefix = "p"
150
+ elif kind == "playbook":
151
+ prefix = "s"
152
+ else:
153
+ raise ValueError(f"unknown citation kind: {kind!r}")
154
+ fp = _fingerprint(real_id)
155
+ return f"{prefix}{rank}-{fp}" if fp else f"{prefix}{rank}"
156
+
157
+
158
+ def parse_citation_command(command: str) -> list[str]:
159
+ """Extract citation ids from a ``cs-cite`` Bash command string.
160
+
161
+ Returns an empty list when the command does not match the expected
162
+ shape (chained commands, extra arguments, or anything other than a
163
+ bare ``cs-cite <ids>`` invocation are rejected to avoid false
164
+ positives from accidental mentions).
165
+
166
+ Args:
167
+ command: The raw ``input.command`` value from a Bash tool_use
168
+ block.
169
+
170
+ Returns:
171
+ list[str]: Lowercase rank ids (e.g. ``"p1"``, ``"s3"``), in the
172
+ order Claude cited them. Empty when the command does not
173
+ match.
174
+ """
175
+ match = CITATION_CMD_RE.match(command or "")
176
+ if not match:
177
+ return []
178
+ return _parse_id_tokens(match.group(1))
179
+
180
+
181
+ def parse_text_citations(text: str) -> list[str]:
182
+ """Extract Codex text-only citation ids from a final learning marker line.
183
+
184
+ The parser intentionally only accepts lines containing the visual
185
+ ``claude-smart learning(s) applied`` marker, so ordinary references to
186
+ injected ``[cs:...]`` ids inside an answer do not count as citations.
187
+ When multiple matching lines exist, the last one wins because the
188
+ instruction requires the citation marker to be final.
189
+ """
190
+ matches = list(_TEXT_CITATION_LINE_RE.finditer(text or ""))
191
+ if not matches:
192
+ return []
193
+ return _parse_id_tokens(matches[-1].group("ids"))
194
+
195
+
196
+ def _parse_id_tokens(raw_ids: str) -> list[str]:
197
+ ids: list[str] = []
198
+ for tok in _SPLIT_RE.split(raw_ids.strip()):
199
+ if clean := _CLEAN_ID_RE.match(tok):
200
+ ids.append(clean.group(1).lower())
201
+ return ids
202
+
203
+
204
+ def ensure_installed() -> Path:
205
+ """Idempotently install ``cs-cite`` into ``~/.claude-smart/bin/``.
206
+
207
+ Called from every PreToolUse / UserPromptSubmit inject, so we
208
+ short-circuit when the target file already exists with
209
+ the executable bit set — the steady-state path is one ``stat`` syscall
210
+ instead of mkdir + copy + stat + chmod. Keying on filesystem state
211
+ (rather than a module-level boolean) keeps test isolation working when
212
+ tests monkeypatch ``INSTALL_PATH`` to a fresh tmpdir.
213
+
214
+ Never raises — filesystem errors are logged at DEBUG and the caller
215
+ proceeds with injection regardless (the citation feature degrades to
216
+ silent if the script is unreachable).
217
+
218
+ Returns:
219
+ Path: Target path, whether or not install succeeded.
220
+ """
221
+ try:
222
+ if (
223
+ INSTALL_PATH.is_file()
224
+ and INSTALL_PATH.stat().st_mode & stat_.S_IXUSR
225
+ and _SOURCE_SCRIPT.is_file()
226
+ and INSTALL_PATH.read_bytes() == _SOURCE_SCRIPT.read_bytes()
227
+ ):
228
+ return INSTALL_PATH
229
+ _INSTALL_DIR.mkdir(parents=True, exist_ok=True)
230
+ if _SOURCE_SCRIPT.is_file():
231
+ shutil.copy2(_SOURCE_SCRIPT, INSTALL_PATH)
232
+ mode = INSTALL_PATH.stat().st_mode
233
+ INSTALL_PATH.chmod(mode | stat_.S_IXUSR | stat_.S_IXGRP | stat_.S_IXOTH)
234
+ except OSError as exc:
235
+ _LOGGER.debug("cs-cite install failed: %s", exc)
236
+ return INSTALL_PATH
@@ -0,0 +1 @@
1
+ """Event handlers — one module per Claude Code hook event."""