opencode-adaptive-snip 0.1.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/LICENSE +21 -0
- package/README.md +841 -0
- package/dist/analyze.d.ts +23 -0
- package/dist/analyze.js +333 -0
- package/dist/config.d.ts +2 -0
- package/dist/config.js +81 -0
- package/dist/events.d.ts +3 -0
- package/dist/events.js +57 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +20 -0
- package/dist/rules.d.ts +4 -0
- package/dist/rules.js +39 -0
- package/dist/snip.d.ts +4 -0
- package/dist/snip.js +57 -0
- package/dist/store.d.ts +12 -0
- package/dist/store.js +119 -0
- package/dist/types.d.ts +185 -0
- package/dist/types.js +54 -0
- package/dist/utils.d.ts +12 -0
- package/dist/utils.js +216 -0
- package/package.json +40 -0
package/dist/store.js
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
import { renameSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, relative, resolve } from "node:path";
|
|
3
|
+
import { compileRules } from "./rules.js";
|
|
4
|
+
export function resolveProjectPath(projectDir, filePath) {
|
|
5
|
+
const root = resolve(projectDir);
|
|
6
|
+
const fullPath = resolve(root, filePath);
|
|
7
|
+
const rel = relative(root, fullPath);
|
|
8
|
+
if (rel === "" || (!rel.startsWith("..") && !rel.startsWith("/") && rel !== "..")) {
|
|
9
|
+
return fullPath;
|
|
10
|
+
}
|
|
11
|
+
throw new Error(`Path escapes project directory: ${filePath}`);
|
|
12
|
+
}
|
|
13
|
+
export function isSafeSnipPrefix(prefix) {
|
|
14
|
+
return /^snip(?:\s+[A-Za-z0-9][A-Za-z0-9._=:/-]*|\s+--[A-Za-z0-9][A-Za-z0-9._=:/-]*)*$/.test(prefix);
|
|
15
|
+
}
|
|
16
|
+
function isValidLearnedSuggestion(suggestion) {
|
|
17
|
+
if (!Number.isFinite(suggestion.confidence))
|
|
18
|
+
return false;
|
|
19
|
+
if (suggestion.confidence < 0 || suggestion.confidence > 1)
|
|
20
|
+
return false;
|
|
21
|
+
if (!suggestion.pattern.startsWith("^"))
|
|
22
|
+
return false;
|
|
23
|
+
if (!isSafeSnipPrefix(suggestion.snip))
|
|
24
|
+
return false;
|
|
25
|
+
try {
|
|
26
|
+
new RegExp(suggestion.pattern);
|
|
27
|
+
return true;
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return false;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
export class SnipRuleStore {
|
|
34
|
+
projectDir;
|
|
35
|
+
filePath;
|
|
36
|
+
constructor(projectDir, filePath) {
|
|
37
|
+
this.projectDir = projectDir;
|
|
38
|
+
this.filePath = filePath;
|
|
39
|
+
}
|
|
40
|
+
async load() {
|
|
41
|
+
const fullPath = resolveProjectPath(this.projectDir, this.filePath);
|
|
42
|
+
try {
|
|
43
|
+
const file = Bun.file(fullPath);
|
|
44
|
+
return await file.json();
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
const err = e;
|
|
48
|
+
if (err?.code === "ENOENT") {
|
|
49
|
+
return { version: 1, rules: [] };
|
|
50
|
+
}
|
|
51
|
+
console.warn(`[adaptive-snip] Failed to load rule store: ${e}`);
|
|
52
|
+
return { version: 1, rules: [] };
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
async save(store) {
|
|
56
|
+
const fullPath = resolveProjectPath(this.projectDir, this.filePath);
|
|
57
|
+
const tmpPath = fullPath + ".tmp";
|
|
58
|
+
mkdirSync(dirname(fullPath), { recursive: true });
|
|
59
|
+
await Bun.write(tmpPath, JSON.stringify(store, null, 2));
|
|
60
|
+
renameSync(tmpPath, fullPath);
|
|
61
|
+
}
|
|
62
|
+
async getCompiledsnipRules() {
|
|
63
|
+
const store = await this.load();
|
|
64
|
+
return compileRules(store.rules);
|
|
65
|
+
}
|
|
66
|
+
async mergeLearned(suggestions, minConfidence, maxRules) {
|
|
67
|
+
const store = await this.load();
|
|
68
|
+
for (const suggestion of suggestions) {
|
|
69
|
+
if (suggestion.confidence < minConfidence)
|
|
70
|
+
continue;
|
|
71
|
+
if (!isValidLearnedSuggestion(suggestion)) {
|
|
72
|
+
console.warn("[adaptive-snip] Ignoring unsafe learned snip rule:", suggestion.pattern);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const configConflict = store.rules.some((r) => r.pattern === suggestion.pattern && r.source === "config");
|
|
76
|
+
if (configConflict)
|
|
77
|
+
continue;
|
|
78
|
+
const existingIndex = store.rules.findIndex((r) => r.pattern === suggestion.pattern && r.source === "learned");
|
|
79
|
+
if (existingIndex !== -1) {
|
|
80
|
+
const existing = store.rules[existingIndex];
|
|
81
|
+
if ((existing.confidence ?? 0) < suggestion.confidence) {
|
|
82
|
+
store.rules[existingIndex] = {
|
|
83
|
+
...existing,
|
|
84
|
+
snip: suggestion.snip,
|
|
85
|
+
confidence: suggestion.confidence,
|
|
86
|
+
description: suggestion.description ?? existing.description,
|
|
87
|
+
updatedAt: new Date().toISOString(),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
const newRule = {
|
|
93
|
+
pattern: suggestion.pattern,
|
|
94
|
+
snip: suggestion.snip,
|
|
95
|
+
confidence: suggestion.confidence,
|
|
96
|
+
description: suggestion.description,
|
|
97
|
+
source: "learned",
|
|
98
|
+
createdAt: new Date().toISOString(),
|
|
99
|
+
updatedAt: new Date().toISOString(),
|
|
100
|
+
};
|
|
101
|
+
store.rules.push(newRule);
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
const configRules = store.rules.filter((r) => r.source === "config");
|
|
105
|
+
const learnedRules = store.rules.filter((r) => r.source === "learned");
|
|
106
|
+
if (learnedRules.length > maxRules) {
|
|
107
|
+
learnedRules.sort((a, b) => (a.confidence ?? 0) - (b.confidence ?? 0));
|
|
108
|
+
const keep = learnedRules.slice(learnedRules.length - maxRules);
|
|
109
|
+
store.rules = [...configRules, ...keep];
|
|
110
|
+
}
|
|
111
|
+
store.analytics = {
|
|
112
|
+
...store.analytics,
|
|
113
|
+
lastAnalysisAt: new Date().toISOString(),
|
|
114
|
+
totalCommandsAnalyzed: (store.analytics?.totalCommandsAnalyzed ?? 0) + suggestions.length,
|
|
115
|
+
};
|
|
116
|
+
await this.save(store);
|
|
117
|
+
return store;
|
|
118
|
+
}
|
|
119
|
+
}
|
package/dist/types.d.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { Hooks, Plugin, PluginInput, PluginModule } from "@opencode-ai/plugin";
|
|
2
|
+
export type { Hooks, Plugin, PluginInput, PluginModule };
|
|
3
|
+
export interface SnipRule {
|
|
4
|
+
/** Regex pattern (JSON-serializable), e.g. "^go test" */
|
|
5
|
+
pattern: string;
|
|
6
|
+
/** Snip prefix to prepend, e.g. "snip --timeout 120", "snip" */
|
|
7
|
+
snip: string;
|
|
8
|
+
/** Regex flags, e.g. "i" for case-insensitive */
|
|
9
|
+
flags?: string;
|
|
10
|
+
/** Human-readable description */
|
|
11
|
+
description?: string;
|
|
12
|
+
/** Confidence 0-1, higher = more confident. LLM-generated only */
|
|
13
|
+
confidence?: number;
|
|
14
|
+
/** Origin of this rule */
|
|
15
|
+
source: "config" | "learned";
|
|
16
|
+
/** ISO 8601 timestamp */
|
|
17
|
+
createdAt?: string;
|
|
18
|
+
/** ISO 8601 timestamp */
|
|
19
|
+
updatedAt?: string;
|
|
20
|
+
}
|
|
21
|
+
export interface RuleStore {
|
|
22
|
+
/** Schema version */
|
|
23
|
+
version: number;
|
|
24
|
+
/** All rules (config + learned) */
|
|
25
|
+
rules: SnipRule[];
|
|
26
|
+
/** Analytics metadata */
|
|
27
|
+
analytics?: RuleAnalytics;
|
|
28
|
+
}
|
|
29
|
+
export interface RuleAnalytics {
|
|
30
|
+
/** Last time LLM analysis ran */
|
|
31
|
+
lastAnalysisAt?: string;
|
|
32
|
+
/** Total sessions plugin has been active in */
|
|
33
|
+
totalSessions?: number;
|
|
34
|
+
/** Total commands sent for LLM analysis */
|
|
35
|
+
totalCommandsAnalyzed?: number;
|
|
36
|
+
}
|
|
37
|
+
export interface CompiledSnipRule {
|
|
38
|
+
regex: RegExp;
|
|
39
|
+
snip: string;
|
|
40
|
+
source: "config" | "learned";
|
|
41
|
+
confidence?: number;
|
|
42
|
+
}
|
|
43
|
+
export interface AnalyzeConfig {
|
|
44
|
+
/** Whether analyze mode is enabled */
|
|
45
|
+
enabled: boolean;
|
|
46
|
+
/** Auto-create LLM sessions to learn from command patterns */
|
|
47
|
+
autoLearn: boolean;
|
|
48
|
+
/** Number of command-output pairs to buffer before analysis */
|
|
49
|
+
batchSize: number;
|
|
50
|
+
/** Minimum confidence to save a learned rule */
|
|
51
|
+
minConfidence: number;
|
|
52
|
+
/** Maximum number of learned rules to keep */
|
|
53
|
+
maxRules: number;
|
|
54
|
+
/** Override LLM model for analysis */
|
|
55
|
+
llmModel?: {
|
|
56
|
+
providerID: string;
|
|
57
|
+
modelID: string;
|
|
58
|
+
};
|
|
59
|
+
/** Minimum minutes between analyses */
|
|
60
|
+
cooldownMinutes: number;
|
|
61
|
+
}
|
|
62
|
+
export interface AdaptiveSnipOptions {
|
|
63
|
+
/** Analyze mode config. true = defaults, false = disabled */
|
|
64
|
+
analyze?: boolean | Partial<AnalyzeConfig>;
|
|
65
|
+
/** User-provided rules (source = "config"), highest priority */
|
|
66
|
+
rules?: SnipRule[];
|
|
67
|
+
/** Fallback snip prefix. null = no fallback. undefined = no fallback */
|
|
68
|
+
fallback?: SnipFallback | null;
|
|
69
|
+
/** Path relative to project dir, default ".opencode/snip-rules.json" */
|
|
70
|
+
ruleFile?: string;
|
|
71
|
+
}
|
|
72
|
+
export interface NormalizedAdaptiveSnipOptions {
|
|
73
|
+
/** Fully resolved analyze config */
|
|
74
|
+
analyze: AnalyzeConfig;
|
|
75
|
+
/** User-provided rules (source = "config"), highest priority */
|
|
76
|
+
rules: SnipRule[];
|
|
77
|
+
/** Fallback snip prefix. null/undefined = no fallback */
|
|
78
|
+
fallback?: SnipFallback | null;
|
|
79
|
+
/** Path relative to project dir */
|
|
80
|
+
ruleFile: string;
|
|
81
|
+
}
|
|
82
|
+
export interface SnipFallback {
|
|
83
|
+
/** Fallback snip prefix, e.g. "snip" */
|
|
84
|
+
prefix: string;
|
|
85
|
+
}
|
|
86
|
+
export interface CommandPair {
|
|
87
|
+
callID: string;
|
|
88
|
+
command: string;
|
|
89
|
+
output: string;
|
|
90
|
+
startedAt: number;
|
|
91
|
+
endedAt: number;
|
|
92
|
+
duration: number;
|
|
93
|
+
}
|
|
94
|
+
export interface ParsedCommand {
|
|
95
|
+
/** Env var prefix, e.g. "FOO=bar " or "" */
|
|
96
|
+
envPrefix: string;
|
|
97
|
+
/** Command after stripping env vars */
|
|
98
|
+
bareCommand: string;
|
|
99
|
+
/** Whether command is an unproxyable shell builtin */
|
|
100
|
+
isBuiltin: boolean;
|
|
101
|
+
/** Segments split by operators: ["cmd1", " && ", "cmd2"] */
|
|
102
|
+
segments: string[];
|
|
103
|
+
/** First segment before unquoted pipe */
|
|
104
|
+
firstPipeSegment: string;
|
|
105
|
+
/** Whether command has an unquoted pipe */
|
|
106
|
+
hasPipe: boolean;
|
|
107
|
+
/** Index of first unquoted pipe, or -1 */
|
|
108
|
+
pipeIndex?: number;
|
|
109
|
+
}
|
|
110
|
+
export interface LLMRuleSuggestion {
|
|
111
|
+
pattern: string;
|
|
112
|
+
snip: string;
|
|
113
|
+
confidence: number;
|
|
114
|
+
description?: string;
|
|
115
|
+
}
|
|
116
|
+
export interface LLMAnalysisResponse {
|
|
117
|
+
rules: LLMRuleSuggestion[];
|
|
118
|
+
}
|
|
119
|
+
export declare const ANALYSIS_JSON_SCHEMA: {
|
|
120
|
+
readonly type: "object";
|
|
121
|
+
readonly properties: {
|
|
122
|
+
readonly rules: {
|
|
123
|
+
readonly type: "array";
|
|
124
|
+
readonly items: {
|
|
125
|
+
readonly type: "object";
|
|
126
|
+
readonly properties: {
|
|
127
|
+
readonly pattern: {
|
|
128
|
+
readonly type: "string";
|
|
129
|
+
readonly pattern: "^\\^";
|
|
130
|
+
readonly description: "Regex to match the command";
|
|
131
|
+
};
|
|
132
|
+
readonly snip: {
|
|
133
|
+
readonly type: "string";
|
|
134
|
+
readonly pattern: "^snip(?:\\s+[A-Za-z0-9][A-Za-z0-9._=:/-]*|\\s+--[A-Za-z0-9][A-Za-z0-9._=:/-]*)*$";
|
|
135
|
+
readonly description: "Snip prefix to prepend to command";
|
|
136
|
+
};
|
|
137
|
+
readonly confidence: {
|
|
138
|
+
readonly type: "number";
|
|
139
|
+
readonly minimum: 0;
|
|
140
|
+
readonly maximum: 1;
|
|
141
|
+
};
|
|
142
|
+
readonly description: {
|
|
143
|
+
readonly type: "string";
|
|
144
|
+
};
|
|
145
|
+
};
|
|
146
|
+
readonly required: readonly ["pattern", "snip", "confidence"];
|
|
147
|
+
readonly additionalProperties: false;
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
};
|
|
151
|
+
readonly required: readonly ["rules"];
|
|
152
|
+
readonly additionalProperties: false;
|
|
153
|
+
};
|
|
154
|
+
export interface ClassifierDecision {
|
|
155
|
+
command: string;
|
|
156
|
+
shouldSnip: boolean;
|
|
157
|
+
reason: string;
|
|
158
|
+
}
|
|
159
|
+
export interface ClassifierResponse {
|
|
160
|
+
decisions: ClassifierDecision[];
|
|
161
|
+
}
|
|
162
|
+
export declare const CLASSIFIER_JSON_SCHEMA: {
|
|
163
|
+
readonly type: "object";
|
|
164
|
+
readonly properties: {
|
|
165
|
+
readonly decisions: {
|
|
166
|
+
readonly type: "array";
|
|
167
|
+
readonly items: {
|
|
168
|
+
readonly type: "object";
|
|
169
|
+
readonly properties: {
|
|
170
|
+
readonly command: {
|
|
171
|
+
readonly type: "string";
|
|
172
|
+
};
|
|
173
|
+
readonly shouldSnip: {
|
|
174
|
+
readonly type: "boolean";
|
|
175
|
+
};
|
|
176
|
+
readonly reason: {
|
|
177
|
+
readonly type: "string";
|
|
178
|
+
};
|
|
179
|
+
};
|
|
180
|
+
readonly required: readonly ["command", "shouldSnip", "reason"];
|
|
181
|
+
};
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
readonly required: readonly ["decisions"];
|
|
185
|
+
};
|
package/dist/types.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// ── LLM Structured Output Schema (JSON Schema) ──
|
|
2
|
+
export const ANALYSIS_JSON_SCHEMA = {
|
|
3
|
+
type: "object",
|
|
4
|
+
properties: {
|
|
5
|
+
rules: {
|
|
6
|
+
type: "array",
|
|
7
|
+
items: {
|
|
8
|
+
type: "object",
|
|
9
|
+
properties: {
|
|
10
|
+
pattern: {
|
|
11
|
+
type: "string",
|
|
12
|
+
pattern: "^\\^",
|
|
13
|
+
description: "Regex to match the command",
|
|
14
|
+
},
|
|
15
|
+
snip: {
|
|
16
|
+
type: "string",
|
|
17
|
+
pattern: "^snip(?:\\s+[A-Za-z0-9][A-Za-z0-9._=:/-]*|\\s+--[A-Za-z0-9][A-Za-z0-9._=:/-]*)*$",
|
|
18
|
+
description: "Snip prefix to prepend to command",
|
|
19
|
+
},
|
|
20
|
+
confidence: {
|
|
21
|
+
type: "number",
|
|
22
|
+
minimum: 0,
|
|
23
|
+
maximum: 1,
|
|
24
|
+
},
|
|
25
|
+
description: {
|
|
26
|
+
type: "string",
|
|
27
|
+
},
|
|
28
|
+
},
|
|
29
|
+
required: ["pattern", "snip", "confidence"],
|
|
30
|
+
additionalProperties: false,
|
|
31
|
+
},
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["rules"],
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
};
|
|
37
|
+
export const CLASSIFIER_JSON_SCHEMA = {
|
|
38
|
+
type: "object",
|
|
39
|
+
properties: {
|
|
40
|
+
decisions: {
|
|
41
|
+
type: "array",
|
|
42
|
+
items: {
|
|
43
|
+
type: "object",
|
|
44
|
+
properties: {
|
|
45
|
+
command: { type: "string" },
|
|
46
|
+
shouldSnip: { type: "boolean" },
|
|
47
|
+
reason: { type: "string" },
|
|
48
|
+
},
|
|
49
|
+
required: ["command", "shouldSnip", "reason"],
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
required: ["decisions"],
|
|
54
|
+
};
|
package/dist/utils.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ParsedCommand } from "./types.js";
|
|
2
|
+
export declare const ENV_VAR_RE: RegExp;
|
|
3
|
+
export declare const UNPROXYABLE_COMMANDS: Set<string>;
|
|
4
|
+
export declare const OPERATOR_RE: RegExp;
|
|
5
|
+
export declare function readEnvPrefix(cmd: string): string;
|
|
6
|
+
export declare function stripEnvPrefix(cmd: string): string;
|
|
7
|
+
export declare function isBuiltin(cmd: string): boolean;
|
|
8
|
+
export declare function findFirstPipe(cmd: string): number;
|
|
9
|
+
export declare function splitOperators(cmd: string): string[];
|
|
10
|
+
export declare function isOperatorSegment(segment: string): boolean;
|
|
11
|
+
export declare function isAlreadyPrefixed(cmd: string, prefix: string): boolean;
|
|
12
|
+
export declare function parseCommand(cmd: string): ParsedCommand;
|
package/dist/utils.js
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
1
|
+
export const ENV_VAR_RE = /^([A-Za-z_][A-Za-z0-9_]*=[^\s]* +)*/;
|
|
2
|
+
export const UNPROXYABLE_COMMANDS = new Set([
|
|
3
|
+
"cd",
|
|
4
|
+
"source",
|
|
5
|
+
".",
|
|
6
|
+
"export",
|
|
7
|
+
"alias",
|
|
8
|
+
"unset",
|
|
9
|
+
"set",
|
|
10
|
+
"shopt",
|
|
11
|
+
"eval",
|
|
12
|
+
"exec",
|
|
13
|
+
"echo",
|
|
14
|
+
"pwd",
|
|
15
|
+
"type",
|
|
16
|
+
"read",
|
|
17
|
+
"ulimit",
|
|
18
|
+
]);
|
|
19
|
+
export const OPERATOR_RE = /(\s*(?:&&|\|\||;)\s*|\s&\s?)/;
|
|
20
|
+
function isNameStart(ch) {
|
|
21
|
+
return !!ch && /[A-Za-z_]/.test(ch);
|
|
22
|
+
}
|
|
23
|
+
function isNameChar(ch) {
|
|
24
|
+
return !!ch && /[A-Za-z0-9_]/.test(ch);
|
|
25
|
+
}
|
|
26
|
+
function readQuoted(input, start) {
|
|
27
|
+
const quote = input[start];
|
|
28
|
+
let i = start + 1;
|
|
29
|
+
while (i < input.length) {
|
|
30
|
+
const ch = input[i];
|
|
31
|
+
if (quote === '"' && ch === "\\") {
|
|
32
|
+
i += 2;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
if (ch === quote)
|
|
36
|
+
return i + 1;
|
|
37
|
+
i++;
|
|
38
|
+
}
|
|
39
|
+
return start;
|
|
40
|
+
}
|
|
41
|
+
function readEnvAssignment(input, start) {
|
|
42
|
+
let i = start;
|
|
43
|
+
if (!isNameStart(input[i]))
|
|
44
|
+
return start;
|
|
45
|
+
i++;
|
|
46
|
+
while (isNameChar(input[i]))
|
|
47
|
+
i++;
|
|
48
|
+
if (input[i] !== "=")
|
|
49
|
+
return start;
|
|
50
|
+
i++;
|
|
51
|
+
if (input[i] === "'" || input[i] === '"') {
|
|
52
|
+
const quotedEnd = readQuoted(input, i);
|
|
53
|
+
if (quotedEnd === i)
|
|
54
|
+
return start;
|
|
55
|
+
i = quotedEnd;
|
|
56
|
+
}
|
|
57
|
+
else {
|
|
58
|
+
while (i < input.length && !/\s/.test(input[i]))
|
|
59
|
+
i++;
|
|
60
|
+
}
|
|
61
|
+
if (i < input.length && !/\s/.test(input[i]))
|
|
62
|
+
return start;
|
|
63
|
+
while (i < input.length && /\s/.test(input[i]))
|
|
64
|
+
i++;
|
|
65
|
+
return i;
|
|
66
|
+
}
|
|
67
|
+
export function readEnvPrefix(cmd) {
|
|
68
|
+
let i = 0;
|
|
69
|
+
let last = 0;
|
|
70
|
+
while (i < cmd.length) {
|
|
71
|
+
const next = readEnvAssignment(cmd, i);
|
|
72
|
+
if (next === i)
|
|
73
|
+
break;
|
|
74
|
+
last = next;
|
|
75
|
+
i = next;
|
|
76
|
+
}
|
|
77
|
+
return cmd.slice(0, last);
|
|
78
|
+
}
|
|
79
|
+
export function stripEnvPrefix(cmd) {
|
|
80
|
+
return cmd.slice(readEnvPrefix(cmd).length);
|
|
81
|
+
}
|
|
82
|
+
export function isBuiltin(cmd) {
|
|
83
|
+
const firstWord = cmd.trimStart().split(/\s+/)[0] || "";
|
|
84
|
+
return UNPROXYABLE_COMMANDS.has(firstWord);
|
|
85
|
+
}
|
|
86
|
+
export function findFirstPipe(cmd) {
|
|
87
|
+
let inSingle = false;
|
|
88
|
+
let inDouble = false;
|
|
89
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
90
|
+
const ch = cmd[i];
|
|
91
|
+
if (inSingle) {
|
|
92
|
+
if (ch === "'")
|
|
93
|
+
inSingle = false;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (inDouble) {
|
|
97
|
+
if (ch === '"')
|
|
98
|
+
inDouble = false;
|
|
99
|
+
continue;
|
|
100
|
+
}
|
|
101
|
+
if (ch === "'") {
|
|
102
|
+
inSingle = true;
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (ch === '"') {
|
|
106
|
+
inDouble = true;
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
if (ch === "\\") {
|
|
110
|
+
i++;
|
|
111
|
+
continue;
|
|
112
|
+
}
|
|
113
|
+
if (ch === "|") {
|
|
114
|
+
if (i + 1 < cmd.length && cmd[i + 1] === "|") {
|
|
115
|
+
i++;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
return i;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
export function splitOperators(cmd) {
|
|
124
|
+
const parts = [];
|
|
125
|
+
let start = 0;
|
|
126
|
+
let inSingle = false;
|
|
127
|
+
let inDouble = false;
|
|
128
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
129
|
+
const ch = cmd[i];
|
|
130
|
+
if (inSingle) {
|
|
131
|
+
if (ch === "'")
|
|
132
|
+
inSingle = false;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (inDouble) {
|
|
136
|
+
if (ch === "\\") {
|
|
137
|
+
i++;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
if (ch === '"')
|
|
141
|
+
inDouble = false;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
if (ch === "'") {
|
|
145
|
+
inSingle = true;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (ch === '"') {
|
|
149
|
+
inDouble = true;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (ch === "\\") {
|
|
153
|
+
i++;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
let opStart = -1;
|
|
157
|
+
let opEnd = -1;
|
|
158
|
+
if ((ch === "&" && cmd[i + 1] === "&") || (ch === "|" && cmd[i + 1] === "|")) {
|
|
159
|
+
opStart = i;
|
|
160
|
+
opEnd = i + 2;
|
|
161
|
+
}
|
|
162
|
+
else if (ch === ";") {
|
|
163
|
+
opStart = i;
|
|
164
|
+
opEnd = i + 1;
|
|
165
|
+
}
|
|
166
|
+
else if (ch === "&") {
|
|
167
|
+
opStart = i;
|
|
168
|
+
opEnd = i + 1;
|
|
169
|
+
}
|
|
170
|
+
if (opStart === -1)
|
|
171
|
+
continue;
|
|
172
|
+
while (opStart > start && /\s/.test(cmd[opStart - 1]))
|
|
173
|
+
opStart--;
|
|
174
|
+
while (opEnd < cmd.length && /\s/.test(cmd[opEnd]))
|
|
175
|
+
opEnd++;
|
|
176
|
+
parts.push(cmd.slice(start, opStart));
|
|
177
|
+
parts.push(cmd.slice(opStart, opEnd));
|
|
178
|
+
start = opEnd;
|
|
179
|
+
i = opEnd - 1;
|
|
180
|
+
}
|
|
181
|
+
parts.push(cmd.slice(start));
|
|
182
|
+
return parts;
|
|
183
|
+
}
|
|
184
|
+
export function isOperatorSegment(segment) {
|
|
185
|
+
return /^(?:\s*(?:&&|\|\||;|&)\s*)$/.test(segment);
|
|
186
|
+
}
|
|
187
|
+
export function isAlreadyPrefixed(cmd, prefix) {
|
|
188
|
+
const trimmed = cmd.trimStart();
|
|
189
|
+
if (!trimmed || !prefix)
|
|
190
|
+
return false;
|
|
191
|
+
const firstWord = trimmed.split(/\s+/)[0];
|
|
192
|
+
if (firstWord === "snip")
|
|
193
|
+
return true;
|
|
194
|
+
if (!trimmed.startsWith(prefix))
|
|
195
|
+
return false;
|
|
196
|
+
const next = trimmed[prefix.length];
|
|
197
|
+
return next === undefined || /\s/.test(next);
|
|
198
|
+
}
|
|
199
|
+
export function parseCommand(cmd) {
|
|
200
|
+
const envPrefix = readEnvPrefix(cmd);
|
|
201
|
+
const bareCommand = stripEnvPrefix(cmd);
|
|
202
|
+
const isB = isBuiltin(bareCommand);
|
|
203
|
+
const pipeIdx = findFirstPipe(bareCommand);
|
|
204
|
+
const hasPipe = pipeIdx !== -1;
|
|
205
|
+
const firstPipeSegment = hasPipe ? bareCommand.substring(0, pipeIdx) : bareCommand;
|
|
206
|
+
const segments = splitOperators(bareCommand);
|
|
207
|
+
return {
|
|
208
|
+
envPrefix,
|
|
209
|
+
bareCommand,
|
|
210
|
+
isBuiltin: isB,
|
|
211
|
+
segments,
|
|
212
|
+
firstPipeSegment,
|
|
213
|
+
hasPipe,
|
|
214
|
+
pipeIndex: hasPipe ? pipeIdx : undefined,
|
|
215
|
+
};
|
|
216
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "opencode-adaptive-snip",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "OpenCode plugin that learns verbose shell commands and automatically prefixes them with snip.",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "./dist/index.js",
|
|
8
|
+
"module": "./dist/index.js",
|
|
9
|
+
"exports": {
|
|
10
|
+
".": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"dist",
|
|
14
|
+
"README.md"
|
|
15
|
+
],
|
|
16
|
+
"scripts": {
|
|
17
|
+
"build": "tsc -p tsconfig.dist.json",
|
|
18
|
+
"test": "bun test",
|
|
19
|
+
"typecheck": "tsc -p tsconfig.build.json --noEmit",
|
|
20
|
+
"prepack": "npm run build"
|
|
21
|
+
},
|
|
22
|
+
"types": "./dist/index.d.ts",
|
|
23
|
+
"publishConfig": {
|
|
24
|
+
"access": "public"
|
|
25
|
+
},
|
|
26
|
+
"keywords": [
|
|
27
|
+
"opencode",
|
|
28
|
+
"opencode-plugin",
|
|
29
|
+
"snip",
|
|
30
|
+
"shell",
|
|
31
|
+
"llm"
|
|
32
|
+
],
|
|
33
|
+
"packageManager": "bun@1.3.13",
|
|
34
|
+
"devDependencies": {
|
|
35
|
+
"@types/bun": "latest",
|
|
36
|
+
"@opencode-ai/plugin": "latest",
|
|
37
|
+
"@opencode-ai/sdk": "latest",
|
|
38
|
+
"typescript": "^5"
|
|
39
|
+
}
|
|
40
|
+
}
|