smoothie-code 1.0.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 +82 -0
- package/auto-blend-hook.sh +94 -0
- package/banner-v2.svg +307 -0
- package/bin/smoothie +67 -0
- package/config.json +17 -0
- package/dist/blend-cli.d.ts +12 -0
- package/dist/blend-cli.js +185 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +286 -0
- package/dist/select-models.d.ts +2 -0
- package/dist/select-models.js +277 -0
- package/docs/banner.svg +307 -0
- package/docs/favicon.svg +17 -0
- package/docs/index.html +306 -0
- package/icon.svg +17 -0
- package/install.sh +377 -0
- package/package.json +20 -0
- package/plan-hook.sh +38 -0
- package/pr-blend-hook.sh +95 -0
- package/src/blend-cli.ts +219 -0
- package/src/index.ts +367 -0
- package/src/select-models.ts +318 -0
- package/tsconfig.json +14 -0
package/package.json
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "smoothie-code",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"main": "dist/index.js",
|
|
6
|
+
"bin": {
|
|
7
|
+
"smoothie": "./bin/smoothie"
|
|
8
|
+
},
|
|
9
|
+
"scripts": {
|
|
10
|
+
"build": "tsc",
|
|
11
|
+
"start": "node dist/index.js"
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@modelcontextprotocol/sdk": "latest"
|
|
15
|
+
},
|
|
16
|
+
"devDependencies": {
|
|
17
|
+
"typescript": "^5.0.0",
|
|
18
|
+
"@types/node": "^22.0.0"
|
|
19
|
+
}
|
|
20
|
+
}
|
package/plan-hook.sh
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
|
|
3
|
+
INPUT=$(cat)
|
|
4
|
+
|
|
5
|
+
STOP_ACTIVE=$(echo "$INPUT" | python3 -c "
|
|
6
|
+
import sys, json
|
|
7
|
+
try:
|
|
8
|
+
d = json.load(sys.stdin)
|
|
9
|
+
print(d.get('stop_hook_active', False))
|
|
10
|
+
except:
|
|
11
|
+
print(False)
|
|
12
|
+
" 2>/dev/null)
|
|
13
|
+
|
|
14
|
+
if [ "$STOP_ACTIVE" = "True" ]; then
|
|
15
|
+
exit 0
|
|
16
|
+
fi
|
|
17
|
+
|
|
18
|
+
TRANSCRIPT=$(echo "$INPUT" | python3 -c "
|
|
19
|
+
import sys, json
|
|
20
|
+
try:
|
|
21
|
+
d = json.load(sys.stdin)
|
|
22
|
+
print(d.get('transcript_path', ''))
|
|
23
|
+
except:
|
|
24
|
+
print('')
|
|
25
|
+
" 2>/dev/null)
|
|
26
|
+
|
|
27
|
+
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
|
|
28
|
+
exit 0
|
|
29
|
+
fi
|
|
30
|
+
|
|
31
|
+
IS_PLAN=$(tail -c 3000 "$TRANSCRIPT" | grep -c "Would you like to proceed\|## Plan\|### Plan\|Here's my plan\|Here is my plan\|Steps to\|Step 1\b\|step 1\b" 2>/dev/null || true)
|
|
32
|
+
|
|
33
|
+
if [ "$IS_PLAN" -gt 0 ]; then
|
|
34
|
+
echo ""
|
|
35
|
+
echo "š§ Smoothie: type 'smoothie' in option 5 to blend this plan before approving."
|
|
36
|
+
fi
|
|
37
|
+
|
|
38
|
+
exit 0
|
package/pr-blend-hook.sh
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
#
|
|
3
|
+
# pr-blend-hook.sh ā PreToolUse hook for Bash commands
|
|
4
|
+
#
|
|
5
|
+
# Intercepts `gh pr create` commands, runs Smoothie blend on the
|
|
6
|
+
# branch diff, and injects review results so Claude can revise
|
|
7
|
+
# the PR description before creating it.
|
|
8
|
+
#
|
|
9
|
+
|
|
10
|
+
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
11
|
+
|
|
12
|
+
# Read hook input from stdin
|
|
13
|
+
INPUT=$(cat)
|
|
14
|
+
|
|
15
|
+
# Check if auto-blend is enabled in config
|
|
16
|
+
AUTO_ENABLED=$(node -e "
|
|
17
|
+
try {
|
|
18
|
+
const c = JSON.parse(require('fs').readFileSync('$SCRIPT_DIR/config.json','utf8'));
|
|
19
|
+
console.log(c.auto_blend === true ? 'true' : 'false');
|
|
20
|
+
} catch(e) { console.log('false'); }
|
|
21
|
+
" 2>/dev/null)
|
|
22
|
+
|
|
23
|
+
if [ "$AUTO_ENABLED" != "true" ]; then
|
|
24
|
+
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
25
|
+
exit 0
|
|
26
|
+
fi
|
|
27
|
+
|
|
28
|
+
# Check if this is a gh pr create command
|
|
29
|
+
COMMAND=$(echo "$INPUT" | python3 -c "
|
|
30
|
+
import sys, json
|
|
31
|
+
try:
|
|
32
|
+
d = json.load(sys.stdin)
|
|
33
|
+
print(d.get('tool_input', {}).get('command', ''))
|
|
34
|
+
except:
|
|
35
|
+
print('')
|
|
36
|
+
" 2>/dev/null)
|
|
37
|
+
|
|
38
|
+
if ! echo "$COMMAND" | grep -q "gh pr create"; then
|
|
39
|
+
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
40
|
+
exit 0
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# Extract cwd from hook input
|
|
44
|
+
CWD=$(echo "$INPUT" | python3 -c "
|
|
45
|
+
import sys, json
|
|
46
|
+
try:
|
|
47
|
+
d = json.load(sys.stdin)
|
|
48
|
+
print(d.get('cwd', ''))
|
|
49
|
+
except:
|
|
50
|
+
print('')
|
|
51
|
+
" 2>/dev/null)
|
|
52
|
+
|
|
53
|
+
# Get the diff
|
|
54
|
+
DIFF=$(cd "$CWD" 2>/dev/null && git diff main...HEAD 2>/dev/null | head -c 4000)
|
|
55
|
+
|
|
56
|
+
if [ -z "$DIFF" ]; then
|
|
57
|
+
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
58
|
+
exit 0
|
|
59
|
+
fi
|
|
60
|
+
|
|
61
|
+
# Build review prompt and run blend
|
|
62
|
+
REVIEW_PROMPT="Review this PR diff for bugs, security issues, and improvements:
|
|
63
|
+
|
|
64
|
+
$DIFF
|
|
65
|
+
|
|
66
|
+
Provide concise, actionable feedback."
|
|
67
|
+
|
|
68
|
+
BLEND_RESULTS=$(echo "$REVIEW_PROMPT" | node "$SCRIPT_DIR/dist/blend-cli.js" 2>/dev/stderr)
|
|
69
|
+
|
|
70
|
+
if [ -z "$BLEND_RESULTS" ]; then
|
|
71
|
+
echo '{"hookSpecificOutput":{"hookEventName":"PreToolUse","permissionDecision":"allow"}}'
|
|
72
|
+
exit 0
|
|
73
|
+
fi
|
|
74
|
+
|
|
75
|
+
# Build the additionalContext string
|
|
76
|
+
CONTEXT="Smoothie PR review ā multiple models reviewed this diff:
|
|
77
|
+
|
|
78
|
+
$BLEND_RESULTS
|
|
79
|
+
|
|
80
|
+
Consider this feedback. If there are valid issues, revise the PR description to note them or fix the code before creating the PR."
|
|
81
|
+
|
|
82
|
+
# Return: allow Bash but inject blend results
|
|
83
|
+
node -e "
|
|
84
|
+
const ctx = $(echo "$CONTEXT" | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))" 2>/dev/null);
|
|
85
|
+
console.log(JSON.stringify({
|
|
86
|
+
hookSpecificOutput: {
|
|
87
|
+
hookEventName: 'PreToolUse',
|
|
88
|
+
permissionDecision: 'allow',
|
|
89
|
+
permissionDecisionReason: 'Smoothie PR review completed',
|
|
90
|
+
additionalContext: ctx
|
|
91
|
+
}
|
|
92
|
+
}));
|
|
93
|
+
"
|
|
94
|
+
|
|
95
|
+
exit 0
|
package/src/blend-cli.ts
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* blend-cli.ts ā Standalone blend runner for hooks.
|
|
5
|
+
*
|
|
6
|
+
* Usage:
|
|
7
|
+
* node dist/blend-cli.js "Review this plan: ..."
|
|
8
|
+
* echo "plan text" | node dist/blend-cli.js
|
|
9
|
+
*
|
|
10
|
+
* Queries Codex + OpenRouter models in parallel, prints JSON results to stdout.
|
|
11
|
+
* Progress goes to stderr so it doesn't interfere with hook JSON output.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { readFileSync } from 'fs';
|
|
15
|
+
import { fileURLToPath } from 'url';
|
|
16
|
+
import { dirname, join } from 'path';
|
|
17
|
+
import { execFile as execFileCb } from 'child_process';
|
|
18
|
+
import { promisify } from 'util';
|
|
19
|
+
import { createInterface } from 'readline';
|
|
20
|
+
|
|
21
|
+
const execFile = promisify(execFileCb);
|
|
22
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
23
|
+
const PROJECT_ROOT = join(__dirname, '..');
|
|
24
|
+
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
// Types
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
|
|
29
|
+
interface Config {
|
|
30
|
+
openrouter_models: Array<{ id: string; label: string }>;
|
|
31
|
+
auto_blend?: boolean;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
interface ModelResult {
|
|
35
|
+
model: string;
|
|
36
|
+
response: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface OpenRouterResponse {
|
|
40
|
+
choices?: Array<{ message: { content: string } }>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// .env loader
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
function loadEnv(): void {
|
|
47
|
+
try {
|
|
48
|
+
const env = readFileSync(join(PROJECT_ROOT, '.env'), 'utf8');
|
|
49
|
+
for (const line of env.split('\n')) {
|
|
50
|
+
const [key, ...val] = line.split('=');
|
|
51
|
+
if (key && val.length) process.env[key.trim()] = val.join('=').trim();
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// no .env
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
loadEnv();
|
|
58
|
+
|
|
59
|
+
// ---------------------------------------------------------------------------
|
|
60
|
+
// Model queries (same as index.ts)
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
|
|
63
|
+
async function queryCodex(prompt: string): Promise<ModelResult> {
|
|
64
|
+
try {
|
|
65
|
+
const tmpFile = join(PROJECT_ROOT, `.codex-out-${Date.now()}.txt`);
|
|
66
|
+
await execFile('codex', ['exec', '--full-auto', '-o', tmpFile, prompt], {
|
|
67
|
+
timeout: 90_000,
|
|
68
|
+
});
|
|
69
|
+
let response: string;
|
|
70
|
+
try {
|
|
71
|
+
response = readFileSync(tmpFile, 'utf8').trim();
|
|
72
|
+
const { unlinkSync } = await import('fs');
|
|
73
|
+
unlinkSync(tmpFile);
|
|
74
|
+
} catch {
|
|
75
|
+
response = '';
|
|
76
|
+
}
|
|
77
|
+
return { model: 'Codex', response: response || '(empty response)' };
|
|
78
|
+
} catch (err: unknown) {
|
|
79
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
80
|
+
return { model: 'Codex', response: `Error: ${message}` };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function queryOpenRouter(
|
|
85
|
+
prompt: string,
|
|
86
|
+
modelId: string,
|
|
87
|
+
modelLabel: string,
|
|
88
|
+
): Promise<ModelResult> {
|
|
89
|
+
try {
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const timer = setTimeout(() => controller.abort(), 60_000);
|
|
92
|
+
const res = await fetch('https://openrouter.ai/api/v1/chat/completions', {
|
|
93
|
+
method: 'POST',
|
|
94
|
+
headers: {
|
|
95
|
+
'Authorization': `Bearer ${process.env.OPENROUTER_API_KEY}`,
|
|
96
|
+
'HTTP-Referer': 'https://hotairbag.github.io/smoothie',
|
|
97
|
+
'X-Title': 'Smoothie',
|
|
98
|
+
'Content-Type': 'application/json',
|
|
99
|
+
},
|
|
100
|
+
body: JSON.stringify({
|
|
101
|
+
model: modelId,
|
|
102
|
+
messages: [{ role: 'user', content: prompt }],
|
|
103
|
+
}),
|
|
104
|
+
signal: controller.signal,
|
|
105
|
+
});
|
|
106
|
+
clearTimeout(timer);
|
|
107
|
+
const data = (await res.json()) as OpenRouterResponse;
|
|
108
|
+
const text = data.choices?.[0]?.message?.content ?? 'No response content';
|
|
109
|
+
return { model: modelLabel, response: text };
|
|
110
|
+
} catch (err: unknown) {
|
|
111
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
112
|
+
return { model: modelLabel, response: `Error: ${message}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// ---------------------------------------------------------------------------
|
|
117
|
+
// Read prompt from arg or stdin
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
|
|
120
|
+
async function getPrompt(): Promise<string> {
|
|
121
|
+
if (process.argv[2]) return process.argv[2];
|
|
122
|
+
|
|
123
|
+
// Read from stdin
|
|
124
|
+
const rl = createInterface({ input: process.stdin });
|
|
125
|
+
const lines: string[] = [];
|
|
126
|
+
for await (const line of rl) {
|
|
127
|
+
lines.push(line);
|
|
128
|
+
}
|
|
129
|
+
return lines.join('\n');
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// ---------------------------------------------------------------------------
|
|
133
|
+
// Main
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
|
|
136
|
+
async function main(): Promise<void> {
|
|
137
|
+
const args = process.argv.slice(2);
|
|
138
|
+
const deep = args.includes('--deep');
|
|
139
|
+
const filteredArgs = args.filter(a => a !== '--deep');
|
|
140
|
+
// Temporarily override argv for getPrompt
|
|
141
|
+
process.argv = [process.argv[0], process.argv[1], ...filteredArgs];
|
|
142
|
+
const prompt = await getPrompt();
|
|
143
|
+
if (!prompt.trim()) {
|
|
144
|
+
process.stderr.write('blend-cli: no prompt provided\n');
|
|
145
|
+
process.exit(1);
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
let finalPrompt = prompt;
|
|
149
|
+
if (deep) {
|
|
150
|
+
// Read context file
|
|
151
|
+
for (const name of ['GEMINI.md', 'CLAUDE.md', 'AGENTS.md']) {
|
|
152
|
+
try {
|
|
153
|
+
const content = readFileSync(join(process.cwd(), name), 'utf8');
|
|
154
|
+
if (content.trim()) {
|
|
155
|
+
finalPrompt = `## Context File\n${content}\n\n## Prompt\n${prompt}`;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
} catch {
|
|
159
|
+
// file not found, try next
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
// Add git diff
|
|
163
|
+
try {
|
|
164
|
+
const { execFileSync } = await import('child_process');
|
|
165
|
+
const diff = execFileSync('git', ['diff', 'HEAD~3'], { encoding: 'utf8', maxBuffer: 100 * 1024, timeout: 10000 });
|
|
166
|
+
if (diff) finalPrompt += `\n\n## Recent Git Diff\n${diff.slice(0, 40000)}`;
|
|
167
|
+
} catch {
|
|
168
|
+
// no git diff available
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
let config: Config;
|
|
173
|
+
try {
|
|
174
|
+
config = JSON.parse(
|
|
175
|
+
readFileSync(join(PROJECT_ROOT, 'config.json'), 'utf8'),
|
|
176
|
+
) as Config;
|
|
177
|
+
} catch {
|
|
178
|
+
config = { openrouter_models: [] };
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const models: Array<{ fn: () => Promise<ModelResult>; label: string }> = [
|
|
182
|
+
{ fn: () => queryCodex(finalPrompt), label: 'Codex' },
|
|
183
|
+
...config.openrouter_models.map((m) => ({
|
|
184
|
+
fn: () => queryOpenRouter(finalPrompt, m.id, m.label),
|
|
185
|
+
label: m.label,
|
|
186
|
+
})),
|
|
187
|
+
];
|
|
188
|
+
|
|
189
|
+
process.stderr.write('\nš§ Smoothie blending...\n\n');
|
|
190
|
+
for (const { label } of models) {
|
|
191
|
+
process.stderr.write(` ā³ ${label.padEnd(26)} waiting...\n`);
|
|
192
|
+
}
|
|
193
|
+
process.stderr.write('\n');
|
|
194
|
+
|
|
195
|
+
const startTimes: Record<string, number> = {};
|
|
196
|
+
const promises = models.map(({ fn, label }) => {
|
|
197
|
+
startTimes[label] = Date.now();
|
|
198
|
+
return fn()
|
|
199
|
+
.then((result) => {
|
|
200
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
|
|
201
|
+
process.stderr.write(` ā ${label.padEnd(26)} done (${elapsed}s)\n`);
|
|
202
|
+
return result;
|
|
203
|
+
})
|
|
204
|
+
.catch((err: unknown) => {
|
|
205
|
+
const elapsed = ((Date.now() - startTimes[label]) / 1000).toFixed(1);
|
|
206
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
207
|
+
process.stderr.write(` ā ${label.padEnd(26)} failed (${elapsed}s)\n`);
|
|
208
|
+
return { model: label, response: `Error: ${message}` };
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
const results = await Promise.all(promises);
|
|
213
|
+
process.stderr.write('\n ā All done.\n\n');
|
|
214
|
+
|
|
215
|
+
// Output JSON to stdout (for hook consumption)
|
|
216
|
+
process.stdout.write(JSON.stringify({ results }, null, 2));
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
main();
|