claude-smart 0.2.46 → 0.2.47

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 (85) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/README.md +19 -11
  3. package/bin/claude-smart.js +290 -68
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +1 -1
  7. package/plugin/README.md +11 -10
  8. package/plugin/dashboard/app/layout.tsx +20 -0
  9. package/plugin/opencode/dist/server.mjs +76 -2
  10. package/plugin/opencode/server.mts +79 -2
  11. package/plugin/pyproject.toml +6 -2
  12. package/plugin/scripts/smart-install.sh +7 -1
  13. package/plugin/src/claude_smart/cli.py +210 -22
  14. package/plugin/src/claude_smart/context_format.py +9 -9
  15. package/plugin/src/claude_smart/cs_cite.py +66 -19
  16. package/plugin/uv.lock +5 -5
  17. package/plugin/vendor/reflexio/README.md +3 -3
  18. package/plugin/vendor/reflexio/pyproject.toml +1 -1
  19. package/plugin/vendor/reflexio/reflexio/README.md +3 -1
  20. package/plugin/vendor/reflexio/reflexio/cli/bootstrap_config.py +1 -1
  21. package/plugin/vendor/reflexio/reflexio/cli/commands/setup_cmd.py +2 -2
  22. package/plugin/vendor/reflexio/reflexio/cli/utils.py +44 -1
  23. package/plugin/vendor/reflexio/reflexio/client/client.py +97 -0
  24. package/plugin/vendor/reflexio/reflexio/lib/_agent_playbook.py +8 -0
  25. package/plugin/vendor/reflexio/reflexio/lib/_base.py +15 -0
  26. package/plugin/vendor/reflexio/reflexio/lib/_generation.py +9 -8
  27. package/plugin/vendor/reflexio/reflexio/lib/_profiles.py +27 -16
  28. package/plugin/vendor/reflexio/reflexio/lib/_search.py +23 -5
  29. package/plugin/vendor/reflexio/reflexio/lib/_user_playbook.py +9 -0
  30. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/__init__.py +1 -0
  31. package/plugin/vendor/reflexio/reflexio/models/api_schema/domain/governance.py +117 -0
  32. package/plugin/vendor/reflexio/reflexio/models/api_schema/retriever_schema.py +45 -3
  33. package/plugin/vendor/reflexio/reflexio/models/config_schema.py +16 -0
  34. package/plugin/vendor/reflexio/reflexio/server/README.md +14 -2
  35. package/plugin/vendor/reflexio/reflexio/server/api.py +176 -29
  36. package/plugin/vendor/reflexio/reflexio/server/api_endpoints/request_context.py +1 -1
  37. package/plugin/vendor/reflexio/reflexio/server/{_auth.py → auth.py} +2 -0
  38. package/plugin/vendor/reflexio/reflexio/server/extensions.py +213 -0
  39. package/plugin/vendor/reflexio/reflexio/server/llm/model_defaults.py +4 -4
  40. package/plugin/vendor/reflexio/reflexio/server/llm/providers/claude_code_provider.py +1 -1
  41. package/plugin/vendor/reflexio/reflexio/server/llm/rerank/cross_encoder_reranker.py +12 -1
  42. package/plugin/vendor/reflexio/reflexio/server/prompt/prompt_bank/playbook_extraction_context/v4.4.0.prompt.md +14 -2
  43. package/plugin/vendor/reflexio/reflexio/server/services/README.md +3 -1
  44. package/plugin/vendor/reflexio/reflexio/server/services/extraction/resume_worker.py +8 -0
  45. package/plugin/vendor/reflexio/reflexio/server/services/governance/config.py +52 -0
  46. package/plugin/vendor/reflexio/reflexio/server/services/governance/service.py +378 -0
  47. package/plugin/vendor/reflexio/reflexio/server/services/governance/subject_refs.py +34 -0
  48. package/plugin/vendor/reflexio/reflexio/server/services/lineage/gc_scheduler.py +45 -19
  49. package/plugin/vendor/reflexio/reflexio/server/services/playbook/README.md +9 -1
  50. package/plugin/vendor/reflexio/reflexio/server/services/playbook/aggregation_prompt_processing.py +100 -0
  51. package/plugin/vendor/reflexio/reflexio/server/services/playbook/components/aggregator.py +129 -111
  52. package/plugin/vendor/reflexio/reflexio/server/services/playbook/service.py +9 -9
  53. package/plugin/vendor/reflexio/reflexio/server/services/profile/components/extractor.py +3 -2
  54. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/recency.py +211 -0
  55. package/plugin/vendor/reflexio/reflexio/server/services/retrieval/relevance_floor.py +29 -13
  56. package/plugin/vendor/reflexio/reflexio/server/services/storage/error.py +4 -0
  57. package/plugin/vendor/reflexio/reflexio/server/services/storage/governance_validation.py +681 -0
  58. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/__init__.py +14 -2
  59. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_base.py +2 -0
  60. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_extras.py +49 -19
  61. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_governance.py +1965 -0
  62. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_lineage.py +5 -3
  63. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_playbook.py +1 -2133
  64. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_profiles.py +262 -107
  65. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/_requests.py +73 -33
  66. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/__init__.py +13 -0
  67. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_agent.py +952 -0
  68. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_eval_results.py +189 -0
  69. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_optimization.py +247 -0
  70. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_source_linkage.py +145 -0
  71. package/plugin/vendor/reflexio/reflexio/server/services/storage/sqlite_storage/playbook/_user.py +838 -0
  72. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/__init__.py +19 -3
  73. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_extras.py +4 -4
  74. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_governance.py +148 -0
  75. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_playbook.py +0 -909
  76. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_profiles.py +13 -0
  77. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/_requests.py +2 -0
  78. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/__init__.py +13 -0
  79. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_agent.py +365 -0
  80. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_eval_results.py +124 -0
  81. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_optimization.py +85 -0
  82. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_source_linkage.py +47 -0
  83. package/plugin/vendor/reflexio/reflexio/server/services/storage/storage_base/playbook/_user.py +333 -0
  84. package/plugin/vendor/reflexio/reflexio/server/services/unified_search_service.py +153 -12
  85. package/plugin/vendor/reflexio/reflexio/server/services/playbook/user_detail_stripping.py +0 -84
package/plugin/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Self-improving [Claude Code](https://claude.com/claude-code), Codex, and OpenCode plugin — turns corrections and successful workflows into durable Preferences, Project-specific skills, and Shared skills that future sessions follow, via [reflexio](https://github.com/ReflexioAI/reflexio).
4
4
 
5
- This directory is the published Python package (`claude-smart` on PyPI) and the Claude Code/Codex/OpenCode plugin payload shipped through the marketplace and npm. For the project overview, install instructions, benchmarks, and feature walkthrough, see the [top-level README](https://github.com/ReflexioAI/claude-smart#readme).
5
+ This directory is the Claude Code/Codex/OpenCode plugin payload shipped through the marketplace and the `claude-smart` npm package. For the project overview, install instructions, benchmarks, and feature walkthrough, see the [top-level README](https://github.com/ReflexioAI/claude-smart#readme).
6
6
 
7
7
  ## Install
8
8
 
@@ -15,8 +15,7 @@ claude plugin install claude-smart@reflexioai
15
15
 
16
16
  The Setup hook bootstraps `uv`, Python 3.12, and a private Node.js/npm runtime
17
17
  under `~/.claude-smart/` when they are missing. If Node.js is already installed,
18
- `npx claude-smart install` is equivalent; if uv is already installed,
19
- `uvx claude-smart install` is equivalent.
18
+ `npx claude-smart install` is equivalent.
20
19
 
21
20
  Add `--read-only` to either install command to skip hooks that publish
22
21
  interactions for learning.
@@ -43,9 +42,10 @@ npx claude-smart install --host opencode
43
42
  ```
44
43
 
45
44
  Then restart OpenCode in your project so it loads the plugin from `opencode.json`
46
- or your existing `.opencode/opencode.json*` config. OpenCode installs reuse the
47
- same local Preferences, Project-specific skills, and Shared skills as Claude Code
48
- and Codex.
45
+ or your existing `.opencode/opencode.json*` config. The installer copies the
46
+ active npm package to `~/.claude-smart/opencode/claude-smart` and registers that
47
+ stable local package with `file://`. OpenCode installs reuse the same local
48
+ Preferences, Project-specific skills, and Shared skills as Claude Code and Codex.
49
49
 
50
50
  ## Uninstall
51
51
 
@@ -55,10 +55,10 @@ and Codex.
55
55
  claude plugin uninstall claude-smart@reflexioai
56
56
  ```
57
57
 
58
- Or, if Node.js or uv is already installed:
58
+ Or, if Node.js is already installed:
59
59
 
60
60
  ```bash
61
- npx claude-smart uninstall # or: uvx claude-smart uninstall
61
+ npx claude-smart uninstall
62
62
  ```
63
63
 
64
64
  ### Codex
@@ -76,8 +76,9 @@ Restart Codex after uninstalling. Local data under `~/.reflexio/` and
76
76
  npx claude-smart uninstall --host opencode
77
77
  ```
78
78
 
79
- Restart OpenCode after uninstalling. Local data under `~/.reflexio/` and
80
- `~/.claude-smart/` is preserved and shared across supported hosts.
79
+ Restart OpenCode after uninstalling. The copied OpenCode package is removed, but
80
+ local learning data under `~/.reflexio/` and `~/.claude-smart/` is preserved and
81
+ shared across supported hosts.
81
82
 
82
83
  ## License
83
84
 
@@ -36,6 +36,26 @@ export default function RootLayout({
36
36
  {children}
37
37
  </main>
38
38
  </div>
39
+ <footer className="shrink-0 border-t border-border bg-card/60 px-4 py-2 text-xs text-muted-foreground flex items-center justify-center gap-1.5">
40
+ <span>Powered by</span>
41
+ <a
42
+ href="https://github.com/ReflexioAI/reflexio"
43
+ target="_blank"
44
+ rel="noopener noreferrer"
45
+ className="font-medium hover:text-foreground underline-offset-2 hover:underline"
46
+ >
47
+ reflexio
48
+ </a>
49
+ <span aria-hidden>·</span>
50
+ <a
51
+ href="https://github.com/ReflexioAI/reflexio"
52
+ target="_blank"
53
+ rel="noopener noreferrer"
54
+ className="hover:text-foreground underline-offset-2 hover:underline"
55
+ >
56
+ ⭐ Star on GitHub
57
+ </a>
58
+ </footer>
39
59
  </Providers>
40
60
  </body>
41
61
  </html>
@@ -1,11 +1,85 @@
1
1
  import { spawn } from "node:child_process";
2
- import { dirname, resolve } from "node:path";
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { AssistantBuffer } from "./assistant-buffer.js";
5
7
  import { sessionIDFrom } from "./internal.js";
6
8
  import { chatMessagePayload, eventPayload, stopPayload, toolAfterPayload } from "./payload.js";
7
9
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url));
8
- const PLUGIN_ROOT = resolve(MODULE_DIR, "../..");
10
+ function homeDir() {
11
+ return process.env.HOME || homedir();
12
+ }
13
+ function textFilePluginRoot() {
14
+ try {
15
+ return readFileSync(join(homeDir(), ".reflexio", "plugin-root.txt"), "utf8").trim() || undefined;
16
+ }
17
+ catch {
18
+ return undefined;
19
+ }
20
+ }
21
+ function isPluginRoot(candidate) {
22
+ return !!candidate
23
+ && existsSync(join(candidate, "pyproject.toml"))
24
+ && existsSync(join(candidate, "uv.lock"))
25
+ && existsSync(join(candidate, "scripts", "hook_entry.sh"));
26
+ }
27
+ function canonicalPluginRoot(candidate) {
28
+ if (!isPluginRoot(candidate))
29
+ return undefined;
30
+ try {
31
+ return realpathSync(candidate);
32
+ }
33
+ catch {
34
+ return undefined;
35
+ }
36
+ }
37
+ function pathParts(candidate) {
38
+ return candidate.split(/[\\/]+/).filter(Boolean);
39
+ }
40
+ function isDescendantOrSame(child, parent) {
41
+ const path = relative(parent, child);
42
+ return path === "" || (!path.startsWith("..") && !isAbsolute(path));
43
+ }
44
+ function isReflexioSessionCopy(candidate) {
45
+ try {
46
+ const reflexioRoot = realpathSync(join(homeDir(), ".reflexio"));
47
+ return isDescendantOrSame(candidate, reflexioRoot);
48
+ }
49
+ catch {
50
+ return false;
51
+ }
52
+ }
53
+ function isTransientPackageRoot(candidate) {
54
+ return pathParts(candidate).includes("_npx") || isReflexioSessionCopy(candidate);
55
+ }
56
+ function resolvePluginRoot() {
57
+ const current = resolve(MODULE_DIR, "../..");
58
+ const explicit = canonicalPluginRoot(process.env.CLAUDE_SMART_PLUGIN_ROOT);
59
+ if (explicit)
60
+ return explicit;
61
+ const currentRoot = canonicalPluginRoot(current);
62
+ if (currentRoot && !isTransientPackageRoot(currentRoot))
63
+ return currentRoot;
64
+ const candidates = [
65
+ join(homeDir(), ".reflexio", "plugin-root"),
66
+ textFilePluginRoot(),
67
+ ];
68
+ for (const candidate of candidates) {
69
+ const root = canonicalPluginRoot(candidate);
70
+ if (root)
71
+ return root;
72
+ }
73
+ if (currentRoot)
74
+ return currentRoot;
75
+ try {
76
+ return realpathSync(current);
77
+ }
78
+ catch {
79
+ return current;
80
+ }
81
+ }
82
+ const PLUGIN_ROOT = resolvePluginRoot();
9
83
  const SCRIPTS_DIR = resolve(PLUGIN_ROOT, "scripts");
10
84
  const HOOK_ENTRY = resolve(SCRIPTS_DIR, "hook_entry.sh");
11
85
  const BACKEND_SERVICE = resolve(SCRIPTS_DIR, "backend-service.sh");
@@ -1,5 +1,7 @@
1
1
  import { spawn } from "node:child_process"
2
- import { dirname, resolve } from "node:path"
2
+ import { existsSync, readFileSync, realpathSync } from "node:fs"
3
+ import { homedir } from "node:os"
4
+ import { dirname, isAbsolute, join, relative, resolve } from "node:path"
3
5
  import { fileURLToPath } from "node:url"
4
6
 
5
7
  import { AssistantBuffer } from "./assistant-buffer.js"
@@ -20,7 +22,82 @@ type EventInput = {
20
22
  }
21
23
 
22
24
  const MODULE_DIR = dirname(fileURLToPath(import.meta.url))
23
- const PLUGIN_ROOT = resolve(MODULE_DIR, "../..")
25
+
26
+ function homeDir(): string {
27
+ return process.env.HOME || homedir()
28
+ }
29
+
30
+ function textFilePluginRoot(): string | undefined {
31
+ try {
32
+ return readFileSync(join(homeDir(), ".reflexio", "plugin-root.txt"), "utf8").trim() || undefined
33
+ } catch {
34
+ return undefined
35
+ }
36
+ }
37
+
38
+ function isPluginRoot(candidate: string | undefined): candidate is string {
39
+ return !!candidate
40
+ && existsSync(join(candidate, "pyproject.toml"))
41
+ && existsSync(join(candidate, "uv.lock"))
42
+ && existsSync(join(candidate, "scripts", "hook_entry.sh"))
43
+ }
44
+
45
+ function canonicalPluginRoot(candidate: string | undefined): string | undefined {
46
+ if (!isPluginRoot(candidate)) return undefined
47
+ try {
48
+ return realpathSync(candidate)
49
+ } catch {
50
+ return undefined
51
+ }
52
+ }
53
+
54
+ function pathParts(candidate: string): string[] {
55
+ return candidate.split(/[\\/]+/).filter(Boolean)
56
+ }
57
+
58
+ function isDescendantOrSame(child: string, parent: string): boolean {
59
+ const path = relative(parent, child)
60
+ return path === "" || (!path.startsWith("..") && !isAbsolute(path))
61
+ }
62
+
63
+ function isReflexioSessionCopy(candidate: string): boolean {
64
+ try {
65
+ const reflexioRoot = realpathSync(join(homeDir(), ".reflexio"))
66
+ return isDescendantOrSame(candidate, reflexioRoot)
67
+ } catch {
68
+ return false
69
+ }
70
+ }
71
+
72
+ function isTransientPackageRoot(candidate: string): boolean {
73
+ return pathParts(candidate).includes("_npx") || isReflexioSessionCopy(candidate)
74
+ }
75
+
76
+ function resolvePluginRoot(): string {
77
+ const current = resolve(MODULE_DIR, "../..")
78
+ const explicit = canonicalPluginRoot(process.env.CLAUDE_SMART_PLUGIN_ROOT)
79
+ if (explicit) return explicit
80
+
81
+ const currentRoot = canonicalPluginRoot(current)
82
+ if (currentRoot && !isTransientPackageRoot(currentRoot)) return currentRoot
83
+
84
+ const candidates = [
85
+ join(homeDir(), ".reflexio", "plugin-root"),
86
+ textFilePluginRoot(),
87
+ ]
88
+ for (const candidate of candidates) {
89
+ const root = canonicalPluginRoot(candidate)
90
+ if (root) return root
91
+ }
92
+ if (currentRoot) return currentRoot
93
+ try {
94
+ return realpathSync(current)
95
+ } catch {
96
+ return current
97
+ }
98
+ }
99
+
100
+ const PLUGIN_ROOT = resolvePluginRoot()
24
101
  const SCRIPTS_DIR = resolve(PLUGIN_ROOT, "scripts")
25
102
  const HOOK_ENTRY = resolve(SCRIPTS_DIR, "hook_entry.sh")
26
103
  const BACKEND_SERVICE = resolve(SCRIPTS_DIR, "backend-service.sh")
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "claude-smart"
3
- version = "0.2.46"
3
+ version = "0.2.47"
4
4
  description = "Self-improving Claude Code, Codex, and OpenCode plugin that turns corrections into Preferences, Project-specific skills, and Shared skills"
5
5
  keywords = [
6
6
  "claude",
@@ -38,9 +38,13 @@ dependencies = [
38
38
  "sqlite-vec>=0.1.6",
39
39
  ]
40
40
 
41
+ # claude-smart is distributed via npm only (see bin/claude-smart.js); it is not
42
+ # published to PyPI, so there is no `claude-smart` console script / uvx entry
43
+ # point. These two scripts are resolved by the runtime from the plugin venv
44
+ # (session_start.py locates claude-smart-optimizer-assistant; the hook is invoked
45
+ # via `python -m claude_smart.hook`), so they stay.
41
46
  [project.scripts]
42
47
  claude-smart-hook = "claude_smart.hook:main"
43
- claude-smart = "claude_smart.cli:main"
44
48
  claude-smart-optimizer-assistant = "claude_smart.optimizer_assistant:main"
45
49
 
46
50
  [build-system]
@@ -465,7 +465,13 @@ if [ -d "$PLUGIN_ROOT/.venv" ]; then
465
465
  fi
466
466
  echo "[claude-smart] running uv sync..." >&2
467
467
  if ! uv sync --locked --python 3.12 --quiet >&2; then
468
- write_failure "uv sync failed in $PLUGIN_ROOT — run 'uv sync --locked --python 3.12' there to diagnose"
468
+ if uv lock --check --python 3.12 >/dev/null 2>&1; then
469
+ write_failure "uv sync failed in $PLUGIN_ROOT — run 'uv sync --locked --python 3.12' there to diagnose"
470
+ fi
471
+ echo "[claude-smart] plugin/uv.lock is out of sync; refreshing local lockfile and retrying uv sync" >&2
472
+ if ! uv lock --python 3.12 >&2 || ! uv sync --python 3.12 --quiet >&2; then
473
+ write_failure "uv sync failed in $PLUGIN_ROOT after refreshing plugin/uv.lock — run 'uv lock --python 3.12 && uv sync --python 3.12' there to diagnose"
474
+ fi
469
475
  fi
470
476
  # Defend against a stale lockfile silently dropping the project install.
471
477
  # When plugin/uv.lock pins a different claude-smart version than pyproject.toml,
@@ -21,6 +21,10 @@ Exposes the following subcommands:
21
21
  from __future__ import annotations
22
22
 
23
23
  import argparse
24
+ from collections.abc import Iterable, Iterator
25
+ from contextlib import contextmanager
26
+ from dataclasses import dataclass
27
+ from hashlib import sha256
24
28
  import json
25
29
  import os
26
30
  import re
@@ -29,11 +33,11 @@ import shutil
29
33
  import subprocess
30
34
  import sys
31
35
  import time
32
- from collections.abc import Iterable
33
- from dataclasses import dataclass
34
36
  from pathlib import Path
37
+ from urllib.parse import urlparse
38
+ from urllib.request import url2pathname
35
39
 
36
- from claude_smart import context_format, env_config, ids, publish, state
40
+ from claude_smart import context_format, cs_cite, env_config, ids, publish, state
37
41
  from claude_smart.reflexio_adapter import Adapter
38
42
 
39
43
  _REFLEXIO_ENV_PATH = env_config.REFLEXIO_ENV_PATH
@@ -56,7 +60,7 @@ _CODEX_PLUGIN_CACHE_DIR = (
56
60
  / "claude-smart"
57
61
  )
58
62
  _CODEX_CLI_TIMEOUT_SECONDS = 30
59
- _OPENCODE_PLUGIN_SPEC = "claude-smart"
63
+ _OPENCODE_BARE_PLUGIN_SPEC = "claude-smart"
60
64
  _OPENCODE_CONFIG_NAMES = ("opencode.json", "opencode.jsonc")
61
65
  _REFLEXIO_UNREACHABLE_MSG = (
62
66
  "Failed to reach reflexio. Check ~/.claude-smart/backend.log "
@@ -72,6 +76,9 @@ _BACKEND_SCRIPT = _SCRIPTS_DIR / "backend-service.sh"
72
76
  _DASHBOARD_SCRIPT = _SCRIPTS_DIR / "dashboard-service.sh"
73
77
  _REFLEXIO_DIR = Path.home() / ".reflexio"
74
78
  _STATE_DIR = Path.home() / ".claude-smart"
79
+ _OPENCODE_LOCAL_PACKAGE_DIR = _STATE_DIR / "opencode" / "claude-smart"
80
+ _OPENCODE_PACKAGE_LOCK_TIMEOUT_SECONDS = 120.0
81
+ _OPENCODE_PACKAGE_LOCK_STALE_SECONDS = 10 * 60.0
75
82
  _LOCAL_DATA_NOTICE = (
76
83
  "Local data was kept so reinstalling claude-smart can reuse your learned "
77
84
  "rules, sessions, logs, and local Reflexio data.\n"
@@ -113,6 +120,7 @@ _COPYTREE_IGNORE = shutil.ignore_patterns(
113
120
  "*.pyo",
114
121
  ".pytest_cache",
115
122
  ".ruff_cache",
123
+ ".git",
116
124
  ".next",
117
125
  "node_modules",
118
126
  )
@@ -1080,9 +1088,50 @@ def _opencode_plugin_spec(entry: object) -> str | None:
1080
1088
  return None
1081
1089
 
1082
1090
 
1091
+ def _opencode_local_plugin_spec(
1092
+ package_root: Path = _OPENCODE_LOCAL_PACKAGE_DIR,
1093
+ ) -> str:
1094
+ return package_root.resolve(strict=False).as_uri()
1095
+
1096
+
1097
+ def _is_default_opencode_package_path(package_path: Path) -> bool:
1098
+ if _same_real_path(package_path, _OPENCODE_LOCAL_PACKAGE_DIR):
1099
+ return True
1100
+ try:
1101
+ return package_path.resolve(strict=False) == _OPENCODE_LOCAL_PACKAGE_DIR.resolve(
1102
+ strict=False
1103
+ )
1104
+ except OSError:
1105
+ return False
1106
+
1107
+
1108
+ def _is_opencode_claude_smart_spec(spec: str | None) -> bool:
1109
+ if not spec:
1110
+ return False
1111
+ if spec == _OPENCODE_BARE_PLUGIN_SPEC or spec.startswith(
1112
+ f"{_OPENCODE_BARE_PLUGIN_SPEC}@"
1113
+ ):
1114
+ return True
1115
+ parsed = urlparse(spec)
1116
+ if parsed.scheme != "file":
1117
+ return False
1118
+ package_path = Path(url2pathname(parsed.path))
1119
+ if _is_default_opencode_package_path(package_path):
1120
+ return True
1121
+ try:
1122
+ manifest = json.loads((package_path / "package.json").read_text())
1123
+ if manifest.get("name") == _OPENCODE_BARE_PLUGIN_SPEC:
1124
+ return True
1125
+ except (OSError, json.JSONDecodeError):
1126
+ pass
1127
+ return False
1128
+
1129
+
1083
1130
  def _patch_opencode_plugin_config(
1084
- config_path: Path, *, install: bool
1131
+ config_path: Path, *, install: bool, plugin_spec: str | None = None
1085
1132
  ) -> tuple[bool, Path]:
1133
+ if install and plugin_spec is None:
1134
+ plugin_spec = _opencode_local_plugin_spec()
1086
1135
  data = _read_jsonc_object(config_path)
1087
1136
  for field in ("plugins", "plugin"):
1088
1137
  value = data.get(field)
@@ -1100,9 +1149,13 @@ def _patch_opencode_plugin_config(
1100
1149
  kept = [
1101
1150
  item
1102
1151
  for item in plugins
1103
- if _opencode_plugin_spec(item) != _OPENCODE_PLUGIN_SPEC
1152
+ if not _is_opencode_claude_smart_spec(_opencode_plugin_spec(item))
1104
1153
  ]
1105
- next_plugins = [*kept, _OPENCODE_PLUGIN_SPEC] if install else kept
1154
+ if install:
1155
+ assert plugin_spec is not None
1156
+ next_plugins = [*kept, plugin_spec]
1157
+ else:
1158
+ next_plugins = kept
1106
1159
  if isinstance(current_plugin, list):
1107
1160
  changed = ("plugins" in data) or next_plugins != current_plugin
1108
1161
  else:
@@ -1144,6 +1197,102 @@ def _opencode_install_supported_from_this_package() -> bool:
1144
1197
  return (_SCRIPTS_DIR / "smart-install.sh").is_file()
1145
1198
 
1146
1199
 
1200
+ def _file_sha256(path: Path) -> str:
1201
+ return sha256(path.read_bytes()).hexdigest()
1202
+
1203
+
1204
+ def _same_real_path(left: Path, right: Path) -> bool:
1205
+ try:
1206
+ return left.resolve(strict=True) == right.resolve(strict=True)
1207
+ except OSError:
1208
+ return False
1209
+
1210
+
1211
+ def _verify_opencode_plugin_package(package_root: Path) -> None:
1212
+ source_script = _PLUGIN_ROOT / "scripts" / "smart-install.sh"
1213
+ copied_script = package_root / "plugin" / "scripts" / "smart-install.sh"
1214
+ for path in (package_root / "package.json", copied_script):
1215
+ if not path.exists():
1216
+ raise OSError(f"OpenCode local plugin package is missing {path}")
1217
+ if _file_sha256(source_script) != _file_sha256(copied_script):
1218
+ raise OSError(
1219
+ "OpenCode local plugin package does not match the installed claude-smart package"
1220
+ )
1221
+
1222
+
1223
+ @contextmanager
1224
+ def _opencode_package_install_lock(package_root: Path) -> Iterator[None]:
1225
+ lock_dir = package_root.parent / ".install.lock"
1226
+ package_root.parent.mkdir(parents=True, exist_ok=True)
1227
+ deadline = time.monotonic() + _OPENCODE_PACKAGE_LOCK_TIMEOUT_SECONDS
1228
+ while True:
1229
+ try:
1230
+ lock_dir.mkdir()
1231
+ break
1232
+ except FileExistsError:
1233
+ try:
1234
+ stale = (
1235
+ time.time() - lock_dir.stat().st_mtime
1236
+ > _OPENCODE_PACKAGE_LOCK_STALE_SECONDS
1237
+ )
1238
+ except OSError:
1239
+ stale = False
1240
+ if stale:
1241
+ shutil.rmtree(lock_dir, ignore_errors=True)
1242
+ continue
1243
+ if time.monotonic() >= deadline:
1244
+ raise OSError(
1245
+ f"timed out waiting for OpenCode package install lock at {lock_dir}"
1246
+ )
1247
+ time.sleep(0.1)
1248
+ try:
1249
+ yield
1250
+ finally:
1251
+ shutil.rmtree(lock_dir, ignore_errors=True)
1252
+
1253
+
1254
+ def _unique_opencode_package_path(parent: Path, prefix: str) -> Path:
1255
+ return parent / f"{prefix}-{os.getpid()}-{time.monotonic_ns()}"
1256
+
1257
+
1258
+ def _replace_opencode_plugin_package(staged_package: Path, package_root: Path) -> None:
1259
+ backup_package = _unique_opencode_package_path(
1260
+ package_root.parent, ".claude-smart-previous"
1261
+ )
1262
+ backup_created = False
1263
+ try:
1264
+ if package_root.exists() or package_root.is_symlink():
1265
+ package_root.rename(backup_package)
1266
+ backup_created = True
1267
+ staged_package.rename(package_root)
1268
+ if backup_created:
1269
+ shutil.rmtree(backup_package, ignore_errors=True)
1270
+ except OSError:
1271
+ if backup_created and not package_root.exists() and backup_package.exists():
1272
+ backup_package.rename(package_root)
1273
+ raise
1274
+
1275
+
1276
+ def _install_opencode_plugin_package() -> Path:
1277
+ package_root = _OPENCODE_LOCAL_PACKAGE_DIR
1278
+ if _same_real_path(_REPO_ROOT, package_root):
1279
+ _verify_opencode_plugin_package(package_root)
1280
+ return package_root
1281
+ with _opencode_package_install_lock(package_root):
1282
+ staged_package = _unique_opencode_package_path(
1283
+ package_root.parent, ".claude-smart-copy"
1284
+ )
1285
+ shutil.rmtree(staged_package, ignore_errors=True)
1286
+ try:
1287
+ shutil.copytree(_REPO_ROOT, staged_package, ignore=_COPYTREE_IGNORE)
1288
+ _verify_opencode_plugin_package(staged_package)
1289
+ _replace_opencode_plugin_package(staged_package, package_root)
1290
+ _verify_opencode_plugin_package(package_root)
1291
+ finally:
1292
+ shutil.rmtree(staged_package, ignore_errors=True)
1293
+ return package_root
1294
+
1295
+
1147
1296
  def _bootstrap_opencode_install(read_only: bool) -> tuple[bool, str]:
1148
1297
  if not _opencode_install_supported_from_this_package():
1149
1298
  return (
@@ -1152,22 +1301,28 @@ def _bootstrap_opencode_install(read_only: bool) -> tuple[bool, str]:
1152
1301
  "Run `npx claude-smart install --host opencode`.",
1153
1302
  )
1154
1303
  try:
1155
- _force_plugin_root(_PLUGIN_ROOT)
1304
+ package_root = _install_opencode_plugin_package()
1305
+ except OSError as exc:
1306
+ return False, str(exc)
1307
+ plugin_root = package_root / "plugin"
1308
+ try:
1309
+ _force_plugin_root(plugin_root)
1156
1310
  except OSError as exc:
1157
1311
  return False, str(exc)
1158
1312
  bash = _resolve_bash()
1159
1313
  if not bash:
1160
1314
  return False, "bash is required to bootstrap claude-smart dependencies"
1161
- result = subprocess.run([bash, str(_SCRIPTS_DIR / "smart-install.sh")], cwd=_PLUGIN_ROOT)
1315
+ scripts_dir = plugin_root / "scripts"
1316
+ result = subprocess.run([bash, str(scripts_dir / "smart-install.sh")], cwd=plugin_root)
1162
1317
  if result.returncode != 0:
1163
- return False, f"smart-install.sh failed in {_PLUGIN_ROOT}"
1318
+ return False, f"smart-install.sh failed in {plugin_root}"
1164
1319
  if _INSTALL_FAILURE_MARKER.is_file():
1165
1320
  first_line = _INSTALL_FAILURE_MARKER.read_text().splitlines()
1166
1321
  reason = (first_line[0].strip() if first_line else "") or "unknown error"
1167
1322
  return False, reason
1168
1323
  if read_only:
1169
- _prune_publish_hooks_for_read_only(_PLUGIN_ROOT)
1170
- return True, str(_PLUGIN_ROOT)
1324
+ _prune_publish_hooks_for_read_only(plugin_root)
1325
+ return True, str(package_root)
1171
1326
 
1172
1327
 
1173
1328
  def cmd_install_opencode(args: argparse.Namespace) -> int:
@@ -1181,24 +1336,31 @@ def cmd_install_opencode(args: argparse.Namespace) -> int:
1181
1336
  if not _has_extraction_provider():
1182
1337
  sys.stderr.write(_extraction_provider_error())
1183
1338
  return 1
1184
- bootstrapped, message = _bootstrap_opencode_install(read_only)
1339
+ bootstrapped, package_root_text = _bootstrap_opencode_install(read_only)
1185
1340
  if not bootstrapped:
1186
1341
  sys.stderr.write(
1187
- f"error: claude-smart OpenCode setup failed during dependency bootstrap: {message}\n"
1342
+ f"error: claude-smart OpenCode setup failed during dependency bootstrap: {package_root_text}\n"
1188
1343
  )
1189
1344
  return 1
1345
+ package_root = Path(package_root_text)
1346
+ plugin_root = package_root / "plugin"
1347
+ plugin_spec = _opencode_local_plugin_spec(package_root)
1190
1348
  config_path = _opencode_config_path(
1191
1349
  global_config=bool(getattr(args, "global_config", False))
1192
1350
  )
1193
1351
  try:
1194
- changed, config_path = _patch_opencode_plugin_config(config_path, install=True)
1352
+ changed, config_path = _patch_opencode_plugin_config(
1353
+ config_path,
1354
+ install=True,
1355
+ plugin_spec=plugin_spec,
1356
+ )
1195
1357
  except ValueError as exc:
1196
1358
  sys.stderr.write(f"error: {exc}\n")
1197
1359
  return 1
1198
1360
  action = "Updated" if changed else "OpenCode config already includes"
1199
1361
  sys.stdout.write(
1200
- f"{action} `{_OPENCODE_PLUGIN_SPEC}` in {config_path}.\n"
1201
- f"Prepared claude-smart runtime at {message}.\n"
1362
+ f"{action} `{plugin_spec}` in {config_path}.\n"
1363
+ f"Prepared claude-smart runtime at {plugin_root}.\n"
1202
1364
  "Restart OpenCode in your project so it loads the plugin.\n"
1203
1365
  )
1204
1366
  return 0
@@ -1517,6 +1679,13 @@ def cmd_uninstall_codex(_args: argparse.Namespace) -> int:
1517
1679
  def cmd_uninstall_opencode(args: argparse.Namespace) -> int:
1518
1680
  _run_service(_DASHBOARD_SCRIPT, "stop")
1519
1681
  _run_service(_BACKEND_SCRIPT, "stop")
1682
+ local_scripts = _OPENCODE_LOCAL_PACKAGE_DIR / "plugin" / "scripts"
1683
+ for script in (
1684
+ local_scripts / "dashboard-service.sh",
1685
+ local_scripts / "backend-service.sh",
1686
+ ):
1687
+ if script.exists():
1688
+ _run_service(script, "stop")
1520
1689
  config_path = _opencode_config_path(
1521
1690
  global_config=bool(getattr(args, "global_config", False))
1522
1691
  )
@@ -1525,10 +1694,15 @@ def cmd_uninstall_opencode(args: argparse.Namespace) -> int:
1525
1694
  except ValueError as exc:
1526
1695
  sys.stderr.write(f"error: {exc}\n")
1527
1696
  return 1
1697
+ shutil.rmtree(_OPENCODE_LOCAL_PACKAGE_DIR, ignore_errors=True)
1698
+ try:
1699
+ _OPENCODE_LOCAL_PACKAGE_DIR.parent.rmdir()
1700
+ except OSError:
1701
+ pass
1528
1702
  if changed:
1529
- sys.stdout.write(f"Removed `{_OPENCODE_PLUGIN_SPEC}` from {config_path}.\n")
1703
+ sys.stdout.write(f"Removed claude-smart OpenCode plugin entries from {config_path}.\n")
1530
1704
  else:
1531
- sys.stdout.write(f"OpenCode config did not include `{_OPENCODE_PLUGIN_SPEC}`.\n")
1705
+ sys.stdout.write("OpenCode config did not include claude-smart.\n")
1532
1706
  sys.stdout.write(
1533
1707
  "Restart OpenCode to apply. "
1534
1708
  f"{_LOCAL_DATA_NOTICE}"
@@ -1572,12 +1746,26 @@ def cmd_show(args: argparse.Namespace) -> int:
1572
1746
  f"First error: {adapter.read_errors[0]}\n"
1573
1747
  )
1574
1748
  return 1
1575
- sys.stdout.write(
1576
- md or f"_No skills or preferences yet for project `{project_id}`._\n"
1577
- )
1749
+ body = md or f"_No skills or preferences yet for project `{project_id}`._\n"
1750
+ sys.stdout.write(body + _reflexio_show_footer())
1578
1751
  return 0
1579
1752
 
1580
1753
 
1754
+ def _reflexio_show_footer() -> str:
1755
+ """Return the markdown attribution footer appended to ``show`` output.
1756
+
1757
+ Reinforces that claude-smart is powered by the open-source reflexio engine
1758
+ and nudges users to star the repo.
1759
+
1760
+ Returns:
1761
+ str: A leading-separator markdown footer ending in a newline.
1762
+ """
1763
+ return (
1764
+ f"\n---\n⭐ claude-smart is powered by [reflexio]({cs_cite.REFLEXIO_REPO_URL})"
1765
+ f" — if it helps you, [star it on GitHub]({cs_cite.REFLEXIO_REPO_URL}).\n"
1766
+ )
1767
+
1768
+
1581
1769
  def cmd_learn(args: argparse.Namespace) -> int:
1582
1770
  """Force reflexio extraction over the active session's interactions.
1583
1771