Files
oh-my-pi/AGENTS.md
T
can1357 e22d123009 refactor(fs): migrated remaining sync file operations to async
- Converted readdirSync, readFileSync, and statSync to async readdir, readFile, stat across skills and agent discovery.
- Made scanDirectoryForSkills async and refactored custom directory scanning to use Promise.all for concurrent processing.
- Updated agent discovery to use fs/promises for async file reading and refactored helper patterns.
- Added AgentParsingError exception class for better error handling during agent parsing.
- Added filesystem error type guards (isEnoent, isEacces, isPerm, etc.) to pi-utils for safe error checking.
- Added color manipulation utilities to pi-utils for accessibility features.
- Added color-blind mode setting to settings manager.
- Migrated plugins, settings, and config modules from sync to async file operations.
- Updated error handling to use new pi-utils type guards for type-safe checking.
2026-01-24 03:18:03 +01:00

12 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 any types unless absolutely necessary
  • Check node_modules for external API type definitions instead of guessing
  • NEVER use inline imports - no await import("./foo.js"), no import("pkg").Type in 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 use relative parent imports (../xxx) — always use configured path aliases (e.g., @/utils, ~/components)
  • NEVER build prompts in code — no inline strings, no template literals, no string concatenation. Prompts live in static .md files; use Handlebars for any dynamic content.
  • Import static text files via Bun — use import content from "./prompt.md" with { type: "text" } instead of readFileSync

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";

// GOOD: Namespace imports
import * as fs from "node:fs/promises";
import * as path from "node:path";

// 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", use fs.promises.xxx for 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/writeFileSync when 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).

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

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)) → use Bun.sleep(ms)
  • existsSync/readFileSync/writeFileSync in async code → use Bun.file() APIs
  • Manual child.stdout.getReader() loops for non-streaming commands → use readStream() helper

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 test unless user instructs
  • Only run specific tests if user instructs: bun test test/specific.test.ts
  • NEVER commit unless user asks
  • Do NOT use tsc or npx tsc - always use bun 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> or closes #<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

  1. Update CHANGELOGs: Ensure all changes since last release are documented in the [Unreleased] section of each affected package's CHANGELOG.md

  2. Run release script:

    bun run release
    

The script handles: version bump, CHANGELOG finalization, commit, tag, publish, and adding new [Unreleased] sections.