@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.
|
|
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
|
@@ -1,10 +1,15 @@
|
|
|
1
|
-
import
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
{
|
|
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
|
-
|
|
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 (!
|
|
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
|
-
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
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 }`.
|
|
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
|
+
};
|