@objctp/opencode-shell-routines 1.2.0 → 1.3.0

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/README.md CHANGED
@@ -56,6 +56,20 @@ Add to your config — OpenCode auto-installs npm plugins via Bun at startup.
56
56
  git clone https://github.com/objctp/shell-routines && cd shell-routines && opencode
57
57
  ```
58
58
 
59
+ > **How the OpenCode plugin registers its content:** OpenCode loads only the
60
+ > plugin's server entrypoint from the npm package, so on first load the plugin
61
+ > copies its bundled `agents/ commands/ skills/ scripts/` into
62
+ > `~/.config/opencode/`, where OpenCode's scanners discover them. **Restart
63
+ > OpenCode once after installing** for skills, commands, and agents to register.
64
+ > The copies are re-synced automatically when the package version changes
65
+ > (overwriting the synced files) — to customise, edit copies under your
66
+ > project's `.opencode/` instead. For project-local scope, configure the plugin
67
+ > with options:
68
+ >
69
+ > ```jsonc
70
+ > { "plugin": [["@objctp/opencode-shell-routines", { "scope": "project" }]] }
71
+ > ```
72
+
59
73
  ## Components
60
74
 
61
75
  | Component | Purpose | Trigger |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objctp/opencode-shell-routines",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
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,6 +21,11 @@
21
21
  "code-quality",
22
22
  "bash-scripting"
23
23
  ],
24
+ "exports": {
25
+ "./server": {
26
+ "import": "./plugins/shell-hooks.ts"
27
+ }
28
+ },
24
29
  "files": [
25
30
  "agents/",
26
31
  "commands/",
@@ -0,0 +1,181 @@
1
+ // Self-install bundled content for OpenCode consumers.
2
+ //
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.
13
+
14
+ import {
15
+ chmodSync,
16
+ cpSync,
17
+ existsSync,
18
+ lstatSync,
19
+ mkdirSync,
20
+ readdirSync,
21
+ readFileSync,
22
+ readlinkSync,
23
+ writeFileSync,
24
+ } from "node:fs";
25
+ import path from "node:path";
26
+ import os from "node:os";
27
+ import type { PluginInput } from "@opencode-ai/plugin";
28
+
29
+ // `scripts/` is not scanned by OpenCode — it is synced only so the rewritten
30
+ // `${CLAUDE_PLUGIN_ROOT}/scripts/lib-*.sh` references resolve.
31
+ const DIRS = ["agents", "commands", "skills", "scripts"] as const;
32
+
33
+ // Text files whose `${CLAUDE_PLUGIN_ROOT}` tokens are rewritten at copy time.
34
+ // Everything else is copied verbatim.
35
+ const TEXT_EXT = new Set(["md", "sh", "bash", "zsh", "ksh", "txt", "json"]);
36
+
37
+ // The defaulted variant must be replaced first, otherwise the plain token would
38
+ // leave a stray `:-}` behind.
39
+ const TOKEN_DEFAULTED = "${CLAUDE_PLUGIN_ROOT:-}";
40
+ const TOKEN_PLAIN = "${CLAUDE_PLUGIN_ROOT}";
41
+
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";
46
+
47
+ export type SyncScope = "global" | "project";
48
+
49
+ export interface SyncOptions {
50
+ /** Absolute path to the installed package directory (sibling of agents/commands/skills/scripts). */
51
+ packageRoot: string;
52
+ /** Package version, read from `<packageRoot>/package.json`. */
53
+ version: string;
54
+ /** OpenCode client, used for structured logging. */
55
+ client: PluginInput["client"];
56
+ /** Sync scope. Defaults to "global". */
57
+ scope?: SyncScope;
58
+ /** Project directory; required when scope is "project". */
59
+ directory?: string;
60
+ /** Override the destination config directory (used by tests). */
61
+ configDirOverride?: string;
62
+ }
63
+
64
+ /**
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.
71
+ */
72
+ export function syncContent(opts: SyncOptions): void {
73
+ const { packageRoot, version, client, scope = "global", directory } = opts;
74
+
75
+ const log = (
76
+ level: "debug" | "info" | "warn" | "error",
77
+ message: string,
78
+ extra?: Record<string, unknown>,
79
+ ) => {
80
+ try {
81
+ const ret = client.app.log({
82
+ body: { service: "shell-routines", level, message, extra },
83
+ });
84
+ if (ret && typeof ret.catch === "function") ret.catch(() => {});
85
+ } catch {
86
+ // Logging must never break the plugin.
87
+ }
88
+ };
89
+
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.
93
+ if (!packageRoot.includes("node_modules")) {
94
+ log("debug", "content sync skipped (not an npm install)");
95
+ return;
96
+ }
97
+
98
+ // Fail safe on an unexpected package layout.
99
+ if (!existsSync(path.join(packageRoot, "scripts"))) {
100
+ log("warn", "content sync skipped (unexpected package layout)", {
101
+ packageRoot,
102
+ });
103
+ return;
104
+ }
105
+
106
+ const configDir =
107
+ opts.configDirOverride ??
108
+ (scope === "project" && directory
109
+ ? path.join(directory, ".opencode")
110
+ : path.join(os.homedir(), ".config", "opencode"));
111
+
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 });
118
+ return;
119
+ }
120
+
121
+ mkdirSync(configDir, { recursive: true });
122
+
123
+ try {
124
+ for (const dir of DIRS) {
125
+ syncDir(path.join(packageRoot, dir), path.join(configDir, dir), configDir);
126
+ }
127
+ } catch (error) {
128
+ log("error", "content sync failed", { error: String(error) });
129
+ return; // marker untouched → next load retries
130
+ }
131
+
132
+ writeFileSync(marker, version);
133
+ log("info", "synced shell-routines skills/commands/agents/scripts", {
134
+ configDir,
135
+ version,
136
+ });
137
+ }
138
+
139
+ function syncDir(src: string, dest: string, configDir: string): void {
140
+ if (!existsSync(src)) return;
141
+ mkdirSync(dest, { recursive: true });
142
+ for (const entry of readdirSync(src)) {
143
+ const srcPath = path.join(src, entry);
144
+ const destPath = path.join(dest, entry);
145
+ const stat = lstatSync(srcPath);
146
+
147
+ if (stat.isSymbolicLink()) {
148
+ // Dereference — defensive, since dist files are real but repo sources symlink.
149
+ const target = path.resolve(
150
+ path.dirname(srcPath),
151
+ readlinkSync(srcPath),
152
+ );
153
+ syncDir(target, destPath, configDir);
154
+ } else if (stat.isDirectory()) {
155
+ syncDir(srcPath, destPath, configDir);
156
+ } else {
157
+ syncFile(srcPath, destPath, stat.mode, configDir);
158
+ }
159
+ }
160
+ }
161
+
162
+ function syncFile(
163
+ src: string,
164
+ dest: string,
165
+ mode: number,
166
+ configDir: string,
167
+ ): void {
168
+ const ext = path.extname(src).slice(1).toLowerCase();
169
+ if (TEXT_EXT.has(ext)) {
170
+ const rewritten = readFileSync(src, "utf8")
171
+ .split(TOKEN_DEFAULTED)
172
+ .join(configDir)
173
+ .split(TOKEN_PLAIN)
174
+ .join(configDir);
175
+ writeFileSync(dest, rewritten);
176
+ } else {
177
+ cpSync(src, dest);
178
+ }
179
+ // Preserve the source mode (exec bit): batch examples stay 0644, runtime libs 0755.
180
+ chmodSync(dest, mode & 0o777);
181
+ }
@@ -1,4 +1,12 @@
1
- import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
1
+ import { readFileSync } from "node:fs";
2
+ import path from "node:path";
3
+ import type {
4
+ Hooks,
5
+ Plugin,
6
+ PluginInput,
7
+ PluginOptions,
8
+ } from "@opencode-ai/plugin";
9
+ import { syncContent } from "./setup-content.ts";
2
10
 
3
11
  const SHELL_EXTENSIONS = new Set(["sh", "bash", "zsh", "ksh"]);
4
12
  const SHEBANG_PATTERN = /^#!.*\b(bash|sh|zsh|ksh)\b/;
@@ -12,8 +20,38 @@ async function hasCmd($: PluginInput["$"], cmd: string): Promise<boolean> {
12
20
  }
13
21
 
14
22
  export const ShellHooksPlugin: Plugin = async (
15
- { $ }: PluginInput,
23
+ { $, client, directory }: PluginInput,
24
+ options?: PluginOptions,
16
25
  ): Promise<Hooks> => {
26
+ // Self-install bundled skills/commands/agents/scripts into the OpenCode config
27
+ // directory so OpenCode discovers them (npm plugins only). Failures are logged
28
+ // and swallowed — they must never block the quality-check hook below.
29
+ try {
30
+ const packageRoot = path.resolve(import.meta.dirname, "..");
31
+ const pkg = JSON.parse(
32
+ readFileSync(path.join(packageRoot, "package.json"), "utf8"),
33
+ );
34
+ syncContent({
35
+ packageRoot,
36
+ version: pkg.version,
37
+ client,
38
+ scope: options?.scope === "project" ? "project" : "global",
39
+ directory,
40
+ });
41
+ } catch (error) {
42
+ try {
43
+ void client.app.log({
44
+ body: {
45
+ service: "shell-routines",
46
+ level: "warn",
47
+ message: "content sync failed",
48
+ extra: { error: String(error) },
49
+ },
50
+ });
51
+ // deno-lint-ignore no-empty
52
+ } catch {}
53
+ }
54
+
17
55
  const hasShellcheck = await hasCmd($, "shellcheck");
18
56
  const hasCheckbashisms = await hasCmd($, "checkbashisms");
19
57
  return {
@@ -148,3 +186,11 @@ export const ShellHooksPlugin: Plugin = async (
148
186
  },
149
187
  };
150
188
  };
189
+
190
+ // OpenCode resolves the `exports["./server"]` entry in dist/package.json and
191
+ // expects a V1 plugin module that default-exports `{ id, server }`. The named
192
+ // export above is kept for direct imports and tests.
193
+ export default {
194
+ id: "shell-routines",
195
+ server: ShellHooksPlugin,
196
+ };