opencode-snippets 1.1.2 → 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 +69 -79
- package/index.ts +64 -17
- package/package.json +12 -5
- package/src/commands.ts +336 -0
- package/src/constants.ts +9 -14
- package/src/expander.test.ts +466 -0
- package/src/expander.ts +47 -37
- package/src/loader.test.ts +261 -0
- package/src/loader.ts +234 -56
- package/src/logger.test.ts +136 -0
- package/src/logger.ts +95 -95
- package/src/notification.ts +29 -0
- package/src/shell.ts +30 -24
- package/src/types.ts +19 -7
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test";
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from "fs";
|
|
3
|
+
import { tmpdir } from "os";
|
|
4
|
+
import { join } from "path";
|
|
5
|
+
|
|
6
|
+
describe("Logger", () => {
|
|
7
|
+
let tempDir: string;
|
|
8
|
+
let originalEnv: NodeJS.ProcessEnv;
|
|
9
|
+
let Logger: typeof import("../src/logger.js").Logger;
|
|
10
|
+
|
|
11
|
+
beforeAll(() => {
|
|
12
|
+
originalEnv = { ...process.env };
|
|
13
|
+
});
|
|
14
|
+
|
|
15
|
+
beforeEach(() => {
|
|
16
|
+
tempDir = mkdtempSync(join(tmpdir(), "snippets-logger-test-"));
|
|
17
|
+
Logger = require("../src/logger.js").Logger;
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
afterEach(() => {
|
|
21
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
22
|
+
vi.restoreAllMocks();
|
|
23
|
+
process.env = originalEnv;
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
describe("isDebugEnabled", () => {
|
|
27
|
+
it("should return false when DEBUG_SNIPPETS is not set", () => {
|
|
28
|
+
delete process.env.DEBUG_SNIPPETS;
|
|
29
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
30
|
+
expect(testLogger.enabled).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("should return true when DEBUG_SNIPPETS=1", () => {
|
|
34
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
35
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
36
|
+
expect(testLogger.enabled).toBe(true);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("should return true when DEBUG_SNIPPETS=true", () => {
|
|
40
|
+
process.env.DEBUG_SNIPPETS = "true";
|
|
41
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
42
|
+
expect(testLogger.enabled).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it("should return false for other values", () => {
|
|
46
|
+
process.env.DEBUG_SNIPPETS = "yes";
|
|
47
|
+
const testLogger = new Logger(join(tempDir, "logs"));
|
|
48
|
+
expect(testLogger.enabled).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe("log writing", () => {
|
|
53
|
+
it("should not write logs when debug is disabled", () => {
|
|
54
|
+
delete process.env.DEBUG_SNIPPETS;
|
|
55
|
+
const writeSpy = vi.spyOn(require("fs"), "writeFileSync");
|
|
56
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
57
|
+
logger.debug("test message", { key: "value" });
|
|
58
|
+
expect(writeSpy).not.toHaveBeenCalled();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("should write logs when debug is enabled", () => {
|
|
62
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
63
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
64
|
+
logger.debug("test debug message", { test: true });
|
|
65
|
+
logger.info("test info message");
|
|
66
|
+
logger.warn("test warn message");
|
|
67
|
+
logger.error("test error message");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("should format data correctly", () => {
|
|
71
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
72
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
73
|
+
logger.debug("with data", {
|
|
74
|
+
string: "hello",
|
|
75
|
+
number: 42,
|
|
76
|
+
bool: true,
|
|
77
|
+
nullVal: null,
|
|
78
|
+
undefinedVal: undefined,
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("should handle empty data", () => {
|
|
83
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
84
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
85
|
+
logger.debug("no data");
|
|
86
|
+
logger.debug("empty object", {});
|
|
87
|
+
logger.debug("empty array", { items: [] as unknown[] });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("should format arrays compactly", () => {
|
|
91
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
92
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
93
|
+
logger.debug("small array", { items: [1, 2, 3] });
|
|
94
|
+
logger.debug("large array", { items: [1, 2, 3, 4, 5, 6] });
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("should handle objects in data", () => {
|
|
98
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
99
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
100
|
+
logger.debug("nested object", {
|
|
101
|
+
nested: { a: 1, b: 2 },
|
|
102
|
+
long: "this is a very long string that should be truncated if over 50 characters",
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("file output", () => {
|
|
108
|
+
it("should create log file in daily directory", () => {
|
|
109
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
110
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
111
|
+
logger.info("file output test", { test: true });
|
|
112
|
+
|
|
113
|
+
const today = new Date().toISOString().split("T")[0];
|
|
114
|
+
const logFile = join(tempDir, "logs", "daily", `${today}.log`);
|
|
115
|
+
|
|
116
|
+
expect(existsSync(logFile)).toBe(true);
|
|
117
|
+
const content = readFileSync(logFile, "utf-8");
|
|
118
|
+
expect(content).toContain("file output test");
|
|
119
|
+
expect(content).toContain("test=true");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("should append to existing log file", () => {
|
|
123
|
+
process.env.DEBUG_SNIPPETS = "1";
|
|
124
|
+
const logger = new Logger(join(tempDir, "logs"));
|
|
125
|
+
logger.info("first message");
|
|
126
|
+
logger.info("second message");
|
|
127
|
+
|
|
128
|
+
const today = new Date().toISOString().split("T")[0];
|
|
129
|
+
const logFile = join(tempDir, "logs", "daily", `${today}.log`);
|
|
130
|
+
const content = readFileSync(logFile, "utf-8");
|
|
131
|
+
|
|
132
|
+
expect(content).toContain("first message");
|
|
133
|
+
expect(content).toContain("second message");
|
|
134
|
+
});
|
|
135
|
+
});
|
|
136
|
+
});
|
package/src/logger.ts
CHANGED
|
@@ -1,121 +1,121 @@
|
|
|
1
|
-
import {
|
|
2
|
-
import { join } from "path"
|
|
3
|
-
import {
|
|
1
|
+
import { existsSync, mkdirSync, writeFileSync } from "fs";
|
|
2
|
+
import { join } from "path";
|
|
3
|
+
import { PATHS } from "./constants.js";
|
|
4
4
|
|
|
5
5
|
/**
|
|
6
6
|
* Check if debug logging is enabled via environment variable
|
|
7
7
|
*/
|
|
8
8
|
function isDebugEnabled(): boolean {
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
const value = process.env.DEBUG_SNIPPETS;
|
|
10
|
+
return value === "1" || value === "true";
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
export class Logger {
|
|
14
|
-
|
|
14
|
+
private logDir: string;
|
|
15
15
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
constructor(logDirOverride?: string) {
|
|
17
|
+
this.logDir = logDirOverride ?? join(PATHS.CONFIG_DIR, "logs", "snippets");
|
|
18
|
+
}
|
|
19
19
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
20
|
+
get enabled(): boolean {
|
|
21
|
+
return isDebugEnabled();
|
|
22
|
+
}
|
|
23
23
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
}
|
|
24
|
+
private ensureLogDir() {
|
|
25
|
+
if (!existsSync(this.logDir)) {
|
|
26
|
+
mkdirSync(this.logDir, { recursive: true });
|
|
28
27
|
}
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
else {
|
|
49
|
-
parts.push(`${key}=${value}`)
|
|
50
|
-
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
private formatData(data?: Record<string, unknown>): string {
|
|
31
|
+
if (!data) return "";
|
|
32
|
+
|
|
33
|
+
const parts: string[] = [];
|
|
34
|
+
for (const [key, value] of Object.entries(data)) {
|
|
35
|
+
if (value === undefined || value === null) continue;
|
|
36
|
+
|
|
37
|
+
// Format arrays compactly
|
|
38
|
+
if (Array.isArray(value)) {
|
|
39
|
+
if (value.length === 0) continue;
|
|
40
|
+
parts.push(
|
|
41
|
+
`${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`,
|
|
42
|
+
);
|
|
43
|
+
} else if (typeof value === "object") {
|
|
44
|
+
const str = JSON.stringify(value);
|
|
45
|
+
if (str.length < 50) {
|
|
46
|
+
parts.push(`${key}=${str}`);
|
|
51
47
|
}
|
|
52
|
-
|
|
48
|
+
} else {
|
|
49
|
+
parts.push(`${key}=${value}`);
|
|
50
|
+
}
|
|
53
51
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
return 'unknown'
|
|
71
|
-
} catch {
|
|
72
|
-
return 'unknown'
|
|
52
|
+
return parts.join(" ");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
private getCallerFile(): string {
|
|
56
|
+
const originalPrepareStackTrace = Error.prepareStackTrace;
|
|
57
|
+
try {
|
|
58
|
+
const err = new Error();
|
|
59
|
+
Error.prepareStackTrace = (_, stack) => stack;
|
|
60
|
+
const stack = err.stack as unknown as NodeJS.CallSite[];
|
|
61
|
+
Error.prepareStackTrace = originalPrepareStackTrace;
|
|
62
|
+
|
|
63
|
+
for (let i = 3; i < stack.length; i++) {
|
|
64
|
+
const filename = stack[i]?.getFileName();
|
|
65
|
+
if (filename && !filename.includes("logger.")) {
|
|
66
|
+
const match = filename.match(/([^/\\]+)\.[tj]s$/);
|
|
67
|
+
return match ? match[1] : "unknown";
|
|
73
68
|
}
|
|
69
|
+
}
|
|
70
|
+
return "unknown";
|
|
71
|
+
} catch {
|
|
72
|
+
return "unknown";
|
|
74
73
|
}
|
|
74
|
+
}
|
|
75
75
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
try {
|
|
80
|
-
this.ensureLogDir()
|
|
81
|
-
|
|
82
|
-
const timestamp = new Date().toISOString()
|
|
83
|
-
const dataStr = this.formatData(data)
|
|
76
|
+
private write(level: string, component: string, message: string, data?: Record<string, unknown>) {
|
|
77
|
+
if (!this.enabled) return;
|
|
84
78
|
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
mkdirSync(dailyLogDir, { recursive: true })
|
|
88
|
-
}
|
|
79
|
+
try {
|
|
80
|
+
this.ensureLogDir();
|
|
89
81
|
|
|
90
|
-
|
|
82
|
+
const timestamp = new Date().toISOString();
|
|
83
|
+
const dataStr = this.formatData(data);
|
|
91
84
|
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
info(message: string, data?: any) {
|
|
100
|
-
const component = this.getCallerFile()
|
|
101
|
-
this.write("INFO", component, message, data)
|
|
102
|
-
}
|
|
85
|
+
const dailyLogDir = join(this.logDir, "daily");
|
|
86
|
+
if (!existsSync(dailyLogDir)) {
|
|
87
|
+
mkdirSync(dailyLogDir, { recursive: true });
|
|
88
|
+
}
|
|
103
89
|
|
|
104
|
-
|
|
105
|
-
const component = this.getCallerFile()
|
|
106
|
-
this.write("DEBUG", component, message, data)
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
warn(message: string, data?: any) {
|
|
110
|
-
const component = this.getCallerFile()
|
|
111
|
-
this.write("WARN", component, message, data)
|
|
112
|
-
}
|
|
90
|
+
const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? ` | ${dataStr}` : ""}\n`;
|
|
113
91
|
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
92
|
+
const logFile = join(dailyLogDir, `${new Date().toISOString().split("T")[0]}.log`);
|
|
93
|
+
writeFileSync(logFile, logLine, { flag: "a" });
|
|
94
|
+
} catch {
|
|
95
|
+
// Silent fail
|
|
117
96
|
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
info(message: string, data?: Record<string, unknown>) {
|
|
100
|
+
const component = this.getCallerFile();
|
|
101
|
+
this.write("INFO", component, message, data);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
debug(message: string, data?: Record<string, unknown>) {
|
|
105
|
+
const component = this.getCallerFile();
|
|
106
|
+
this.write("DEBUG", component, message, data);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
warn(message: string, data?: Record<string, unknown>) {
|
|
110
|
+
const component = this.getCallerFile();
|
|
111
|
+
this.write("WARN", component, message, data);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
error(message: string, data?: Record<string, unknown>) {
|
|
115
|
+
const component = this.getCallerFile();
|
|
116
|
+
this.write("ERROR", component, message, data);
|
|
117
|
+
}
|
|
118
118
|
}
|
|
119
119
|
|
|
120
120
|
// Export singleton logger instance
|
|
121
|
-
export const logger = new Logger()
|
|
121
|
+
export const logger = new Logger();
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { logger } from "./logger.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Sends a message that will be displayed but ignored by the AI
|
|
5
|
+
* Used for command output that shouldn't trigger AI responses
|
|
6
|
+
*
|
|
7
|
+
* @param client - The OpenCode client instance
|
|
8
|
+
* @param sessionId - The current session ID
|
|
9
|
+
* @param text - The text to display
|
|
10
|
+
*/
|
|
11
|
+
export async function sendIgnoredMessage(
|
|
12
|
+
client: any,
|
|
13
|
+
sessionId: string,
|
|
14
|
+
text: string,
|
|
15
|
+
): Promise<void> {
|
|
16
|
+
try {
|
|
17
|
+
await client.session.prompt({
|
|
18
|
+
path: { id: sessionId },
|
|
19
|
+
body: {
|
|
20
|
+
noReply: true,
|
|
21
|
+
parts: [{ type: "text", text, ignored: true }],
|
|
22
|
+
},
|
|
23
|
+
});
|
|
24
|
+
} catch (error) {
|
|
25
|
+
logger.error("Failed to send ignored message", {
|
|
26
|
+
error: error instanceof Error ? error.message : String(error),
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
}
|
package/src/shell.ts
CHANGED
|
@@ -1,44 +1,50 @@
|
|
|
1
|
-
import { PATTERNS } from "./constants.js"
|
|
2
|
-
import { logger } from "./logger.js"
|
|
1
|
+
import { PATTERNS } from "./constants.js";
|
|
2
|
+
import { logger } from "./logger.js";
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Executes shell commands in text using !`command` syntax
|
|
6
|
-
*
|
|
6
|
+
*
|
|
7
7
|
* @param text - The text containing shell commands to execute
|
|
8
8
|
* @param ctx - The plugin context (with Bun shell)
|
|
9
9
|
* @returns The text with shell commands replaced by their output
|
|
10
10
|
*/
|
|
11
|
-
export
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
11
|
+
export type ShellContext = {
|
|
12
|
+
$: (
|
|
13
|
+
template: TemplateStringsArray,
|
|
14
|
+
...args: unknown[]
|
|
15
|
+
) => {
|
|
16
|
+
quiet: () => { nothrow: () => { text: () => Promise<string> } };
|
|
17
|
+
};
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export async function executeShellCommands(text: string, ctx: ShellContext): Promise<string> {
|
|
21
|
+
let result = text;
|
|
22
|
+
|
|
17
23
|
// Reset regex state (global flag requires this)
|
|
18
|
-
PATTERNS.SHELL_COMMAND.lastIndex = 0
|
|
19
|
-
|
|
24
|
+
PATTERNS.SHELL_COMMAND.lastIndex = 0;
|
|
25
|
+
|
|
20
26
|
// Find all shell command matches
|
|
21
|
-
const matches = [...text.matchAll(PATTERNS.SHELL_COMMAND)]
|
|
22
|
-
|
|
27
|
+
const matches = [...text.matchAll(PATTERNS.SHELL_COMMAND)];
|
|
28
|
+
|
|
23
29
|
// Execute each command and replace in text
|
|
24
30
|
for (const match of matches) {
|
|
25
|
-
const cmd = match[1]
|
|
26
|
-
const
|
|
27
|
-
|
|
31
|
+
const cmd = match[1];
|
|
32
|
+
const _placeholder = match[0];
|
|
33
|
+
|
|
28
34
|
try {
|
|
29
|
-
const output = await ctx.$`${{ raw: cmd }}`.quiet().nothrow().text()
|
|
30
|
-
//
|
|
31
|
-
const replacement = `$ ${cmd}\n--> ${output.trim()}
|
|
32
|
-
result = result.replace(
|
|
35
|
+
const output = await ctx.$`${{ raw: cmd }}`.quiet().nothrow().text();
|
|
36
|
+
// Deviate from slash commands' substitution mechanism: print command first, then output
|
|
37
|
+
const replacement = `$ ${cmd}\n--> ${output.trim()}`;
|
|
38
|
+
result = result.replace(_placeholder, replacement);
|
|
33
39
|
} catch (error) {
|
|
34
40
|
// If shell command fails, leave it as-is
|
|
35
41
|
// This preserves the original syntax for debugging
|
|
36
42
|
logger.warn("Shell command execution failed", {
|
|
37
43
|
command: cmd,
|
|
38
|
-
error: error instanceof Error ? error.message : String(error)
|
|
39
|
-
})
|
|
44
|
+
error: error instanceof Error ? error.message : String(error),
|
|
45
|
+
});
|
|
40
46
|
}
|
|
41
47
|
}
|
|
42
|
-
|
|
43
|
-
return result
|
|
48
|
+
|
|
49
|
+
return result;
|
|
44
50
|
}
|
package/src/types.ts
CHANGED
|
@@ -3,24 +3,36 @@
|
|
|
3
3
|
*/
|
|
4
4
|
export interface Snippet {
|
|
5
5
|
/** The primary name/key of the snippet */
|
|
6
|
-
name: string
|
|
6
|
+
name: string;
|
|
7
7
|
/** The content of the snippet (without frontmatter) */
|
|
8
|
-
content: string
|
|
8
|
+
content: string;
|
|
9
9
|
/** Alternative names that also trigger this snippet */
|
|
10
|
-
aliases: string[]
|
|
10
|
+
aliases: string[];
|
|
11
11
|
}
|
|
12
12
|
|
|
13
13
|
/**
|
|
14
|
-
*
|
|
14
|
+
* Extended snippet info with file metadata
|
|
15
15
|
*/
|
|
16
|
-
export
|
|
16
|
+
export interface SnippetInfo {
|
|
17
|
+
name: string;
|
|
18
|
+
content: string;
|
|
19
|
+
aliases: string[];
|
|
20
|
+
description?: string;
|
|
21
|
+
filePath: string;
|
|
22
|
+
source: "global" | "project";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Snippet registry that maps keys to snippet info
|
|
27
|
+
*/
|
|
28
|
+
export type SnippetRegistry = Map<string, SnippetInfo>;
|
|
17
29
|
|
|
18
30
|
/**
|
|
19
31
|
* Frontmatter data from snippet files
|
|
20
32
|
*/
|
|
21
33
|
export interface SnippetFrontmatter {
|
|
22
34
|
/** Alternative hashtags for this snippet */
|
|
23
|
-
aliases?: string[]
|
|
35
|
+
aliases?: string[];
|
|
24
36
|
/** Optional description of what this snippet does */
|
|
25
|
-
description?: string
|
|
37
|
+
description?: string;
|
|
26
38
|
}
|