@haposoft/cafekit 0.8.17 → 0.9.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 +47 -84
- package/bin/install.js +90 -653
- package/bin/lib/copy-utils.js +61 -0
- package/bin/lib/opencode-install.js +866 -0
- package/package.json +4 -1
- package/src/claude/CLAUDE.md +2 -2
- package/src/claude/migration-manifest.json +1 -0
- package/src/claude/rules/skill-domain-routing.md +2 -0
- package/src/claude/rules/skill-workflow-routing.md +5 -1
- package/src/claude/skills/question/SKILL.md +162 -0
- package/src/claude/skills/question/templates/question.md +32 -0
- package/src/opencode/AGENTS.md +6 -6
- package/src/opencode/plugins/docs-sync.ts +181 -0
- package/src/opencode/plugins/inspect-block.ts +164 -0
- package/src/opencode/plugins/privacy-block.ts +182 -0
- package/src/opencode/plugins/rules.ts +163 -0
- package/src/opencode/plugins/session.ts +179 -0
- package/src/opencode/plugins/state.ts +253 -0
- package/src/opencode/plugins/usage.ts +178 -0
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode Plugin — privacy-block.ts
|
|
5
|
+
*
|
|
6
|
+
* Port of .claude/hooks/privacy-block.cjs to OpenCode plugin protocol.
|
|
7
|
+
*
|
|
8
|
+
* Behavior:
|
|
9
|
+
* - Match tool input paths against RESTRICTED_PATTERNS.
|
|
10
|
+
* - For `bash`: log warning and allow (caller may have user approval already).
|
|
11
|
+
* - For other tools: throw with @@PRIVACY_PROMPT_START@@ marker payload so the
|
|
12
|
+
* assistant prompts the user before retrying via bash cat.
|
|
13
|
+
*
|
|
14
|
+
* Disable via `.opencode/runtime.json` { "privacyBlock": false }.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
18
|
+
import { existsSync, readFileSync, mkdirSync, appendFileSync } from "node:fs";
|
|
19
|
+
import { join, basename, dirname } from "node:path";
|
|
20
|
+
import { fileURLToPath } from "node:url";
|
|
21
|
+
|
|
22
|
+
const RESTRICTED_PATTERNS: RegExp[] = [
|
|
23
|
+
/^\.env(\.|$)/i,
|
|
24
|
+
/^credentials/i,
|
|
25
|
+
/secrets?\.(ya?ml|json)$/i,
|
|
26
|
+
/\.pem$/i,
|
|
27
|
+
/\.key$/i,
|
|
28
|
+
/\.p12$/i,
|
|
29
|
+
/\.pfx$/i,
|
|
30
|
+
/^id_(rsa|ed25519|ecdsa|dsa)$/i,
|
|
31
|
+
/\.netrc$/i,
|
|
32
|
+
/\.pgpass$/i,
|
|
33
|
+
/kubeconfig/i,
|
|
34
|
+
/\.keystore$/i,
|
|
35
|
+
/\.jks$/i,
|
|
36
|
+
/auth\.json$/i,
|
|
37
|
+
/token(s)?\.json$/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
const ALLOWED_EXEMPTIONS: RegExp[] = [/\.env\.(example|sample|template|test)$/i];
|
|
41
|
+
|
|
42
|
+
const GATED_TOOLS = new Set(["read", "write", "edit", "glob", "grep", "bash"]);
|
|
43
|
+
|
|
44
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
45
|
+
|
|
46
|
+
function readRuntime(cwd: string): Record<string, unknown> {
|
|
47
|
+
try {
|
|
48
|
+
const file = join(cwd, ".opencode", "runtime.json");
|
|
49
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
50
|
+
} catch {
|
|
51
|
+
return {};
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isSafe(filePath: string): boolean {
|
|
56
|
+
const base = basename(filePath);
|
|
57
|
+
return ALLOWED_EXEMPTIONS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function isSensitive(filePath: string): boolean {
|
|
61
|
+
const base = basename(filePath);
|
|
62
|
+
return RESTRICTED_PATTERNS.some((rule) => rule.test(base) || rule.test(filePath));
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function extractBashPaths(command: string): string[] {
|
|
66
|
+
const paths: string[] = [];
|
|
67
|
+
const regex = /(?:cat|less|more|head|tail|source|\.)\s+(?:"([^"]+)"|'([^']+)'|([^\s]+))/g;
|
|
68
|
+
let match: RegExpExecArray | null;
|
|
69
|
+
while ((match = regex.exec(command)) !== null) {
|
|
70
|
+
const value = match[1] || match[2] || match[3];
|
|
71
|
+
if (value) paths.push(value);
|
|
72
|
+
}
|
|
73
|
+
return paths;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function extractPaths(tool: string, args: Record<string, unknown>): string[] {
|
|
77
|
+
const paths: string[] = [];
|
|
78
|
+
if (!args) return paths;
|
|
79
|
+
|
|
80
|
+
const filePath = typeof args.filePath === "string" ? args.filePath.trim() : "";
|
|
81
|
+
if (filePath) paths.push(filePath);
|
|
82
|
+
|
|
83
|
+
const path = typeof args.path === "string" ? args.path.trim() : "";
|
|
84
|
+
if (path) paths.push(path);
|
|
85
|
+
|
|
86
|
+
if (tool === "bash" && typeof args.command === "string") {
|
|
87
|
+
paths.push(...extractBashPaths(args.command));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return paths.filter(Boolean);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function formatBlockMessage(filePath: string): string {
|
|
94
|
+
const fileBase = basename(filePath);
|
|
95
|
+
const promptData = {
|
|
96
|
+
type: "PRIVACY_PROMPT",
|
|
97
|
+
file: filePath,
|
|
98
|
+
basename: fileBase,
|
|
99
|
+
question: {
|
|
100
|
+
header: "File Access",
|
|
101
|
+
text: `I need to read "${fileBase}" which may contain sensitive data (API keys, passwords, tokens). Do you approve?`,
|
|
102
|
+
options: [
|
|
103
|
+
{
|
|
104
|
+
label: "Yes, approve access",
|
|
105
|
+
description: `Allow reading ${fileBase} this time`,
|
|
106
|
+
},
|
|
107
|
+
{
|
|
108
|
+
label: "No, skip this file",
|
|
109
|
+
description: "Continue without accessing this file",
|
|
110
|
+
},
|
|
111
|
+
],
|
|
112
|
+
},
|
|
113
|
+
};
|
|
114
|
+
|
|
115
|
+
return [
|
|
116
|
+
"NOTE: This is not an error. This block protects sensitive data.",
|
|
117
|
+
"",
|
|
118
|
+
"PRIVACY BLOCK: Sensitive file access requires user approval",
|
|
119
|
+
`File: ${filePath}`,
|
|
120
|
+
"",
|
|
121
|
+
"@@PRIVACY_PROMPT_START@@",
|
|
122
|
+
JSON.stringify(promptData, null, 2),
|
|
123
|
+
"@@PRIVACY_PROMPT_END@@",
|
|
124
|
+
"",
|
|
125
|
+
"OpenCode follow-up:",
|
|
126
|
+
`- If approved: use bash to read: cat "${filePath}"`,
|
|
127
|
+
"- If denied: continue without this file",
|
|
128
|
+
].join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function logCrash(error: unknown): void {
|
|
132
|
+
try {
|
|
133
|
+
const logDir = join(PLUGIN_DIR, ".logs");
|
|
134
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
135
|
+
appendFileSync(
|
|
136
|
+
join(logDir, "hook-log.jsonl"),
|
|
137
|
+
JSON.stringify({
|
|
138
|
+
ts: new Date().toISOString(),
|
|
139
|
+
hook: "privacy-block",
|
|
140
|
+
status: "crash",
|
|
141
|
+
error: error instanceof Error ? error.message : String(error),
|
|
142
|
+
}) + "\n",
|
|
143
|
+
);
|
|
144
|
+
} catch {
|
|
145
|
+
// fail-open
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
export const PrivacyBlock: Plugin = async ({ directory }) => ({
|
|
150
|
+
"tool.execute.before": async (input, output) => {
|
|
151
|
+
try {
|
|
152
|
+
if (!GATED_TOOLS.has(input.tool)) return;
|
|
153
|
+
|
|
154
|
+
const runtime = readRuntime(directory);
|
|
155
|
+
if (runtime.privacyBlock === false) return;
|
|
156
|
+
|
|
157
|
+
const args = (output.args ?? {}) as Record<string, unknown>;
|
|
158
|
+
const paths = extractPaths(input.tool, args);
|
|
159
|
+
if (paths.length === 0) return;
|
|
160
|
+
|
|
161
|
+
for (const filePath of paths) {
|
|
162
|
+
if (isSafe(filePath)) continue;
|
|
163
|
+
if (!isSensitive(filePath)) continue;
|
|
164
|
+
|
|
165
|
+
if (input.tool === "bash") {
|
|
166
|
+
console.error(
|
|
167
|
+
`WARN: Privacy-sensitive file access via bash allowed for approved follow-up: ${basename(filePath)}`,
|
|
168
|
+
);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
throw new Error(formatBlockMessage(filePath));
|
|
173
|
+
}
|
|
174
|
+
} catch (error) {
|
|
175
|
+
// Re-throw the privacy block error (intentional); only swallow programming errors.
|
|
176
|
+
if (error instanceof Error && error.message.includes("@@PRIVACY_PROMPT_START@@")) {
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
179
|
+
logCrash(error);
|
|
180
|
+
}
|
|
181
|
+
},
|
|
182
|
+
});
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode Plugin — rules.ts
|
|
5
|
+
*
|
|
6
|
+
* Port of .claude/hooks/rules.cjs adapted to OpenCode.
|
|
7
|
+
*
|
|
8
|
+
* Claude's version injects a per-prompt rules reminder via stdout into
|
|
9
|
+
* the UserPromptSubmit hook. OpenCode has no equivalent per-prompt
|
|
10
|
+
* injection channel, so this plugin renders the same dynamic rules
|
|
11
|
+
* (paths, docs.maxLoc, locale) on every session.created and upserts a
|
|
12
|
+
* managed block inside the project AGENTS.md. OpenCode auto-loads
|
|
13
|
+
* AGENTS.md as instructions on each session, so the rules reach the
|
|
14
|
+
* model context at session start instead of per prompt.
|
|
15
|
+
*
|
|
16
|
+
* Static rules content already lives in AGENTS.md base template — this
|
|
17
|
+
* plugin only refreshes the dynamic block so paths/locale stay in sync
|
|
18
|
+
* with runtime.json.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
22
|
+
import {
|
|
23
|
+
existsSync,
|
|
24
|
+
readFileSync,
|
|
25
|
+
writeFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join, dirname } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
|
|
30
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
31
|
+
const RULES_BLOCK_START = "<!-- CAFEKIT DYNAMIC RULES START -->";
|
|
32
|
+
const RULES_BLOCK_END = "<!-- CAFEKIT DYNAMIC RULES END -->";
|
|
33
|
+
|
|
34
|
+
function resolveProjectDir(directory?: string): string {
|
|
35
|
+
if (directory && existsSync(directory)) return directory;
|
|
36
|
+
return dirname(dirname(PLUGIN_DIR));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readRuntime(cwd: string): Record<string, any> {
|
|
40
|
+
try {
|
|
41
|
+
const file = join(cwd, ".opencode", "runtime.json");
|
|
42
|
+
if (existsSync(file)) {
|
|
43
|
+
return JSON.parse(readFileSync(file, "utf8"));
|
|
44
|
+
}
|
|
45
|
+
} catch {
|
|
46
|
+
/* fail-open */
|
|
47
|
+
}
|
|
48
|
+
return {};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function renderDynamicRules(cwd: string): string {
|
|
52
|
+
const runtime = readRuntime(cwd);
|
|
53
|
+
const thinkLang: string = runtime?.locale?.thinkingLanguage || "";
|
|
54
|
+
const respondLang: string = runtime?.locale?.responseLanguage || "";
|
|
55
|
+
const effectThink = thinkLang || (respondLang ? "en" : "");
|
|
56
|
+
const plansPath = join(cwd, runtime?.paths?.plans || "plans");
|
|
57
|
+
const docsPath = join(cwd, runtime?.paths?.docs || "docs");
|
|
58
|
+
const maxLoc: number = runtime?.docs?.maxLoc || 800;
|
|
59
|
+
|
|
60
|
+
const lines: string[] = [];
|
|
61
|
+
lines.push(RULES_BLOCK_START);
|
|
62
|
+
lines.push("");
|
|
63
|
+
lines.push("## CafeKit Session Rules (auto-refreshed)");
|
|
64
|
+
lines.push("");
|
|
65
|
+
|
|
66
|
+
const hasThink = effectThink && effectThink !== respondLang;
|
|
67
|
+
if (hasThink || respondLang) {
|
|
68
|
+
lines.push("### Language");
|
|
69
|
+
if (hasThink) lines.push(`- Thinking: Use ${effectThink} for reasoning.`);
|
|
70
|
+
if (respondLang) lines.push(`- Response: Respond in ${respondLang}.`);
|
|
71
|
+
lines.push("");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
lines.push("### Rules");
|
|
75
|
+
lines.push(`- Markdown files: Plans → "${plansPath}/" | Docs → "${docsPath}/"`);
|
|
76
|
+
lines.push(
|
|
77
|
+
"- **DO NOT** create markdown files outside of those directories unless explicitly asked.",
|
|
78
|
+
);
|
|
79
|
+
lines.push(`- docs.maxLoc: ${maxLoc} lines max per doc file`);
|
|
80
|
+
lines.push("- Follow **YAGNI · KISS · DRY** principles");
|
|
81
|
+
lines.push(
|
|
82
|
+
"- Sacrifice grammar for concision in reports. List unresolved Qs at end.",
|
|
83
|
+
);
|
|
84
|
+
lines.push("- Ensure token efficiency while maintaining high quality.");
|
|
85
|
+
lines.push("");
|
|
86
|
+
|
|
87
|
+
lines.push("### Skill Routing");
|
|
88
|
+
lines.push(
|
|
89
|
+
"- Choose skills from intent using `.opencode/rules/skill-workflow-routing.md` and `.opencode/rules/skill-domain-routing.md`.",
|
|
90
|
+
);
|
|
91
|
+
lines.push(
|
|
92
|
+
"- Use the OpenCode slash commands installed under `.opencode/commands/` (no `hapo:` prefix).",
|
|
93
|
+
);
|
|
94
|
+
lines.push(
|
|
95
|
+
"- Explicit user commands and direct-answer requests override routing suggestions.",
|
|
96
|
+
);
|
|
97
|
+
lines.push("");
|
|
98
|
+
|
|
99
|
+
lines.push("### Modularization");
|
|
100
|
+
lines.push("- If a file exceeds 200 lines, consider splitting it");
|
|
101
|
+
lines.push("- Check existing modules before creating new ones");
|
|
102
|
+
lines.push(
|
|
103
|
+
"- Prefer kebab-case (JS/TS/Python/shell); PascalCase (C#/Java); snake_case (Go/Rust)",
|
|
104
|
+
);
|
|
105
|
+
lines.push(
|
|
106
|
+
"- Skip modularization for: markdown, plain text, bash scripts, config files, .env files",
|
|
107
|
+
);
|
|
108
|
+
lines.push("");
|
|
109
|
+
lines.push(RULES_BLOCK_END);
|
|
110
|
+
|
|
111
|
+
return lines.join("\n");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function upsertBlock(filePath: string, block: string): boolean {
|
|
115
|
+
if (!existsSync(filePath)) return false;
|
|
116
|
+
let content: string;
|
|
117
|
+
try {
|
|
118
|
+
content = readFileSync(filePath, "utf8");
|
|
119
|
+
} catch {
|
|
120
|
+
return false;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const startIdx = content.indexOf(RULES_BLOCK_START);
|
|
124
|
+
const endIdx = content.indexOf(RULES_BLOCK_END);
|
|
125
|
+
|
|
126
|
+
let next: string;
|
|
127
|
+
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
|
128
|
+
const before = content.slice(0, startIdx);
|
|
129
|
+
const after = content.slice(endIdx + RULES_BLOCK_END.length);
|
|
130
|
+
next = `${before}${block}${after}`;
|
|
131
|
+
} else {
|
|
132
|
+
const sep = content.endsWith("\n") ? "" : "\n";
|
|
133
|
+
next = `${content}${sep}\n${block}\n`;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (next === content) return false;
|
|
137
|
+
try {
|
|
138
|
+
writeFileSync(filePath, next);
|
|
139
|
+
return true;
|
|
140
|
+
} catch {
|
|
141
|
+
return false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export const RulesPlugin: Plugin = async ({ directory }) => {
|
|
146
|
+
const cwd = resolveProjectDir(directory);
|
|
147
|
+
|
|
148
|
+
const refresh = () => {
|
|
149
|
+
const agentsFile = join(cwd, "AGENTS.md");
|
|
150
|
+
const block = renderDynamicRules(cwd);
|
|
151
|
+
upsertBlock(agentsFile, block);
|
|
152
|
+
};
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
event: async ({ event }) => {
|
|
156
|
+
if (event.type === "session.created") {
|
|
157
|
+
refresh();
|
|
158
|
+
}
|
|
159
|
+
},
|
|
160
|
+
};
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
export default RulesPlugin;
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copyright (c) 2026 Haposoft. MIT License.
|
|
3
|
+
*
|
|
4
|
+
* OpenCode Plugin — session.ts
|
|
5
|
+
*
|
|
6
|
+
* Port of .claude/hooks/session.cjs (banner half only).
|
|
7
|
+
*
|
|
8
|
+
* Behavior:
|
|
9
|
+
* - On session.created: detect project type, package manager, framework, git
|
|
10
|
+
* branch and write a one-line banner to .opencode/session-banner.md
|
|
11
|
+
* alongside docs-sync's section. Also emit to stderr (server log).
|
|
12
|
+
* - On session.compacted: append compaction warning to the banner so the
|
|
13
|
+
* next assistant turn is reminded to re-verify pending approvals.
|
|
14
|
+
*
|
|
15
|
+
* Note: Claude's CLAUDE_ENV_FILE writes are dropped — OpenCode does not
|
|
16
|
+
* expose an equivalent env-injection channel.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
20
|
+
import {
|
|
21
|
+
existsSync,
|
|
22
|
+
readFileSync,
|
|
23
|
+
writeFileSync,
|
|
24
|
+
mkdirSync,
|
|
25
|
+
appendFileSync,
|
|
26
|
+
} from "node:fs";
|
|
27
|
+
import { join, dirname } from "node:path";
|
|
28
|
+
import { fileURLToPath } from "node:url";
|
|
29
|
+
import { execSync } from "node:child_process";
|
|
30
|
+
|
|
31
|
+
const PLUGIN_DIR = dirname(fileURLToPath(import.meta.url));
|
|
32
|
+
const SESSION_SECTION_START = "<!-- CAFEKIT SESSION START -->";
|
|
33
|
+
const SESSION_SECTION_END = "<!-- CAFEKIT SESSION END -->";
|
|
34
|
+
|
|
35
|
+
function run(cmd: string, cwd: string, fallback = ""): string {
|
|
36
|
+
try {
|
|
37
|
+
return execSync(cmd, {
|
|
38
|
+
cwd,
|
|
39
|
+
encoding: "utf8",
|
|
40
|
+
timeout: 3000,
|
|
41
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
42
|
+
}).trim();
|
|
43
|
+
} catch {
|
|
44
|
+
return fallback;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readRuntime(cwd: string): Record<string, any> {
|
|
49
|
+
try {
|
|
50
|
+
const file = join(cwd, ".opencode", "runtime.json");
|
|
51
|
+
return existsSync(file) ? JSON.parse(readFileSync(file, "utf8")) : {};
|
|
52
|
+
} catch {
|
|
53
|
+
return {};
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function detectProjectType(cwd: string): string {
|
|
58
|
+
if (existsSync(join(cwd, "pnpm-workspace.yaml")) || existsSync(join(cwd, "lerna.json"))) {
|
|
59
|
+
return "monorepo";
|
|
60
|
+
}
|
|
61
|
+
try {
|
|
62
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
63
|
+
if (pkg.workspaces) return "monorepo";
|
|
64
|
+
if (pkg.main || pkg.exports || pkg.module) return "library";
|
|
65
|
+
} catch {
|
|
66
|
+
// ignore
|
|
67
|
+
}
|
|
68
|
+
return "app";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function detectPackageManager(cwd: string): string {
|
|
72
|
+
if (existsSync(join(cwd, "pnpm-lock.yaml"))) return "pnpm";
|
|
73
|
+
if (existsSync(join(cwd, "yarn.lock"))) return "yarn";
|
|
74
|
+
if (existsSync(join(cwd, "bun.lockb"))) return "bun";
|
|
75
|
+
if (existsSync(join(cwd, "package-lock.json"))) return "npm";
|
|
76
|
+
return "";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function detectFramework(cwd: string): string {
|
|
80
|
+
try {
|
|
81
|
+
const pkg = JSON.parse(readFileSync(join(cwd, "package.json"), "utf8"));
|
|
82
|
+
const deps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
83
|
+
if (deps["next"]) return "next";
|
|
84
|
+
if (deps["nuxt"]) return "nuxt";
|
|
85
|
+
if (deps["@sveltejs/kit"]) return "sveltekit";
|
|
86
|
+
if (deps["react"]) return "react";
|
|
87
|
+
if (deps["vue"]) return "vue";
|
|
88
|
+
if (deps["svelte"]) return "svelte";
|
|
89
|
+
if (deps["express"]) return "express";
|
|
90
|
+
if (deps["fastify"]) return "fastify";
|
|
91
|
+
if (deps["hono"]) return "hono";
|
|
92
|
+
} catch {
|
|
93
|
+
// ignore
|
|
94
|
+
}
|
|
95
|
+
return "";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
function resolveSetting(cwd: string, runtimeValue: unknown, detector: (c: string) => string): string {
|
|
99
|
+
if (runtimeValue === "auto" || runtimeValue == null) return detector(cwd);
|
|
100
|
+
return typeof runtimeValue === "string" ? runtimeValue : detector(cwd);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function writeBannerSection(cwd: string, content: string): void {
|
|
104
|
+
const target = join(cwd, ".opencode", "session-banner.md");
|
|
105
|
+
const dir = dirname(target);
|
|
106
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
107
|
+
|
|
108
|
+
const existing = existsSync(target) ? readFileSync(target, "utf8") : "";
|
|
109
|
+
const pattern = new RegExp(`${SESSION_SECTION_START}[\\s\\S]*?${SESSION_SECTION_END}\\n?`);
|
|
110
|
+
const cleaned = existing.replace(pattern, "").trimEnd();
|
|
111
|
+
const block = [SESSION_SECTION_START, content, SESSION_SECTION_END].join("\n");
|
|
112
|
+
const next = cleaned ? `${cleaned}\n\n${block}\n` : `${block}\n`;
|
|
113
|
+
writeFileSync(target, next);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function logCrash(error: unknown): void {
|
|
117
|
+
try {
|
|
118
|
+
const logDir = join(PLUGIN_DIR, ".logs");
|
|
119
|
+
if (!existsSync(logDir)) mkdirSync(logDir, { recursive: true });
|
|
120
|
+
appendFileSync(
|
|
121
|
+
join(logDir, "hook-log.jsonl"),
|
|
122
|
+
JSON.stringify({
|
|
123
|
+
ts: new Date().toISOString(),
|
|
124
|
+
hook: "session",
|
|
125
|
+
status: "crash",
|
|
126
|
+
error: error instanceof Error ? error.message : String(error),
|
|
127
|
+
}) + "\n",
|
|
128
|
+
);
|
|
129
|
+
} catch {
|
|
130
|
+
// fail-open
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function handleSessionCreated(cwd: string): void {
|
|
135
|
+
const runtime = readRuntime(cwd);
|
|
136
|
+
|
|
137
|
+
const projectType = resolveSetting(cwd, runtime.project?.type, detectProjectType);
|
|
138
|
+
const packageManager = resolveSetting(cwd, runtime.project?.packageManager, detectPackageManager);
|
|
139
|
+
const framework = resolveSetting(cwd, runtime.project?.framework, detectFramework);
|
|
140
|
+
const gitBranch = run("git branch --show-current", cwd);
|
|
141
|
+
|
|
142
|
+
const parts: string[] = [];
|
|
143
|
+
if (projectType) parts.push(`Type: ${projectType}`);
|
|
144
|
+
if (packageManager) parts.push(`PM: ${packageManager}`);
|
|
145
|
+
if (framework) parts.push(`Framework: ${framework}`);
|
|
146
|
+
if (gitBranch) parts.push(`Branch: ${gitBranch}`);
|
|
147
|
+
|
|
148
|
+
const summary = `Session started. ${parts.length ? parts.join(" | ") : "No project info detected."}`;
|
|
149
|
+
writeBannerSection(cwd, summary);
|
|
150
|
+
console.error(summary);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function handleSessionCompacted(cwd: string): void {
|
|
154
|
+
const lines = [
|
|
155
|
+
"🚨 SESSION COMPRESSED — VERIFY PENDING AUTHORIZATIONS:",
|
|
156
|
+
"Any pending confirmations requested via AskUserQuestion might have been lost.",
|
|
157
|
+
"Do not proceed without explicitly asking the user again to ensure safety.",
|
|
158
|
+
'Use AskUserQuestion: "The chat context was compressed. Do I still have permission to proceed?"',
|
|
159
|
+
];
|
|
160
|
+
writeBannerSection(cwd, lines.join("\n"));
|
|
161
|
+
console.error(lines.join("\n"));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export const Session: Plugin = async ({ directory }) => ({
|
|
165
|
+
event: async ({ event }) => {
|
|
166
|
+
try {
|
|
167
|
+
if (event.type === "session.created") {
|
|
168
|
+
handleSessionCreated(directory);
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (event.type === "session.compacted") {
|
|
172
|
+
handleSessionCompacted(directory);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
} catch (error) {
|
|
176
|
+
logCrash(error);
|
|
177
|
+
}
|
|
178
|
+
},
|
|
179
|
+
});
|