- Migrated import paths from absolute package imports to relative imports throughout coding-agent modules. - Consolidated related imports in tools/index.ts to reduce import statement count. - Removed guideline about avoiding relative parent imports from AGENTS.md documentation. - Reformatted code examples in AGENTS.md for improved readability with multi-line formatting.
14 KiB
Development Rules
Default Context
This repo contains multiple packages, but packages/coding-agent/ is the primary focus. Unless otherwise specified, assume work refers to this package.
Terminology: When the user says "agent" or asks "why is agent doing X", they mean the coding-agent package implementation, not you (the assistant). The coding-agent is a CLI tool that uses Claude—questions about its behavior refer to the code in packages/coding-agent/, not your current session.
Package Structure
| Package | Description |
|---|---|
packages/ai |
Multi-provider LLM client with streaming support |
packages/agent |
Agent runtime with tool calling and state management |
packages/coding-agent |
Main CLI application (primary focus) |
packages/tui |
Terminal UI library with differential rendering |
packages/stats |
Local observability dashboard (omp stats) |
packages/pi-utils |
Shared utilities (logger, streams, temp files) |
Code Quality
- No
anytypes unless absolutely necessary - Check node_modules for external API type definitions instead of guessing
- NEVER use inline imports - no
await import("./foo.js"), noimport("pkg").Typein type positions, no dynamic imports for types. Always use standard top-level imports. - NEVER remove or downgrade code to fix type errors from outdated dependencies; upgrade the dependency instead
- Always ask before removing functionality or code that appears to be intentional
- NEVER build prompts in code — no inline strings, no template literals, no string concatenation. Prompts live in static
.mdfiles; use Handlebars for any dynamic content. - Import static text files via Bun — use
import content from "./prompt.md" with { type: "text" }instead ofreadFileSync
Bun Over Node
This project uses Bun. Use Bun APIs where they provide a cleaner alternative; use node:fs for operations Bun doesn't cover.
NEVER spawn shell commands for operations that have proper APIs (e.g., Bun.spawnSync(["mkdir", "-p", dir]) — use mkdirSync instead).
Process Execution
Prefer Bun Shell ($ template literals) for simple commands:
import { $ } from "bun";
// Capture output
const result = await $`git status`.cwd(dir).quiet().nothrow();
if (result.exitCode === 0) {
const text = result.text();
}
// Fire and forget
$`do-stuff ${tmpFile}`.quiet().nothrow();
Use Bun.spawn/Bun.spawnSync only when:
- Long-running processes (LSP servers, Python kernels)
- Streaming stdin/stdout/stderr required (SSE, JSON-RPC)
- Process control needed (signals, kill, complex lifecycle)
Bun Shell methods:
.quiet()- suppress output (stdout/stderr to null).nothrow()- don't throw on non-zero exit.text()- get stdout as string.cwd(path)- set working directory
Sleep
Prefer await Bun.sleep(ms)
Avoid new Promise((resolve) => setTimeout(resolve, ms))
Node Module Imports
NEVER use named imports from node:fs or node:path — always use namespace imports:
// BAD: Named imports
import { readdir, stat } from "node:fs/promises";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
// GOOD: Namespace imports
import * as fs from "node:fs/promises";
import * as path from "node:path";
import * as os from "node:os";
// Then use: fs.readdir(), path.join(), etc.
Choosing between node:fs and node:fs/promises:
- Async-only file →
import * as fs from "node:fs/promises" - Needs both sync and async →
import * as fs from "node:fs", usefs.promises.xxxfor async
// File with only async operations
import * as fs from "node:fs/promises";
await fs.readdir(dir);
await fs.stat(path);
// File mixing sync and async (e.g., sync in constructor, async in methods)
import * as fs from "node:fs";
fs.existsSync(path); // sync
await fs.promises.readdir(dir); // async
File I/O
Prefer Bun file APIs:
// Read
const text = await Bun.file(path).text();
const data = await Bun.file(path).json();
const exists = await Bun.file(path).exists();
// Write
await Bun.write(path, data);
Bun.write() is smart — it auto-creates parent directories and uses optimal syscalls:
// BAD: Redundant mkdir before write
await mkdir(dirname(path), { recursive: true });
await Bun.write(path, data);
// GOOD: Bun.write handles it
await Bun.write(path, data); // Creates parent dirs automatically
Use node:fs/promises for directories (Bun has no native directory APIs):
import * as fs from "node:fs/promises";
await fs.mkdir(path, { recursive: true });
await fs.rm(path, { recursive: true, force: true });
const entries = await fs.readdir(path);
Avoid sync APIs in async flows:
- Don't use
existsSync/readFileSync/writeFileSyncwhen async is possible - Use sync only when required by a synchronous interface
File I/O Anti-Patterns
NEVER check .exists() before reading — use try-catch with error code:
// BAD: Two syscalls, race condition
if (await Bun.file(path).exists()) {
return await Bun.file(path).json();
}
// BAD: Even with handle reuse, still two syscalls
const file = Bun.file(path);
if (await file.exists()) {
return await file.json();
}
// GOOD: One syscall, atomic, type-safe error handling
import { isEnoent } from "@anthropic/pi-utils";
try {
return await Bun.file(path).json();
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
NEVER use Bun.file().exists() for directories — it doesn't distinguish files from dirs:
// BAD: Bun.file is for files, not directories
if (await Bun.file(dirPath).exists()) { ... }
// GOOD: Use fs.stat for directories
import * as fs from "node:fs/promises";
try {
const s = await fs.stat(dirPath);
if (s.isDirectory()) { ... }
} catch (err) {
if (isEnoent(err)) { /* doesn't exist */ }
}
NEVER create multiple handles to the same path:
// BAD: Creates two file handles
if (await Bun.file(path).exists()) {
const content = await Bun.file(path).text();
}
// BAD: Still wasteful even in separate functions
async function checkConfig() {
return await Bun.file(configPath).exists();
}
async function loadConfig() {
return await Bun.file(configPath).json(); // second handle
}
NEVER use Buffer.from(await Bun.file(x).arrayBuffer()) — just use readFile:
// BAD: Unnecessary conversion
const buffer = Buffer.from(await Bun.file(path).arrayBuffer());
// GOOD: Direct buffer read
import * as fs from "node:fs/promises";
const buffer = await fs.readFile(path);
NEVER mix redundant existence checks with try-catch:
// BAD: Existence check is pointless when you have try-catch
if (await file.exists()) {
try {
return await file.json();
} catch {
return null;
}
}
// GOOD: Let try-catch handle missing files
try {
return await Bun.file(path).json();
} catch (err) {
if (isEnoent(err)) return null;
throw err;
}
Streams
Prefer centralized helpers:
import { readStream, readLines } from "./utils/stream";
// Read entire stream
const text = await readStream(child.stdout);
// Line-by-line iteration
for await (const line of readLines(stream)) {
// process line
}
Avoid manual reader loops unless protocol requires it (SSE, streaming JSON-RPC).
JSON5 Parsing
Use Bun.JSON5 — never add json5 as a dependency:
// BAD: External dependency
import JSON5 from "json5";
const data = JSON5.parse(text);
// GOOD: Bun builtin
const data = Bun.JSON5.parse(text);
const output = Bun.JSON5.stringify(obj);
JSONL Parsing
Use Bun.JSONL — never manually split and parse:
// BAD: Manual split + JSON.parse
const lines = text.split("\n").filter(Boolean);
const entries = lines.map((line) => JSON.parse(line));
// GOOD: Full blob parsing
const entries = Bun.JSONL.parse(text);
For streaming JSONL (SSE, JSON-RPC, subprocess output), use Bun.JSONL.parseChunk():
// BAD: Manual buffering and line splitting
let buffer = "";
for await (const chunk of stream) {
buffer += decoder.decode(chunk);
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) {
if (line.trim()) yield JSON.parse(line);
}
}
// GOOD: Bun handles buffering and parsing
let buffer: Uint8Array | undefined;
for await (const chunk of stream) {
const { values, remainder } = Bun.JSONL.parseChunk(chunk, buffer);
buffer = remainder;
for (const value of values) yield value;
}
Terminal Width and Wrapping
Use Bun.stringWidth() for display width calculations:
// BAD: External dependency or custom implementation
import { getWidth } from "get-east-asian-width";
function visibleWidth(str: string) {
/* custom logic */
}
// GOOD: Bun builtin (handles ANSI, emoji, CJK)
const width = Bun.stringWidth(text);
const widthNoAnsi = Bun.stringWidth(text, { countAnsiEscapeCodes: false });
Use Bun.wrapAnsi() for ANSI-aware text wrapping:
// BAD: Custom ANSI-aware wrapping
function wrapTextWithAnsi(text: string, width: number) {
/* complex SGR tracking */
}
// GOOD: Bun builtin
const wrapped = Bun.wrapAnsi(text, width, {
wordWrap: true,
hard: false,
trim: true,
});
Where Bun Wins
| Operation | Use | Not |
|---|---|---|
| File read/write | Bun.file(), Bun.write() |
readFileSync, writeFileSync |
| Spawn process | $\cmd`, Bun.spawn()` |
child_process |
| Sleep | Bun.sleep(ms) |
setTimeout promise |
| Binary lookup | Bun.which("git") |
spawnSync(["which", "git"]) |
| HTTP server | Bun.serve() |
http.createServer() |
| SQLite | bun:sqlite |
better-sqlite3 |
| Hashing | Bun.hash(), Web Crypto |
node:crypto |
| Path resolution | import.meta.dir, import.meta.path |
fileURLToPath dance |
| JSON5 parsing | Bun.JSON5.parse() |
json5 package |
| JSONL parsing | Bun.JSONL.parse(), .parseChunk() |
manual split + JSON.parse |
| String width | Bun.stringWidth() |
get-east-asian-width, custom |
| Text wrapping | Bun.wrapAnsi() |
custom ANSI-aware wrappers |
Patterns
Subprocess streams — cast when using pipe mode:
const child = Bun.spawn(["cmd"], { stdout: "pipe", stderr: "pipe" });
const reader = (child.stdout as ReadableStream<Uint8Array>).getReader();
Password hashing — built-in bcrypt/argon2:
const hash = await Bun.password.hash("password", "bcrypt");
const valid = await Bun.password.verify("password", hash);
Anti-Patterns
Bun.spawnSync([...])for simple commands → use$\...``new Promise((resolve) => setTimeout(resolve, ms))→ useBun.sleep(ms)existsSync/readFileSync/writeFileSyncin async code → useBun.file()APIs- Manual
child.stdout.getReader()loops for non-streaming commands → usereadStream()helper import JSON5 from "json5"→ useBun.JSON5.parse()text.split("\n").map(JSON.parse)for JSONL → useBun.JSONL.parse()- Custom
visibleWidth()/get-east-asian-width→ useBun.stringWidth() - Custom ANSI-aware text wrapping → use
Bun.wrapAnsi()
Logging
NEVER use console.log, console.error, or console.warn in the coding-agent package. Console output corrupts the TUI rendering.
Use the centralized logger instead:
import { logger } from "@oh-my-pi/pi-utils";
logger.error("MCP request failed", { url, method });
logger.warn("Theme file invalid, using fallback", { path });
logger.debug("LSP fallback triggered", { reason });
Logs go to ~/.omp/logs/omp.YYYY-MM-DD.log with automatic rotation.
Commands
- After code changes:
bun run check(runs biome + tsc, get full output, no tail) - For auto-fixable lint issues:
bun run fix(includes unsafe fixes) - NEVER run:
bun run dev,bun testunless user instructs - Only run specific tests if user instructs:
bun test test/specific.test.ts - NEVER commit unless user asks
- Do NOT use
tscornpx tsc- always usebun run check
GitHub Issues
When reading issues:
- Always read all comments on the issue
When creating issues:
- Use standard GitHub labels (bug, enhancement, documentation, etc.)
- If an issue affects a specific package, mention it in the issue title or description
When closing issues via commit:
- Include
fixes #<number>orcloses #<number>in the commit message - This automatically closes the issue when the commit is merged
Tools
- GitHub CLI for issues/PRs
- TUI interaction: use tmux
Style
- Keep answers short and concise
- No emojis in commits, issues, PR comments, or code
- No fluff or cheerful filler text
- Technical prose only, be kind but direct (e.g., "Thanks @user" not "Thanks so much @user!")
Changelog
Location: packages/*/CHANGELOG.md (each package has its own)
Format
Use these sections under ## [Unreleased]:
### Added- New features### Changed- Changes to existing functionality### Fixed- Bug fixes### Removed- Removed features### Breaking Changes- API changes requiring migration (appears first if present)
Rules
- New entries ALWAYS go under
## [Unreleased]section - NEVER modify already-released version sections (e.g.,
## [0.12.2]) - Each version section is immutable once released
Attribution
- Internal changes (from issues):
Fixed foo bar ([#123](https://github.com/can1357/oh-my-pi/issues/123)) - External contributions:
Added feature X ([#456](https://github.com/can1357/oh-my-pi/pull/456) by [@username](https://github.com/username))
Releasing
-
Update CHANGELOGs: Ensure all changes since last release are documented in the
[Unreleased]section of each affected package's CHANGELOG.md -
Run release script:
bun run release
The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new [Unreleased] sections.