@objctp/opencode-shell-routines 1.3.1 → 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.
|
|
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",
|
package/plugins/shell-hooks.ts
CHANGED
|
@@ -21,9 +21,11 @@ export const ShellHooksPlugin: Plugin = async (
|
|
|
21
21
|
options?: PluginOptions,
|
|
22
22
|
): Promise<Hooks> => {
|
|
23
23
|
// :::: Self-install bundled content (npm plugins only) :::: //////////////
|
|
24
|
-
//
|
|
25
|
-
|
|
26
|
-
|
|
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;
|
|
27
29
|
try {
|
|
28
30
|
const packageRoot = path.resolve(import.meta.dirname, "..");
|
|
29
31
|
const pkg = JSON.parse(
|
|
@@ -32,8 +34,9 @@ export const ShellHooksPlugin: Plugin = async (
|
|
|
32
34
|
syncContent({
|
|
33
35
|
packageRoot,
|
|
34
36
|
version: pkg.version,
|
|
37
|
+
packageName: pkg.name,
|
|
35
38
|
client,
|
|
36
|
-
scope:
|
|
39
|
+
scope: explicitScope,
|
|
37
40
|
directory,
|
|
38
41
|
});
|
|
39
42
|
} catch (error) {
|
|
@@ -1,15 +1,10 @@
|
|
|
1
|
-
//
|
|
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.
|
|
2
6
|
//
|
|
3
|
-
//
|
|
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.
|
|
7
|
+
// All work is synchronous FS I/O; logging is fire-and-forget. Never throws.
|
|
13
8
|
|
|
14
9
|
import {
|
|
15
10
|
chmodSync,
|
|
@@ -20,57 +15,79 @@ import {
|
|
|
20
15
|
readdirSync,
|
|
21
16
|
readFileSync,
|
|
22
17
|
readlinkSync,
|
|
18
|
+
unlinkSync,
|
|
23
19
|
writeFileSync,
|
|
24
20
|
} from "node:fs";
|
|
25
21
|
import path from "node:path";
|
|
26
22
|
import os from "node:os";
|
|
27
23
|
import type { PluginInput } from "@opencode-ai/plugin";
|
|
28
24
|
|
|
29
|
-
//
|
|
30
|
-
//
|
|
25
|
+
// scripts/ isn't scanned by OpenCode — synced only so the rewritten
|
|
26
|
+
// ${CLAUDE_PLUGIN_ROOT}/scripts/lib-*.sh references resolve.
|
|
31
27
|
const DIRS = ["agents", "commands", "skills", "scripts"] as const;
|
|
32
28
|
|
|
33
|
-
// Text files whose `${CLAUDE_PLUGIN_ROOT}` tokens are rewritten at copy time.
|
|
34
|
-
// Everything else is copied verbatim.
|
|
35
29
|
const TEXT_EXT = new Set(["md", "sh", "bash", "zsh", "ksh", "txt", "json"]);
|
|
36
30
|
|
|
37
|
-
//
|
|
38
|
-
// leave a stray `:-}` behind.
|
|
31
|
+
// Replace the defaulted variant first, or the plain token leaves a stray `:-}`.
|
|
39
32
|
const TOKEN_DEFAULTED = "${CLAUDE_PLUGIN_ROOT:-}";
|
|
40
33
|
const TOKEN_PLAIN = "${CLAUDE_PLUGIN_ROOT}";
|
|
41
34
|
|
|
42
|
-
//
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
const
|
|
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
|
+
];
|
|
46
56
|
|
|
47
57
|
export type SyncScope = "global" | "project";
|
|
48
58
|
|
|
49
59
|
export interface SyncOptions {
|
|
50
|
-
/** Absolute path to the installed package directory (sibling of agents/commands/skills/scripts). */
|
|
51
60
|
packageRoot: string;
|
|
52
|
-
/** Package version, read from `<packageRoot>/package.json`. */
|
|
53
61
|
version: string;
|
|
54
|
-
/**
|
|
62
|
+
/** Used to auto-detect scope from the project's config files. */
|
|
63
|
+
packageName: string;
|
|
55
64
|
client: PluginInput["client"];
|
|
56
|
-
/**
|
|
65
|
+
/** Overrides auto-detected scope. */
|
|
57
66
|
scope?: SyncScope;
|
|
58
|
-
/** Project directory; required when scope
|
|
67
|
+
/** Project directory; required when scope resolves to "project". */
|
|
59
68
|
directory?: string;
|
|
60
|
-
/** Override the destination config directory (
|
|
69
|
+
/** Override the destination config directory (tests). */
|
|
61
70
|
configDirOverride?: string;
|
|
71
|
+
/** Override the state directory (tests). */
|
|
72
|
+
stateDirOverride?: string;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/**
|
|
65
|
-
* Copy
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* Idempotent via a version marker written only after a complete, successful
|
|
70
|
-
* sync. Never throws.
|
|
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.
|
|
71
80
|
*/
|
|
72
81
|
export function syncContent(opts: SyncOptions): void {
|
|
73
|
-
const {
|
|
82
|
+
const {
|
|
83
|
+
packageRoot,
|
|
84
|
+
version,
|
|
85
|
+
packageName,
|
|
86
|
+
client,
|
|
87
|
+
directory,
|
|
88
|
+
configDirOverride,
|
|
89
|
+
stateDirOverride,
|
|
90
|
+
} = opts;
|
|
74
91
|
|
|
75
92
|
const log = (
|
|
76
93
|
level: "debug" | "info" | "warn" | "error",
|
|
@@ -87,15 +104,13 @@ export function syncContent(opts: SyncOptions): void {
|
|
|
87
104
|
}
|
|
88
105
|
};
|
|
89
106
|
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
// scripts/), so copying would produce a broken tree.
|
|
107
|
+
// File-plugin/dev mode already exposes content where OpenCode finds it, and
|
|
108
|
+
// its layout differs (no sibling scripts/).
|
|
93
109
|
if (!packageRoot.includes("node_modules")) {
|
|
94
110
|
log("debug", "content sync skipped (not an npm install)");
|
|
95
111
|
return;
|
|
96
112
|
}
|
|
97
113
|
|
|
98
|
-
// Fail safe on an unexpected package layout.
|
|
99
114
|
if (!existsSync(path.join(packageRoot, "scripts"))) {
|
|
100
115
|
log("warn", "content sync skipped (unexpected package layout)", {
|
|
101
116
|
packageRoot,
|
|
@@ -103,39 +118,105 @@ export function syncContent(opts: SyncOptions): void {
|
|
|
103
118
|
return;
|
|
104
119
|
}
|
|
105
120
|
|
|
106
|
-
const
|
|
107
|
-
|
|
121
|
+
const scope = opts.scope ?? detectInstallScope(directory, packageName);
|
|
122
|
+
const configDir = configDirOverride ??
|
|
108
123
|
(scope === "project" && directory
|
|
109
124
|
? path.join(directory, ".opencode")
|
|
110
125
|
: path.join(os.homedir(), ".config", "opencode"));
|
|
111
126
|
|
|
112
|
-
const
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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
|
+
});
|
|
118
135
|
return;
|
|
119
136
|
}
|
|
120
137
|
|
|
121
138
|
mkdirSync(configDir, { recursive: true });
|
|
139
|
+
removeLegacyMarker(configDir);
|
|
122
140
|
|
|
123
141
|
try {
|
|
124
142
|
for (const dir of DIRS) {
|
|
125
143
|
syncDir(path.join(packageRoot, dir), path.join(configDir, dir), configDir);
|
|
126
144
|
}
|
|
127
145
|
} catch (error) {
|
|
128
|
-
log("error", "content sync failed", { error: String(error) });
|
|
129
|
-
return; //
|
|
146
|
+
log("error", "content sync failed", { error: String(error), configDir });
|
|
147
|
+
return; // state untouched → next load retries
|
|
130
148
|
}
|
|
131
149
|
|
|
132
|
-
|
|
150
|
+
synced[configDir] = version;
|
|
151
|
+
mkdirSync(stateDir, { recursive: true });
|
|
152
|
+
writeFileSync(stateFile, JSON.stringify(synced, null, 2) + "\n");
|
|
133
153
|
log("info", "synced shell-routines skills/commands/agents/scripts", {
|
|
134
154
|
configDir,
|
|
155
|
+
scope,
|
|
135
156
|
version,
|
|
136
157
|
});
|
|
137
158
|
}
|
|
138
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
|
+
|
|
139
220
|
function syncDir(src: string, dest: string, configDir: string): void {
|
|
140
221
|
if (!existsSync(src)) return;
|
|
141
222
|
mkdirSync(dest, { recursive: true });
|
|
@@ -145,7 +226,7 @@ function syncDir(src: string, dest: string, configDir: string): void {
|
|
|
145
226
|
const stat = lstatSync(srcPath);
|
|
146
227
|
|
|
147
228
|
if (stat.isSymbolicLink()) {
|
|
148
|
-
// Dereference — defensive
|
|
229
|
+
// Dereference — defensive; dist files are real but repo sources symlink.
|
|
149
230
|
const target = path.resolve(
|
|
150
231
|
path.dirname(srcPath),
|
|
151
232
|
readlinkSync(srcPath),
|
|
@@ -176,6 +257,6 @@ function syncFile(
|
|
|
176
257
|
} else {
|
|
177
258
|
cpSync(src, dest);
|
|
178
259
|
}
|
|
179
|
-
// Preserve
|
|
260
|
+
// Preserve source mode: examples stay 0644, runtime libs 0755.
|
|
180
261
|
chmodSync(dest, mode & 0o777);
|
|
181
262
|
}
|