@objctp/opencode-shell-routines 1.2.1 → 1.3.1

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.1",
3
+ "version": "1.3.1",
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",
@@ -1,10 +1,15 @@
1
- import type { Hooks, Plugin, PluginInput } from "@opencode-ai/plugin";
2
-
3
- const SHELL_EXTENSIONS = new Set(["sh", "bash", "zsh", "ksh"]);
4
- const SHEBANG_PATTERN = /^#!.*\b(bash|sh|zsh|ksh)\b/;
5
- const DASH_PATTERN = /#!.*\bdash\b/;
6
- const SH_ONLY_PATTERN = /#!.*\bsh\b/;
7
- const BASH_FAMILY_PATTERN = /#!.*\b(bash|zsh|ksh)\b/;
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 { detectDialect, isShellFile } from "./shell-routines/dialect";
10
+ import { runQualityChecks } from "./shell-routines/quality-checks";
11
+ import { syncContent } from "./shell-routines/setup-content";
12
+ import type { ShellToolInput, ShellToolOutput } from "./shell-routines/types";
8
13
 
9
14
  async function hasCmd($: PluginInput["$"], cmd: string): Promise<boolean> {
10
15
  const r = await $`command -v ${cmd}`.nothrow();
@@ -12,19 +17,47 @@ async function hasCmd($: PluginInput["$"], cmd: string): Promise<boolean> {
12
17
  }
13
18
 
14
19
  export const ShellHooksPlugin: Plugin = async (
15
- { $ }: PluginInput,
20
+ { $, client, directory }: PluginInput,
21
+ options?: PluginOptions,
16
22
  ): Promise<Hooks> => {
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.
27
+ try {
28
+ const packageRoot = path.resolve(import.meta.dirname, "..");
29
+ const pkg = JSON.parse(
30
+ readFileSync(path.join(packageRoot, "package.json"), "utf8"),
31
+ );
32
+ syncContent({
33
+ packageRoot,
34
+ version: pkg.version,
35
+ client,
36
+ scope: options?.scope === "project" ? "project" : "global",
37
+ directory,
38
+ });
39
+ } catch (error) {
40
+ try {
41
+ void client.app.log({
42
+ body: {
43
+ service: "shell-routines",
44
+ level: "warn",
45
+ message: "content sync failed",
46
+ extra: { error: String(error) },
47
+ },
48
+ });
49
+ // deno-lint-ignore no-empty
50
+ } catch {}
51
+ }
52
+
53
+ // :::: Detect available quality tools :::: ///////////////////////////////
17
54
  const hasShellcheck = await hasCmd($, "shellcheck");
18
55
  const hasCheckbashisms = await hasCmd($, "checkbashisms");
56
+
19
57
  return {
20
58
  "tool.execute.after": async (
21
- input: {
22
- tool: string;
23
- sessionID: string;
24
- callID: string;
25
- args: { file_path?: string; filePath?: string; [key: string]: unknown };
26
- },
27
- output: { title: string; output: string; metadata: unknown },
59
+ input: ShellToolInput,
60
+ output: ShellToolOutput,
28
61
  ) => {
29
62
  if (input.tool !== "write" && input.tool !== "edit") return;
30
63
 
@@ -32,7 +65,7 @@ export const ShellHooksPlugin: Plugin = async (
32
65
  input.args?.filePath;
33
66
  if (!filePath) return;
34
67
 
35
- // Canonicalise and verify file exists
68
+ // Canonicalise and verify the file exists.
36
69
  let resolved: string;
37
70
  try {
38
71
  resolved = await $`realpath ${filePath}`.nothrow().text();
@@ -47,7 +80,7 @@ export const ShellHooksPlugin: Plugin = async (
47
80
 
48
81
  const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
49
82
 
50
- // Read first line once — needed for shebang check and dialect detection
83
+ // Read first line once — needed for shebang check and dialect detection.
51
84
  let firstLine: string;
52
85
  try {
53
86
  firstLine = await $`head -1 ${resolved}`.nothrow().text();
@@ -55,91 +88,18 @@ export const ShellHooksPlugin: Plugin = async (
55
88
  return;
56
89
  }
57
90
 
58
- if (!SHELL_EXTENSIONS.has(ext) && !SHEBANG_PATTERN.test(firstLine)) {
59
- return;
60
- }
61
-
62
- let dialect = "bash";
63
- let isPosix = false;
64
- if (DASH_PATTERN.test(firstLine)) {
65
- dialect = "dash";
66
- isPosix = true;
67
- } else if (
68
- SH_ONLY_PATTERN.test(firstLine) && !BASH_FAMILY_PATTERN.test(firstLine)
69
- ) {
70
- dialect = "sh";
71
- isPosix = true;
72
- }
73
-
74
- const findings: string[] = [];
91
+ if (!isShellFile(ext, firstLine)) return;
75
92
 
76
- // ShellCheck — findings on stdout, exits non-zero on issues
77
- if (hasShellcheck) {
78
- try {
79
- const sc = await $`shellcheck -s ${dialect} ${resolved} 2>&1`
80
- .nothrow().text();
81
- if (sc.trim()) {
82
- findings.push(
83
- `ShellCheck findings in ${resolved} (shell=${dialect}):\n${sc.trim()}`,
84
- );
85
- }
86
- // deno-lint-ignore no-empty
87
- } catch {}
88
- }
93
+ const { dialect, isPosix } = detectDialect(firstLine);
89
94
 
90
- if (!isPosix) {
91
- try {
92
- const syntax = await $`bash -n ${resolved} 2>&1`.nothrow().text();
93
- if (syntax.trim()) {
94
- findings.push(`Syntax error in ${resolved}: ${syntax.trim()}`);
95
- }
96
- // deno-lint-ignore no-empty
97
- } catch {}
98
- }
99
-
100
- if (isPosix && hasCheckbashisms) {
101
- try {
102
- const bashisms = await $`checkbashisms ${resolved} 2>&1`.nothrow()
103
- .text();
104
- if (bashisms.trim()) {
105
- findings.push(
106
- `POSIX compatibility issue in ${resolved} — bashisms detected:\n${bashisms.trim()}\n` +
107
- "Note: /bin/sh is dash on Ubuntu/Debian. These will fail at runtime.",
108
- );
109
- }
110
- // deno-lint-ignore no-empty
111
- } catch {}
112
- }
113
-
114
- // TODO/FIXME/HACK/XXX/BUG markers
115
- try {
116
- const todos =
117
- await $`grep -n -E '(^|[^[:alnum:]_])(TODO|FIXME|HACK|XXX|BUG):' ${resolved}`
118
- .nothrow()
119
- .text();
120
- if (todos.trim()) {
121
- findings.push(`Unresolved markers in ${resolved}:\n${todos.trim()}`);
122
- }
123
- // deno-lint-ignore no-empty
124
- } catch {}
125
-
126
- // Batch script pattern validation
127
- try {
128
- const content = await $`cat ${resolved}`.nothrow().text();
129
- if (content.includes("lib-batch.sh")) {
130
- if (!content.includes("batch_output")) {
131
- findings.push(
132
- `Batch script detected in ${resolved}: ensure batch_output() is called to return JSON results`,
133
- );
134
- }
135
- if (!content.includes("declare -A RESULTS")) {
136
- findings.push(
137
- `Batch script detected in ${resolved}: declare RESULTS array with: declare -A RESULTS`,
138
- );
139
- }
140
- }
141
- // deno-lint-ignore no-empty
142
- } catch {}
95
+ const findings = await runQualityChecks({
96
+ $,
97
+ resolved,
98
+ dialect,
99
+ isPosix,
100
+ hasShellcheck,
101
+ hasCheckbashisms,
102
+ });
143
103
 
144
104
  if (findings.length > 0) {
145
105
  output.output += "\n\n---\n**Shell quality checks:**\n" +
@@ -150,8 +110,7 @@ export const ShellHooksPlugin: Plugin = async (
150
110
  };
151
111
 
152
112
  // OpenCode resolves the `exports["./server"]` entry in dist/package.json and
153
- // expects a V1 plugin module that default-exports `{ id, server }`. The named
154
- // export above is kept for direct imports and tests.
113
+ // expects a V1 plugin module that default-exports `{ id, server }`.
155
114
  export default {
156
115
  id: "shell-routines",
157
116
  server: ShellHooksPlugin,
@@ -0,0 +1,25 @@
1
+ import type { DialectResult } from "./types";
2
+
3
+ // :::: Shell classification :::: ///////////////////////////
4
+
5
+ const SHELL_EXTENSIONS = new Set(["sh", "bash", "zsh", "ksh"]);
6
+ const SHEBANG_PATTERN = /^#!.*\b(bash|sh|zsh|ksh)\b/;
7
+ const DASH_PATTERN = /#!.*\bdash\b/;
8
+ const SH_ONLY_PATTERN = /#!.*\bsh\b/;
9
+ const BASH_FAMILY_PATTERN = /#!.*\b(bash|zsh|ksh)\b/;
10
+
11
+ /** A shell file if the extension matches or the shebang names a shell. */
12
+ export function isShellFile(ext: string, firstLine: string): boolean {
13
+ return SHELL_EXTENSIONS.has(ext) || SHEBANG_PATTERN.test(firstLine);
14
+ }
15
+
16
+ /** Classify the shell dialect from the shebang. Defaults to bash. */
17
+ export function detectDialect(firstLine: string): DialectResult {
18
+ if (DASH_PATTERN.test(firstLine)) {
19
+ return { dialect: "dash", isPosix: true };
20
+ }
21
+ if (SH_ONLY_PATTERN.test(firstLine) && !BASH_FAMILY_PATTERN.test(firstLine)) {
22
+ return { dialect: "sh", isPosix: true };
23
+ }
24
+ return { dialect: "bash", isPosix: false };
25
+ }
@@ -0,0 +1,97 @@
1
+ import type { PluginInput } from "@opencode-ai/plugin";
2
+ import type { Dialect } from "./types";
3
+
4
+ export interface QualityCheckDeps {
5
+ $: PluginInput["$"];
6
+ /** Absolute, verified-existing path to the shell file. */
7
+ resolved: string;
8
+ dialect: Dialect;
9
+ isPosix: boolean;
10
+ hasShellcheck: boolean;
11
+ hasCheckbashisms: boolean;
12
+ }
13
+
14
+ /**
15
+ * Run all enabled quality checks against a resolved shell file and return the
16
+ * human-readable findings (empty if clean). Each check is independently
17
+ * fault-tolerant — a missing tool or failed command never aborts the others.
18
+ */
19
+ export async function runQualityChecks(
20
+ deps: QualityCheckDeps,
21
+ ): Promise<string[]> {
22
+ const { $, resolved, dialect, isPosix, hasShellcheck, hasCheckbashisms } =
23
+ deps;
24
+ const findings: string[] = [];
25
+
26
+ // :::: ShellCheck — findings on stdout, exits non-zero on issues :::: /////
27
+ if (hasShellcheck) {
28
+ try {
29
+ const sc = await $`shellcheck -s ${dialect} ${resolved} 2>&1`
30
+ .nothrow().text();
31
+ if (sc.trim()) {
32
+ findings.push(
33
+ `ShellCheck findings in ${resolved} (shell=${dialect}):\n${sc.trim()}`,
34
+ );
35
+ }
36
+ // deno-lint-ignore no-empty
37
+ } catch {}
38
+ }
39
+
40
+ // :::: Syntax — bash -n (skipped for POSIX shells) :::: //////////////////
41
+ if (!isPosix) {
42
+ try {
43
+ const syntax = await $`bash -n ${resolved} 2>&1`.nothrow().text();
44
+ if (syntax.trim()) {
45
+ findings.push(`Syntax error in ${resolved}: ${syntax.trim()}`);
46
+ }
47
+ // deno-lint-ignore no-empty
48
+ } catch {}
49
+ }
50
+
51
+ // :::: POSIX bashisms — only for dash/sh shebangs :::: ////////////////////
52
+ if (isPosix && hasCheckbashisms) {
53
+ try {
54
+ const bashisms = await $`checkbashisms ${resolved} 2>&1`.nothrow()
55
+ .text();
56
+ if (bashisms.trim()) {
57
+ findings.push(
58
+ `POSIX compatibility issue in ${resolved} — bashisms detected:\n${bashisms.trim()}\n` +
59
+ "Note: /bin/sh is dash on Ubuntu/Debian. These will fail at runtime.",
60
+ );
61
+ }
62
+ // deno-lint-ignore no-empty
63
+ } catch {}
64
+ }
65
+
66
+ // :::: Unresolved markers — TODO/FIXME/HACK/XXX/BUG :::: //////////////////
67
+ try {
68
+ const todos =
69
+ await $`grep -n -E '(^|[^[:alnum:]_])(TODO|FIXME|HACK|XXX|BUG):' ${resolved}`
70
+ .nothrow()
71
+ .text();
72
+ if (todos.trim()) {
73
+ findings.push(`Unresolved markers in ${resolved}:\n${todos.trim()}`);
74
+ }
75
+ // deno-lint-ignore no-empty
76
+ } catch {}
77
+
78
+ // :::: Batch script patterns — lib-batch.sh contracts :::: ///////////////
79
+ try {
80
+ const content = await $`cat ${resolved}`.nothrow().text();
81
+ if (content.includes("lib-batch.sh")) {
82
+ if (!content.includes("batch_output")) {
83
+ findings.push(
84
+ `Batch script detected in ${resolved}: ensure batch_output() is called to return JSON results`,
85
+ );
86
+ }
87
+ if (!content.includes("declare -A RESULTS")) {
88
+ findings.push(
89
+ `Batch script detected in ${resolved}: declare RESULTS array with: declare -A RESULTS`,
90
+ );
91
+ }
92
+ }
93
+ // deno-lint-ignore no-empty
94
+ } catch {}
95
+
96
+ return findings;
97
+ }
@@ -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
+ }
@@ -0,0 +1,25 @@
1
+ // Shared types for the shell-routines plugin.
2
+
3
+ // :::: Tool hook shapes :::: ///////////////////////////////
4
+
5
+ export type ShellToolInput = {
6
+ tool: string;
7
+ sessionID: string;
8
+ callID: string;
9
+ args: { file_path?: string; filePath?: string; [key: string]: unknown };
10
+ };
11
+
12
+ export type ShellToolOutput = {
13
+ title: string;
14
+ output: string;
15
+ metadata: unknown;
16
+ };
17
+
18
+ // :::: Dialect classification :::: /////////////////////////
19
+
20
+ export type Dialect = "bash" | "dash" | "sh";
21
+
22
+ export type DialectResult = {
23
+ dialect: Dialect;
24
+ isPosix: boolean;
25
+ };