@objctp/opencode-shell-routines 1.3.0 → 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.0",
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",
@@ -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,12 @@ 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
+ // 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;
29
29
  try {
30
30
  const packageRoot = path.resolve(import.meta.dirname, "..");
31
31
  const pkg = JSON.parse(
@@ -34,8 +34,9 @@ export const ShellHooksPlugin: Plugin = async (
34
34
  syncContent({
35
35
  packageRoot,
36
36
  version: pkg.version,
37
+ packageName: pkg.name,
37
38
  client,
38
- scope: options?.scope === "project" ? "project" : "global",
39
+ scope: explicitScope,
39
40
  directory,
40
41
  });
41
42
  } catch (error) {
@@ -52,17 +53,14 @@ export const ShellHooksPlugin: Plugin = async (
52
53
  } catch {}
53
54
  }
54
55
 
56
+ // :::: Detect available quality tools :::: ///////////////////////////////
55
57
  const hasShellcheck = await hasCmd($, "shellcheck");
56
58
  const hasCheckbashisms = await hasCmd($, "checkbashisms");
59
+
57
60
  return {
58
61
  "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 },
62
+ input: ShellToolInput,
63
+ output: ShellToolOutput,
66
64
  ) => {
67
65
  if (input.tool !== "write" && input.tool !== "edit") return;
68
66
 
@@ -70,7 +68,7 @@ export const ShellHooksPlugin: Plugin = async (
70
68
  input.args?.filePath;
71
69
  if (!filePath) return;
72
70
 
73
- // Canonicalise and verify file exists
71
+ // Canonicalise and verify the file exists.
74
72
  let resolved: string;
75
73
  try {
76
74
  resolved = await $`realpath ${filePath}`.nothrow().text();
@@ -85,7 +83,7 @@ export const ShellHooksPlugin: Plugin = async (
85
83
 
86
84
  const ext = resolved.split(".").pop()?.toLowerCase() ?? "";
87
85
 
88
- // Read first line once — needed for shebang check and dialect detection
86
+ // Read first line once — needed for shebang check and dialect detection.
89
87
  let firstLine: string;
90
88
  try {
91
89
  firstLine = await $`head -1 ${resolved}`.nothrow().text();
@@ -93,91 +91,18 @@ export const ShellHooksPlugin: Plugin = async (
93
91
  return;
94
92
  }
95
93
 
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
- }
94
+ if (!isShellFile(ext, firstLine)) return;
151
95
 
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 {}
96
+ const { dialect, isPosix } = detectDialect(firstLine);
163
97
 
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 {}
98
+ const findings = await runQualityChecks({
99
+ $,
100
+ resolved,
101
+ dialect,
102
+ isPosix,
103
+ hasShellcheck,
104
+ hasCheckbashisms,
105
+ });
181
106
 
182
107
  if (findings.length > 0) {
183
108
  output.output += "\n\n---\n**Shell quality checks:**\n" +
@@ -188,8 +113,7 @@ export const ShellHooksPlugin: Plugin = async (
188
113
  };
189
114
 
190
115
  // 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.
116
+ // expects a V1 plugin module that default-exports `{ id, server }`.
193
117
  export default {
194
118
  id: "shell-routines",
195
119
  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,262 @@
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.
6
+ //
7
+ // All work is synchronous FS I/O; logging is fire-and-forget. Never throws.
8
+
9
+ import {
10
+ chmodSync,
11
+ cpSync,
12
+ existsSync,
13
+ lstatSync,
14
+ mkdirSync,
15
+ readdirSync,
16
+ readFileSync,
17
+ readlinkSync,
18
+ unlinkSync,
19
+ writeFileSync,
20
+ } from "node:fs";
21
+ import path from "node:path";
22
+ import os from "node:os";
23
+ import type { PluginInput } from "@opencode-ai/plugin";
24
+
25
+ // scripts/ isn't scanned by OpenCode — synced only so the rewritten
26
+ // ${CLAUDE_PLUGIN_ROOT}/scripts/lib-*.sh references resolve.
27
+ const DIRS = ["agents", "commands", "skills", "scripts"] as const;
28
+
29
+ const TEXT_EXT = new Set(["md", "sh", "bash", "zsh", "ksh", "txt", "json"]);
30
+
31
+ // Replace the defaulted variant first, or the plain token leaves a stray `:-}`.
32
+ const TOKEN_DEFAULTED = "${CLAUDE_PLUGIN_ROOT:-}";
33
+ const TOKEN_PLAIN = "${CLAUDE_PLUGIN_ROOT}";
34
+
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
+ ];
56
+
57
+ export type SyncScope = "global" | "project";
58
+
59
+ export interface SyncOptions {
60
+ packageRoot: string;
61
+ version: string;
62
+ /** Used to auto-detect scope from the project's config files. */
63
+ packageName: string;
64
+ client: PluginInput["client"];
65
+ /** Overrides auto-detected scope. */
66
+ scope?: SyncScope;
67
+ /** Project directory; required when scope resolves to "project". */
68
+ directory?: string;
69
+ /** Override the destination config directory (tests). */
70
+ configDirOverride?: string;
71
+ /** Override the state directory (tests). */
72
+ stateDirOverride?: string;
73
+ }
74
+
75
+ /**
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.
80
+ */
81
+ export function syncContent(opts: SyncOptions): void {
82
+ const {
83
+ packageRoot,
84
+ version,
85
+ packageName,
86
+ client,
87
+ directory,
88
+ configDirOverride,
89
+ stateDirOverride,
90
+ } = opts;
91
+
92
+ const log = (
93
+ level: "debug" | "info" | "warn" | "error",
94
+ message: string,
95
+ extra?: Record<string, unknown>,
96
+ ) => {
97
+ try {
98
+ const ret = client.app.log({
99
+ body: { service: "shell-routines", level, message, extra },
100
+ });
101
+ if (ret && typeof ret.catch === "function") ret.catch(() => {});
102
+ } catch {
103
+ // Logging must never break the plugin.
104
+ }
105
+ };
106
+
107
+ // File-plugin/dev mode already exposes content where OpenCode finds it, and
108
+ // its layout differs (no sibling scripts/).
109
+ if (!packageRoot.includes("node_modules")) {
110
+ log("debug", "content sync skipped (not an npm install)");
111
+ return;
112
+ }
113
+
114
+ if (!existsSync(path.join(packageRoot, "scripts"))) {
115
+ log("warn", "content sync skipped (unexpected package layout)", {
116
+ packageRoot,
117
+ });
118
+ return;
119
+ }
120
+
121
+ const scope = opts.scope ?? detectInstallScope(directory, packageName);
122
+ const configDir = configDirOverride ??
123
+ (scope === "project" && directory
124
+ ? path.join(directory, ".opencode")
125
+ : path.join(os.homedir(), ".config", "opencode"));
126
+
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
+ });
135
+ return;
136
+ }
137
+
138
+ mkdirSync(configDir, { recursive: true });
139
+ removeLegacyMarker(configDir);
140
+
141
+ try {
142
+ for (const dir of DIRS) {
143
+ syncDir(path.join(packageRoot, dir), path.join(configDir, dir), configDir);
144
+ }
145
+ } catch (error) {
146
+ log("error", "content sync failed", { error: String(error), configDir });
147
+ return; // state untouched → next load retries
148
+ }
149
+
150
+ synced[configDir] = version;
151
+ mkdirSync(stateDir, { recursive: true });
152
+ writeFileSync(stateFile, JSON.stringify(synced, null, 2) + "\n");
153
+ log("info", "synced shell-routines skills/commands/agents/scripts", {
154
+ configDir,
155
+ scope,
156
+ version,
157
+ });
158
+ }
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
+
220
+ function syncDir(src: string, dest: string, configDir: string): void {
221
+ if (!existsSync(src)) return;
222
+ mkdirSync(dest, { recursive: true });
223
+ for (const entry of readdirSync(src)) {
224
+ const srcPath = path.join(src, entry);
225
+ const destPath = path.join(dest, entry);
226
+ const stat = lstatSync(srcPath);
227
+
228
+ if (stat.isSymbolicLink()) {
229
+ // Dereference — defensive; dist files are real but repo sources symlink.
230
+ const target = path.resolve(
231
+ path.dirname(srcPath),
232
+ readlinkSync(srcPath),
233
+ );
234
+ syncDir(target, destPath, configDir);
235
+ } else if (stat.isDirectory()) {
236
+ syncDir(srcPath, destPath, configDir);
237
+ } else {
238
+ syncFile(srcPath, destPath, stat.mode, configDir);
239
+ }
240
+ }
241
+ }
242
+
243
+ function syncFile(
244
+ src: string,
245
+ dest: string,
246
+ mode: number,
247
+ configDir: string,
248
+ ): void {
249
+ const ext = path.extname(src).slice(1).toLowerCase();
250
+ if (TEXT_EXT.has(ext)) {
251
+ const rewritten = readFileSync(src, "utf8")
252
+ .split(TOKEN_DEFAULTED)
253
+ .join(configDir)
254
+ .split(TOKEN_PLAIN)
255
+ .join(configDir);
256
+ writeFileSync(dest, rewritten);
257
+ } else {
258
+ cpSync(src, dest);
259
+ }
260
+ // Preserve source mode: examples stay 0644, runtime libs 0755.
261
+ chmodSync(dest, mode & 0o777);
262
+ }
@@ -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
+ };
@@ -1,181 +0,0 @@
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
- }