@objctp/opencode-shell-routines 1.3.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@objctp/opencode-shell-routines",
3
- "version": "1.3.0",
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",
@@ -6,13 +6,10 @@ import type {
6
6
  PluginInput,
7
7
  PluginOptions,
8
8
  } from "@opencode-ai/plugin";
9
- import { syncContent } from "./setup-content.ts";
10
-
11
- const SHELL_EXTENSIONS = new Set(["sh", "bash", "zsh", "ksh"]);
12
- const SHEBANG_PATTERN = /^#!.*\b(bash|sh|zsh|ksh)\b/;
13
- const DASH_PATTERN = /#!.*\bdash\b/;
14
- const SH_ONLY_PATTERN = /#!.*\bsh\b/;
15
- const BASH_FAMILY_PATTERN = /#!.*\b(bash|zsh|ksh)\b/;
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";
16
13
 
17
14
  async function hasCmd($: PluginInput["$"], cmd: string): Promise<boolean> {
18
15
  const r = await $`command -v ${cmd}`.nothrow();
@@ -23,9 +20,10 @@ export const ShellHooksPlugin: Plugin = async (
23
20
  { $, client, directory }: PluginInput,
24
21
  options?: PluginOptions,
25
22
  ): 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.
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.
29
27
  try {
30
28
  const packageRoot = path.resolve(import.meta.dirname, "..");
31
29
  const pkg = JSON.parse(
@@ -52,17 +50,14 @@ export const ShellHooksPlugin: Plugin = async (
52
50
  } catch {}
53
51
  }
54
52
 
53
+ // :::: Detect available quality tools :::: ///////////////////////////////
55
54
  const hasShellcheck = await hasCmd($, "shellcheck");
56
55
  const hasCheckbashisms = await hasCmd($, "checkbashisms");
56
+
57
57
  return {
58
58
  "tool.execute.after": async (
59
- input: {
60
- tool: string;
61
- sessionID: string;
62
- callID: string;
63
- args: { file_path?: string; filePath?: string; [key: string]: unknown };
64
- },
65
- output: { title: string; output: string; metadata: unknown },
59
+ input: ShellToolInput,
60
+ output: ShellToolOutput,
66
61
  ) => {
67
62
  if (input.tool !== "write" && input.tool !== "edit") return;
68
63
 
@@ -70,7 +65,7 @@ export const ShellHooksPlugin: Plugin = async (
70
65
  input.args?.filePath;
71
66
  if (!filePath) return;
72
67
 
73
- // Canonicalise and verify file exists
68
+ // Canonicalise and verify the file exists.
74
69
  let resolved: string;
75
70
  try {
76
71
  resolved = await $`realpath ${filePath}`.nothrow().text();
@@ -85,7 +80,7 @@ export const ShellHooksPlugin: Plugin = async (
85
80
 
86
81
  const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
87
82
 
88
- // Read first line once — needed for shebang check and dialect detection
83
+ // Read first line once — needed for shebang check and dialect detection.
89
84
  let firstLine: string;
90
85
  try {
91
86
  firstLine = await $`head -1 ${resolved}`.nothrow().text();
@@ -93,91 +88,18 @@ export const ShellHooksPlugin: Plugin = async (
93
88
  return;
94
89
  }
95
90
 
96
- if (!SHELL_EXTENSIONS.has(ext) && !SHEBANG_PATTERN.test(firstLine)) {
97
- return;
98
- }
99
-
100
- let dialect = "bash";
101
- let isPosix = false;
102
- if (DASH_PATTERN.test(firstLine)) {
103
- dialect = "dash";
104
- isPosix = true;
105
- } else if (
106
- SH_ONLY_PATTERN.test(firstLine) && !BASH_FAMILY_PATTERN.test(firstLine)
107
- ) {
108
- dialect = "sh";
109
- isPosix = true;
110
- }
111
-
112
- const findings: string[] = [];
113
-
114
- // ShellCheck — findings on stdout, exits non-zero on issues
115
- if (hasShellcheck) {
116
- try {
117
- const sc = await $`shellcheck -s ${dialect} ${resolved} 2>&1`
118
- .nothrow().text();
119
- if (sc.trim()) {
120
- findings.push(
121
- `ShellCheck findings in ${resolved} (shell=${dialect}):\n${sc.trim()}`,
122
- );
123
- }
124
- // deno-lint-ignore no-empty
125
- } catch {}
126
- }
127
-
128
- if (!isPosix) {
129
- try {
130
- const syntax = await $`bash -n ${resolved} 2>&1`.nothrow().text();
131
- if (syntax.trim()) {
132
- findings.push(`Syntax error in ${resolved}: ${syntax.trim()}`);
133
- }
134
- // deno-lint-ignore no-empty
135
- } catch {}
136
- }
137
-
138
- if (isPosix && hasCheckbashisms) {
139
- try {
140
- const bashisms = await $`checkbashisms ${resolved} 2>&1`.nothrow()
141
- .text();
142
- if (bashisms.trim()) {
143
- findings.push(
144
- `POSIX compatibility issue in ${resolved} — bashisms detected:\n${bashisms.trim()}\n` +
145
- "Note: /bin/sh is dash on Ubuntu/Debian. These will fail at runtime.",
146
- );
147
- }
148
- // deno-lint-ignore no-empty
149
- } catch {}
150
- }
91
+ if (!isShellFile(ext, firstLine)) return;
151
92
 
152
- // TODO/FIXME/HACK/XXX/BUG markers
153
- try {
154
- const todos =
155
- await $`grep -n -E '(^|[^[:alnum:]_])(TODO|FIXME|HACK|XXX|BUG):' ${resolved}`
156
- .nothrow()
157
- .text();
158
- if (todos.trim()) {
159
- findings.push(`Unresolved markers in ${resolved}:\n${todos.trim()}`);
160
- }
161
- // deno-lint-ignore no-empty
162
- } catch {}
93
+ const { dialect, isPosix } = detectDialect(firstLine);
163
94
 
164
- // Batch script pattern validation
165
- try {
166
- const content = await $`cat ${resolved}`.nothrow().text();
167
- if (content.includes("lib-batch.sh")) {
168
- if (!content.includes("batch_output")) {
169
- findings.push(
170
- `Batch script detected in ${resolved}: ensure batch_output() is called to return JSON results`,
171
- );
172
- }
173
- if (!content.includes("declare -A RESULTS")) {
174
- findings.push(
175
- `Batch script detected in ${resolved}: declare RESULTS array with: declare -A RESULTS`,
176
- );
177
- }
178
- }
179
- // deno-lint-ignore no-empty
180
- } catch {}
95
+ const findings = await runQualityChecks({
96
+ $,
97
+ resolved,
98
+ dialect,
99
+ isPosix,
100
+ hasShellcheck,
101
+ hasCheckbashisms,
102
+ });
181
103
 
182
104
  if (findings.length > 0) {
183
105
  output.output += "\n\n---\n**Shell quality checks:**\n" +
@@ -188,8 +110,7 @@ export const ShellHooksPlugin: Plugin = async (
188
110
  };
189
111
 
190
112
  // 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.
113
+ // expects a V1 plugin module that default-exports `{ id, server }`.
193
114
  export default {
194
115
  id: "shell-routines",
195
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,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
+ };