@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.
|
|
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",
|
package/plugins/shell-hooks.ts
CHANGED
|
@@ -6,13 +6,10 @@ import type {
|
|
|
6
6
|
PluginInput,
|
|
7
7
|
PluginOptions,
|
|
8
8
|
} from "@opencode-ai/plugin";
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
|
27
|
-
//
|
|
28
|
-
//
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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 }`.
|
|
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
|
+
};
|
|
File without changes
|