Self-Contained Scripts: From Python’s UV to Bun’s TypeScript Revolution
Remember the pain of sharing a Python script with someone? “First install Python, then pip install these packages, oh and make sure you have the right Python version…” The same story repeats with Node.js scripts. Modern tools are finally solving this ancient problem by enabling truly self-contained scripts.
The Self-Contained Script Revolution
Python recently introduced inline script dependencies with PEP 723, and tools like UV have made it reality:
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "requests",
# "rich",
# ]
# ///
import requests
from rich import print
response = requests.get("https://api.github.com")
print(response.json())
1
2
3
4
5
6
7
8
9
10
11
12
13
Bun takes this concept even further for TypeScript. While Python scripts still need UV installed, Bun can compile your TypeScript into a true standalone binary that includes everything—runtime, dependencies, and your code.
From Python UV to Bun: A Natural Evolution
Let me show you the progression with a real example. Here’s a self-contained Python script using UV:
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "httpx",
# "typer",
# ]
# ///
import httpx
import typer
def main(url: str = "https://api.github.com"):
response = httpx.get(url)
print(f"Status: {response.status_code}")
print(f"Headers: {len(response.headers)}")
if __name__ == "__main__":
typer.run(main)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
Now here’s the equivalent in Bun TypeScript:
#!/usr/bin/env bun
import { $ } from "bun";
const url = process.argv[2] || "https://api.github.com";
const response = await fetch(url);
console.log(`Status: ${response.status}`);
console.log(`Headers: ${response.headers.size}`);
1
2
3
4
5
6
7
8
9
Real-World Self-Contained Scripts: Claude Code Hooks
Let me show you real self-contained TypeScript scripts from my own setup. These are Claude Code hooks that demonstrate the power of Bun’s approach:
Example 1: Command Logger Hook
#!/usr/bin/env bun
import { $ } from "bun";
interface BashToolInput {
command: string;
description?: string;
timeout?: number;
}
interface HookInput {
session_id: string;
transcript_path: string;
hook_event_name: string;
tool_name: string;
tool_input: BashToolInput;
}
const LOG_FILE = `${process.env.HOME}/.claude/command-history.log`;
const JSON_LOG = `${process.env.HOME}/.claude/command-history.jsonl`;
async function main() {
try {
const input = await Bun.stdin.json() as HookInput;
// Only log Bash commands
if (input.tool_name !== "Bash") {
process.exit(0);
}
const { command, description } = input.tool_input;
const timestamp = new Date().toISOString();
const cwd = process.cwd();
// Create log entry
const logEntry = {
timestamp,
session_id: input.session_id,
cwd,
command,
description: description || "No description",
};
// Ensure directory exists
await $`mkdir -p ${process.env.HOME}/.claude`.quiet();
// Append to JSON log for programmatic access
await Bun.write(JSON_LOG, JSON.stringify(logEntry) + 'n', { append: true });
// Create human-readable log entry
const readableEntry = `[${timestamp.replace('T', ' ').split('.')[0]}] ${cwd}
$ ${command}
# ${description || 'No description'}
${'─'.repeat(80)}
`;
await Bun.write(LOG_FILE, readableEntry, { append: true });
// Also create a daily log file
const date = new Date().toISOString().split('T')[0];
const dailyLog = `${process.env.HOME}/.claude/logs/${date}-commands.log`;
await $`mkdir -p ${process.env.HOME}/.claude/logs`.quiet();
await Bun.write(dailyLog, readableEntry, { append: true });
// Success
process.exit(0);
} catch (error) {
// Exit cleanly even on error
process.exit(0);
}
}
await main();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
Example 2: Desktop Notification Hook
Here’s another self-contained script that sends desktop notifications:
#!/usr/bin/env bun
import { $ } from "bun";
interface HookInput {
session_id: string;
transcript_path: string;
hook_event_name: string;
message: string;
}
async function sendNotification(message: string) {
// Clean up the message
const cleanMessage = message.replace(/n/g, ' ').trim();
const shortMessage = cleanMessage.length > 100
? cleanMessage.substring(0, 97) + "..."
: cleanMessage;
try {
// macOS notification
if (process.platform === "darwin") {
// Native macOS notification with sound
await $`osascript -e 'display notification "${shortMessage}" with title "Claude Code" sound name "Glass"'`.quiet();
// Play extra sound for permission requests
if (message.toLowerCase().includes('permission') || message.toLowerCase().includes('waiting')) {
await $`afplay /System/Library/Sounds/Ping.aiff`.quiet();
}
}
// Linux notification
else if (process.platform === "linux") {
await $`notify-send "Claude Code" "${shortMessage}" -i dialog-information -u normal`.quiet();
}
} catch (error) {
// Log error but don't fail
const errorLog = `${process.env.HOME}/.claude/notification-errors.log`;
await Bun.write(errorLog, `${new Date().toISOString()}: ${error}n`, { append: true });
}
}
async function main() {
try {
// Read JSON from stdin
const input = await Bun.stdin.json() as HookInput;
const message = input.message || "Claude Code notification";
// Log and send notification
await sendNotification(message);
// Return JSON to suppress output in transcript
console.log(JSON.stringify({ suppressOutput: true }));
} catch (error) {
// Even on error, exit cleanly
process.exit(0);
}
}
await main();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
The Magic: No Dependencies Required
What makes these scripts truly self-contained? Let’s compare approaches:
Traditional Node.js Approach
// package.json
{
"dependencies": {
"typescript": "^5.0.0",
"ts-node": "^10.0.0",
"@types/node": "^20.0.0"
}
}
1
2
3
4
5
6
7
8
npm install
npx ts-node script.ts
1
2
UV Python Approach
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "requests",
# "click",
# ]
# ///
1
2
3
4
5
6
7
Bun Approach
#!/usr/bin/env bun
// That's it. No dependencies declaration needed.
1
2
Making Scripts Truly Portable
While the scripts above work great if Bun is installed, we can go one step further and create true binaries:
# Make the command logger a standalone executable
bun build ~/.claude/hooks/command-logger.ts --compile --outfile command-logger
# Now it runs without Bun installed
./command-logger
1
2
3
4
5
The compiled binary includes:
- The Bun runtime
- Your TypeScript code (transpiled)
- All dependencies
- Native modules like SQLite
Comparing Self-Contained Approaches
Let’s look at a practical example across different tools:
UV Python Script
#!/usr/bin/env -S uv run
# /// script
# dependencies = [
# "httpx",
# "rich",
# "typer",
# ]
# ///
import httpx
from rich.console import Console
from rich.table import Table
import typer
console = Console()
def fetch_repos(username: str):
response = httpx.get(f"https://api.github.com/users/{username}/repos")
return response.json()
def main(username: str = "torvalds"):
repos = fetch_repos(username)
table = Table(title=f"Repos for {username}")
table.add_column("Name", style="cyan")
table.add_column("Stars", style="magenta")
table.add_column("Language", style="green")
for repo in sorted(repos, key=lambda x: x['stargazers_count'], reverse=True)[:10]:
table.add_row(
repo['name'],
str(repo['stargazers_count']),
repo['language'] or "N/A"
)
console.print(table)
if __name__ == "__main__":
typer.run(main)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
Bun TypeScript Equivalent
#!/usr/bin/env bun
const username = process.argv[2] || "torvalds";
const response = await fetch(`https://api.github.com/users/${username}/repos`);
const repos = await response.json();
console.log(`n📦 Top repos for ${username}n`);
const sorted = repos
.sort((a, b) => b.stargazers_count - a.stargazers_count)
.slice(0, 10);
for (const repo of sorted) {
console.log(`${repo.name.padEnd(30)} ⭐ ${repo.stargazers_count.toString().padStart(6)} | ${repo.language || 'N/A'}`);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
When to Use Each Approach
Different self-contained script approaches serve different needs:
Use UV Python when:
- You need the Python ecosystem (NumPy, Pandas, etc.)
- Scripts will be run by Python developers
- You’re prototyping data analysis scripts
Use Bun TypeScript when:
- You want type safety
- You need maximum performance
- You’re building CLI tools or web services
- You want to compile to standalone binaries
Use traditional package managers when:
- Building large applications with many dependencies
- Working in a team with established workflows
- You need very specific version control
Advanced Pattern: External Dependencies in Bun
While Bun includes many APIs built-in, sometimes you need external packages. Here’s how to keep scripts self-contained while using dependencies:
#!/usr/bin/env bun
// Self-contained script with external dependency
// First run: bun will auto-install dependencies
// Subsequent runs: uses cached dependencies
import chalk from "chalk";
import { Command } from "commander";
const program = new Command();
program
.name("color-log")
.description("Colorful logging utility")
.version("1.0.0");
program
.command("log " )
.option("-c, --color " , "text color", "green")
.action((message, options) => {
const colorFn = chalk[options.color] || chalk.green;
console.log(colorFn(message));
});
program.parse();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Performance Comparison: Real Numbers
I benchmarked the Claude Code hooks on my M1 Mac:
Metric | Node.js + ts-node | Python + UV | Bun (interpreted) | Bun (compiled) |
---|---|---|---|---|
Startup time | ~200ms | ~250ms | ~10ms | ~3ms |
Memory usage | 35MB | 28MB | 25MB | 22MB |
Binary size | N/A | N/A | N/A | 92MB |
Here’s a template for building self-contained TypeScript tools:
#!/usr/bin/env bun
// 1. Type definitions
interface Config {
verbose: boolean;
outputFile?: string;
}
// 2. Built-in utilities
import { $ } from "bun";
import { parseArgs } from "util";
// 3. Parse arguments
const { values, positionals } = parseArgs({
args: Bun.argv,
options: {
verbose: { type: 'boolean', short: 'v' },
output: { type: 'string', short: 'o' },
help: { type: 'boolean', short: 'h' },
},
strict: true,
allowPositionals: true,
});
// 4. Help text
if (values.help) {
console.log(`
Usage: my-tool [options]
Options:
-v, --verbose Enable verbose output
-o, --output Output file (default: stdout)
-h, --help Show this help message
`);
process.exit(0);
}
// 5. Main logic
async function main() {
const input = positionals[0];
if (!input) {
console.error("Error: Input file required");
process.exit(1);
}
try {
// Your tool logic here
const content = await Bun.file(input).text();
const processed = content.toUpperCase(); // Example processing
if (values.output) {
await Bun.write(values.output, processed);
console.log(`✅ Written to ${values.output}`);
} else {
console.log(processed);
}
} catch (error) {
console.error(`Error: ${error.message}`);
process.exit(1);
}
}
await main();
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
The Future of Self-Contained Scripts
We’re seeing a clear trend across languages:
- Python: PEP 723 and UV
- JavaScript/TypeScript: Bun
- Go: Already compiles to binaries
- Rust: Cargo scripts in development
The days of “install these 15 dependencies first” are ending. Modern developers expect scripts that just work.
Conclusion
Self-contained scripts represent a fundamental shift in how we think about code distribution. Whether you’re using UV for Python or Bun for TypeScript, the goal is the same: eliminate the setup friction between writing code and running it.
Bun takes this concept to its logical conclusion by not just embedding dependencies but compiling everything into a single binary. My Claude Code hooks demonstrate this perfectly—complex TypeScript applications that run instantly with zero setup.
The next time you’re writing a utility script, consider making it self-contained. Your future self (and your users) will thank you.
Source link