@objctp/opencode-shell-routines 1.2.0 → 1.3.0
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 +14 -0
- package/package.json +6 -1
- package/plugins/setup-content.ts +181 -0
- package/plugins/shell-hooks.ts +48 -2
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.0",
|
|
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",
|
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
"code-quality",
|
|
22
22
|
"bash-scripting"
|
|
23
23
|
],
|
|
24
|
+
"exports": {
|
|
25
|
+
"./server": {
|
|
26
|
+
"import": "./plugins/shell-hooks.ts"
|
|
27
|
+
}
|
|
28
|
+
},
|
|
24
29
|
"files": [
|
|
25
30
|
"agents/",
|
|
26
31
|
"commands/",
|
|
@@ -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
|
+
}
|
package/plugins/shell-hooks.ts
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import
|
|
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 { syncContent } from "./setup-content.ts";
|
|
2
10
|
|
|
3
11
|
const SHELL_EXTENSIONS = new Set(["sh", "bash", "zsh", "ksh"]);
|
|
4
12
|
const SHEBANG_PATTERN = /^#!.*\b(bash|sh|zsh|ksh)\b/;
|
|
@@ -12,8 +20,38 @@ async function hasCmd($: PluginInput["$"], cmd: string): Promise<boolean> {
|
|
|
12
20
|
}
|
|
13
21
|
|
|
14
22
|
export const ShellHooksPlugin: Plugin = async (
|
|
15
|
-
{
|
|
23
|
+
{ $, client, directory }: PluginInput,
|
|
24
|
+
options?: PluginOptions,
|
|
16
25
|
): 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.
|
|
29
|
+
try {
|
|
30
|
+
const packageRoot = path.resolve(import.meta.dirname, "..");
|
|
31
|
+
const pkg = JSON.parse(
|
|
32
|
+
readFileSync(path.join(packageRoot, "package.json"), "utf8"),
|
|
33
|
+
);
|
|
34
|
+
syncContent({
|
|
35
|
+
packageRoot,
|
|
36
|
+
version: pkg.version,
|
|
37
|
+
client,
|
|
38
|
+
scope: options?.scope === "project" ? "project" : "global",
|
|
39
|
+
directory,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
try {
|
|
43
|
+
void client.app.log({
|
|
44
|
+
body: {
|
|
45
|
+
service: "shell-routines",
|
|
46
|
+
level: "warn",
|
|
47
|
+
message: "content sync failed",
|
|
48
|
+
extra: { error: String(error) },
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
// deno-lint-ignore no-empty
|
|
52
|
+
} catch {}
|
|
53
|
+
}
|
|
54
|
+
|
|
17
55
|
const hasShellcheck = await hasCmd($, "shellcheck");
|
|
18
56
|
const hasCheckbashisms = await hasCmd($, "checkbashisms");
|
|
19
57
|
return {
|
|
@@ -148,3 +186,11 @@ export const ShellHooksPlugin: Plugin = async (
|
|
|
148
186
|
},
|
|
149
187
|
};
|
|
150
188
|
};
|
|
189
|
+
|
|
190
|
+
// 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.
|
|
193
|
+
export default {
|
|
194
|
+
id: "shell-routines",
|
|
195
|
+
server: ShellHooksPlugin,
|
|
196
|
+
};
|