@objctp/opencode-shell-routines 1.3.1 → 1.3.2

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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objctp/opencode-shell-routines",
3
- "version": "1.3.1",
3
+ "version": "1.3.2",
4
4
  "description": "Shell script development toolkit — automated scaffolding, best practices, and quality enforcement for OpenCode and Claude Code",
5
5
  "author": {
6
6
  "name": "objct",
@@ -21,9 +21,11 @@ export const ShellHooksPlugin: Plugin = async (
21
21
  options?: PluginOptions,
22
22
  ): Promise<Hooks> => {
23
23
  // :::: Self-install bundled content (npm plugins only) :::: //////////////
24
- // Copy skills/commands/agents/scripts into the OpenCode config dir so
25
- // OpenCode discovers them. Failures are logged and swallowed — they must
26
- // never block the quality-check hook below.
24
+ // Failures are logged and swallowed they must never block the hook below.
25
+ const explicitScope =
26
+ options?.scope === "project" || options?.scope === "global"
27
+ ? options.scope
28
+ : undefined;
27
29
  try {
28
30
  const packageRoot = path.resolve(import.meta.dirname, "..");
29
31
  const pkg = JSON.parse(
@@ -32,8 +34,9 @@ export const ShellHooksPlugin: Plugin = async (
32
34
  syncContent({
33
35
  packageRoot,
34
36
  version: pkg.version,
37
+ packageName: pkg.name,
35
38
  client,
36
- scope: options?.scope === "project" ? "project" : "global",
39
+ scope: explicitScope,
37
40
  directory,
38
41
  });
39
42
  } catch (error) {
@@ -1,15 +1,10 @@
1
- // Self-install bundled content for OpenCode consumers.
1
+ // OpenCode loads only a plugin's `./server` entrypoint — it does not discover
2
+ // the bundled agents/commands/skills/scripts (separate scanners read config
3
+ // dirs only), and `postinstall` can't run (OpenCode installs with
4
+ // ignoreScripts). So on load the plugin copies its own content into the config
5
+ // dir matching the install scope, rewriting ${CLAUDE_PLUGIN_ROOT} → that dir.
2
6
  //
3
- // OpenCode's plugin loader imports only the `./server` entrypoint of an npm
4
- // plugin; it does not discover the bundled `agents/ commands/ skills/ scripts/`
5
- // directories (those come from separate scanners that read config dirs only),
6
- // and npm `postinstall` cannot run (OpenCode installs with `ignoreScripts`).
7
- // So on load the plugin copies its own content into the OpenCode config
8
- // directory, rewriting `${CLAUDE_PLUGIN_ROOT}` references to that directory so
9
- // script-sourcing resolves to the copied location.
10
- //
11
- // All real work here is synchronous filesystem I/O; logging is fire-and-forget.
12
- // `syncContent` never throws — any failure is logged and swallowed.
7
+ // All work is synchronous FS I/O; logging is fire-and-forget. Never throws.
13
8
 
14
9
  import {
15
10
  chmodSync,
@@ -20,57 +15,79 @@ import {
20
15
  readdirSync,
21
16
  readFileSync,
22
17
  readlinkSync,
18
+ unlinkSync,
23
19
  writeFileSync,
24
20
  } from "node:fs";
25
21
  import path from "node:path";
26
22
  import os from "node:os";
27
23
  import type { PluginInput } from "@opencode-ai/plugin";
28
24
 
29
- // `scripts/` is not scanned by OpenCode — it is synced only so the rewritten
30
- // `${CLAUDE_PLUGIN_ROOT}/scripts/lib-*.sh` references resolve.
25
+ // scripts/ isn't scanned by OpenCode — synced only so the rewritten
26
+ // ${CLAUDE_PLUGIN_ROOT}/scripts/lib-*.sh references resolve.
31
27
  const DIRS = ["agents", "commands", "skills", "scripts"] as const;
32
28
 
33
- // Text files whose `${CLAUDE_PLUGIN_ROOT}` tokens are rewritten at copy time.
34
- // Everything else is copied verbatim.
35
29
  const TEXT_EXT = new Set(["md", "sh", "bash", "zsh", "ksh", "txt", "json"]);
36
30
 
37
- // The defaulted variant must be replaced first, otherwise the plain token would
38
- // leave a stray `:-}` behind.
31
+ // Replace the defaulted variant first, or the plain token leaves a stray `:-}`.
39
32
  const TOKEN_DEFAULTED = "${CLAUDE_PLUGIN_ROOT:-}";
40
33
  const TOKEN_PLAIN = "${CLAUDE_PLUGIN_ROOT}";
41
34
 
42
- // Records the package version last synced into the config directory. Absent or
43
- // mismatched re-sync. Written only after a complete successful copy so a
44
- // partial failure self-heals on the next load.
45
- const MARKER_FILE = ".shell-routines.version";
35
+ // State lives in OpenCode's state dir (next to plugin-meta.json), not the config
36
+ // dir, so it never pollutes ~/.config/opencode or a project's .opencode/.
37
+ // Keyed by destination dir: global and each project-local sync are independent.
38
+ const STATE_DIR = path.join(
39
+ os.homedir(),
40
+ ".local",
41
+ "state",
42
+ "opencode",
43
+ "shell-routines",
44
+ );
45
+ const STATE_FILE = "sync-state.json";
46
+
47
+ // <=1.3.1 wrote this into the config dir; remove on sight to migrate.
48
+ const LEGACY_MARKER = ".shell-routines.version";
49
+
50
+ const PROJECT_CONFIGS = [
51
+ "opencode.json",
52
+ "opencode.jsonc",
53
+ ".opencode/opencode.json",
54
+ ".opencode/opencode.jsonc",
55
+ ];
46
56
 
47
57
  export type SyncScope = "global" | "project";
48
58
 
49
59
  export interface SyncOptions {
50
- /** Absolute path to the installed package directory (sibling of agents/commands/skills/scripts). */
51
60
  packageRoot: string;
52
- /** Package version, read from `<packageRoot>/package.json`. */
53
61
  version: string;
54
- /** OpenCode client, used for structured logging. */
62
+ /** Used to auto-detect scope from the project's config files. */
63
+ packageName: string;
55
64
  client: PluginInput["client"];
56
- /** Sync scope. Defaults to "global". */
65
+ /** Overrides auto-detected scope. */
57
66
  scope?: SyncScope;
58
- /** Project directory; required when scope is "project". */
67
+ /** Project directory; required when scope resolves to "project". */
59
68
  directory?: string;
60
- /** Override the destination config directory (used by tests). */
69
+ /** Override the destination config directory (tests). */
61
70
  configDirOverride?: string;
71
+ /** Override the state directory (tests). */
72
+ stateDirOverride?: string;
62
73
  }
63
74
 
64
75
  /**
65
- * Copy the plugin's bundled agents/commands/skills/scripts into the OpenCode
66
- * config directory so OpenCode's content scanners discover them, rewriting
67
- * `${CLAUDE_PLUGIN_ROOT}` references to the resolved config directory.
68
- *
69
- * Idempotent via a version marker written only after a complete, successful
70
- * sync. Never throws.
76
+ * Copy agents/commands/skills/scripts into the config dir matching the install
77
+ * scope (auto-detected: listed in a project config ⇒ project-local, else
78
+ * global), rewriting ${CLAUDE_PLUGIN_ROOT} that dir. Idempotent via a
79
+ * per-target version record in the state dir. Never throws.
71
80
  */
72
81
  export function syncContent(opts: SyncOptions): void {
73
- const { packageRoot, version, client, scope = "global", directory } = opts;
82
+ const {
83
+ packageRoot,
84
+ version,
85
+ packageName,
86
+ client,
87
+ directory,
88
+ configDirOverride,
89
+ stateDirOverride,
90
+ } = opts;
74
91
 
75
92
  const log = (
76
93
  level: "debug" | "info" | "warn" | "error",
@@ -87,15 +104,13 @@ export function syncContent(opts: SyncOptions): void {
87
104
  }
88
105
  };
89
106
 
90
- // npm-installed plugins only. In file-plugin/dev mode the content already
91
- // lives where OpenCode discovers it, and the layout differs (no sibling
92
- // scripts/), so copying would produce a broken tree.
107
+ // File-plugin/dev mode already exposes content where OpenCode finds it, and
108
+ // its layout differs (no sibling scripts/).
93
109
  if (!packageRoot.includes("node_modules")) {
94
110
  log("debug", "content sync skipped (not an npm install)");
95
111
  return;
96
112
  }
97
113
 
98
- // Fail safe on an unexpected package layout.
99
114
  if (!existsSync(path.join(packageRoot, "scripts"))) {
100
115
  log("warn", "content sync skipped (unexpected package layout)", {
101
116
  packageRoot,
@@ -103,39 +118,105 @@ export function syncContent(opts: SyncOptions): void {
103
118
  return;
104
119
  }
105
120
 
106
- const configDir =
107
- opts.configDirOverride ??
121
+ const scope = opts.scope ?? detectInstallScope(directory, packageName);
122
+ const configDir = configDirOverride ??
108
123
  (scope === "project" && directory
109
124
  ? path.join(directory, ".opencode")
110
125
  : path.join(os.homedir(), ".config", "opencode"));
111
126
 
112
- const marker = path.join(configDir, MARKER_FILE);
113
- if (
114
- existsSync(marker) &&
115
- readFileSync(marker, "utf8").trim() === version
116
- ) {
117
- log("debug", "content sync skipped (already up to date)", { version });
127
+ const stateDir = stateDirOverride ?? STATE_DIR;
128
+ const stateFile = path.join(stateDir, STATE_FILE);
129
+ const synced: Record<string, string> = readState(stateFile);
130
+ if (synced[configDir] === version) {
131
+ log("debug", "content sync skipped (already up to date)", {
132
+ configDir,
133
+ version,
134
+ });
118
135
  return;
119
136
  }
120
137
 
121
138
  mkdirSync(configDir, { recursive: true });
139
+ removeLegacyMarker(configDir);
122
140
 
123
141
  try {
124
142
  for (const dir of DIRS) {
125
143
  syncDir(path.join(packageRoot, dir), path.join(configDir, dir), configDir);
126
144
  }
127
145
  } catch (error) {
128
- log("error", "content sync failed", { error: String(error) });
129
- return; // marker untouched → next load retries
146
+ log("error", "content sync failed", { error: String(error), configDir });
147
+ return; // state untouched → next load retries
130
148
  }
131
149
 
132
- writeFileSync(marker, version);
150
+ synced[configDir] = version;
151
+ mkdirSync(stateDir, { recursive: true });
152
+ writeFileSync(stateFile, JSON.stringify(synced, null, 2) + "\n");
133
153
  log("info", "synced shell-routines skills/commands/agents/scripts", {
134
154
  configDir,
155
+ scope,
135
156
  version,
136
157
  });
137
158
  }
138
159
 
160
+ /** "project" if packageName is in any project config's `plugin` array, else "global". */
161
+ function detectInstallScope(
162
+ directory: string | undefined,
163
+ packageName: string,
164
+ ): SyncScope {
165
+ if (!directory) return "global";
166
+ for (const rel of PROJECT_CONFIGS) {
167
+ const file = path.join(directory, rel);
168
+ if (!existsSync(file)) continue;
169
+ try {
170
+ const cfg = JSON.parse(stripJsonc(readFileSync(file, "utf8"))) as {
171
+ plugin?: unknown[];
172
+ };
173
+ if (
174
+ Array.isArray(cfg.plugin) &&
175
+ cfg.plugin.some((e) => matchesPlugin(e, packageName))
176
+ ) {
177
+ return "project";
178
+ }
179
+ } catch {
180
+ // Unreadable config — keep checking the rest.
181
+ }
182
+ }
183
+ return "global";
184
+ }
185
+
186
+ function matchesPlugin(entry: unknown, packageName: string): boolean {
187
+ const spec = Array.isArray(entry) ? entry[0] : entry;
188
+ return typeof spec === "string" && spec.startsWith(packageName);
189
+ }
190
+
191
+ function stripJsonc(text: string): string {
192
+ return text
193
+ .replace(/\/\*[\s\S]*?\*\//g, "")
194
+ .replace(/(^|[^:\\])\/\/.*$/gm, "$1");
195
+ }
196
+
197
+ function readState(stateFile: string): Record<string, string> {
198
+ try {
199
+ if (existsSync(stateFile)) {
200
+ return JSON.parse(readFileSync(stateFile, "utf8")) as Record<
201
+ string,
202
+ string
203
+ >;
204
+ }
205
+ } catch {
206
+ // Corrupt state — empty so a re-sync repairs it.
207
+ }
208
+ return {};
209
+ }
210
+
211
+ function removeLegacyMarker(configDir: string): void {
212
+ const legacy = path.join(configDir, LEGACY_MARKER);
213
+ try {
214
+ if (existsSync(legacy)) unlinkSync(legacy);
215
+ } catch {
216
+ // best effort
217
+ }
218
+ }
219
+
139
220
  function syncDir(src: string, dest: string, configDir: string): void {
140
221
  if (!existsSync(src)) return;
141
222
  mkdirSync(dest, { recursive: true });
@@ -145,7 +226,7 @@ function syncDir(src: string, dest: string, configDir: string): void {
145
226
  const stat = lstatSync(srcPath);
146
227
 
147
228
  if (stat.isSymbolicLink()) {
148
- // Dereference — defensive, since dist files are real but repo sources symlink.
229
+ // Dereference — defensive; dist files are real but repo sources symlink.
149
230
  const target = path.resolve(
150
231
  path.dirname(srcPath),
151
232
  readlinkSync(srcPath),
@@ -176,6 +257,6 @@ function syncFile(
176
257
  } else {
177
258
  cpSync(src, dest);
178
259
  }
179
- // Preserve the source mode (exec bit): batch examples stay 0644, runtime libs 0755.
260
+ // Preserve source mode: examples stay 0644, runtime libs 0755.
180
261
  chmodSync(dest, mode & 0o777);
181
262
  }