Your project's portable codex.
Pendex turns an entire source tree into portable, plain-text archives —
ideal for AI prompts, code reviews, backups, and sharing. Compile a
project into banner-delimited .txt files grouped by job (source, web,
style, terminal, configuration, documentation, testing, misc), then
reconstruct the whole tree later with px split.
Once compiled, a project becomes something you can paste into an LLM context window, attach to an email, diff against an earlier snapshot, or archive independently of git — a single, portable, human-readable representation of a codebase that can be read as plain text and reconstructed on demand.
Built on Bun + TypeScript (strict, no any), using
@clack/prompts for
the interactive shell.
Pendex comes from two sources: the mathematical notation for a power set — 𝒫(A), the set of every subset of a set — and the Latin roots behind words like index and codex ("one who points out"; "book, collection"). Neither is literal — Pendex doesn't archive every possible subset of a project's files — but the name is meant to carry that same sense of totality: not a compressed export, but a complete, canonical textual representation of a project that can be explored, shared, and reconstructed.
| Mathematics | Pendex |
|---|---|
| Set | Project |
| Elements | Files |
| Power set | Complete representation of every file/group |
| Mapping | manifest.json |
| Reconstruction | px split |
That framing is also why the architecture (see below) is split the way
it is — ArchiveFormat (representation), FileScanner (discovery),
CompileService/SplitService (orchestration), CompileView/SplitView
(presentation) — each piece has exactly one well-defined responsibility,
the same way each piece of a formal system does.
Bun — this project only runs on Bun, not Node. File
I/O, globbing, TOML parsing, and shelling out all use Bun's built-ins
(Bun.file, Bun.write, Bun.Glob, Bun.$, Bun.TOML); there are
no node:* imports anywhere in src/ or packages/*/src. package.json's
devEngines enforces this at install time.
bun installbun run startPresents a menu: Compile Codebase, Split Archive, Exit Program.
compile and split don't require the interactive shell — each
resolves its own Config/Theme via @pendex/core's
resolveRunnerDeps() when run directly, without depending on the CLI
shell package:
bun run compile # bun run packages/compile/src/Compile.ts
bun run split # bun run packages/split/src/Split.tsbun run build:pendex # typechecks, then outputs ./dist/px
./dist/pxDefaults live in packages/core/config/config.toml:
-
theme— the active theme's name (a file stem underpackages/theme/themes/; see Themes below). -
outputDir/rebuiltDir— wherecompilewrites archives andsplitrebuilds into, respectively. -
exclude— glob patterns applied globally, on top of.gitignore. -
[[jobs]]— one block per output.txtfile:Job file Category 1_SOURCE_FILES.txtsource2_WEB_FILES.txtweb3_STYLE_FILES.txtstyle4_TERMINAL_FILES.txtterminal5_CONFIG_FILES.txtconfiguration6_DOC_FILES.txtdocumentation7_TEST_FILES.txttesting8_MISC_FILES.txtmiscEach job has
filename,description,includeglobs, and job-specificexcludeglobs. A job with an emptyincludelist is a remainder job — it catches every file no earlier job claimed (8_MISC_FILES) — and must stay last in the array.
At runtime, an optional runtime.config.json in your project root
overrides any of the above. There's no interactive settings editor;
edit runtime.config.json (or config.toml for the shipped defaults)
directly.
ConfigManager (packages/core/src/Config.ts) is a singleton: it
parses config.toml once per process, merges runtime.config.json on
top if present, and hands back the same Config object to every
command.
Themes are TOML files under packages/theme/themes/, named by their
file stem — pendex (the canonical theme every other theme's section
layout must mirror), dracula, tokyonight, onedark, monokaipro.
Set config.theme to the theme name you want. An unknown or missing
theme name degrades to the built-in brand palette rather than
crashing — the theme is purely cosmetic and must never take the app
down.
A theme's [brand] table can optionally name colors after the eight
job categories above (source, web, style, terminal,
configuration, documentation, testing, misc) to drive
per-category coloring in the compile/split views; a theme without a
matching key for a category just falls back to a semantic style
instead.
Bun workspaces, strictly acyclic package graph:
@pendex/color → @pendex/theme → @pendex/core → @pendex/compile + @pendex/split → pendex CLI (src/)
@pendex/home (website — independent)
| Package | What it owns |
|---|---|
@pendex/color |
ANSI text styling — picocolors-compatible API, plus 24-bit truecolor hex support |
@pendex/theme |
TOML-driven theme system (ThemeManager, ThemePalette, the useTheme chaining engine) |
@pendex/core |
Shared domain types, Config/ConfigManager, Constants, FileScanner, ArchiveFormat, the base View class, and bootstrap.resolveRunnerDeps() |
@pendex/compile |
The Compile command: CompileService (headless orchestration) + CompileView (the only @clack/prompts caller for compiling) |
@pendex/split |
The Split command: SplitService (headless orchestration) + SplitView (the only @clack/prompts caller for splitting) |
@pendex/home |
The whole website — marketing home page at / and the user guide + generated API reference at /docs — one Vite package, one build |
src/ (root) |
The interactive CLI shell — App.ts (menu orchestrator), commands/Exit.ts, utils/ |
Never introduce a cycle. If a package needs to import from
something "above" it in that chain, the code belongs somewhere else —
usually @pendex/core.
Every non-trivial command is split into three layers with one job each, spread across the packages above rather than folders in a single package:
| Layer | Lives in | Owns |
|---|---|---|
| Core | @pendex/core, *Service.ts in compile/split |
business logic: glob resolution, manifest building, archive read/write |
| View | *View.ts in @pendex/compile / @pendex/split |
terminal rendering: intro/progress/summary — the only @clack/prompts caller for its command, session open to close |
| Command | Compile.ts / Split.ts / src/commands/Exit.ts |
Command identity (key/label/hint), wiring a View to its deps |
classDiagram
direction TB
class Command {
<<interface — @pendex/core>>
+key: string
+label: string
+hint: string
+execute() Promise~void~
}
class Compile {
<<@pendex/compile — Command, container>>
+execute() Promise~void~
}
Compile ..|> Command
Compile --> CompileView
class Split {
<<@pendex/split — Command, container>>
+execute() Promise~void~
}
Split ..|> Command
Split --> SplitView
class Exit {
<<src/commands — no service/view split; see file comment>>
+execute() Promise~void~
}
Exit ..|> Command
class CompileView {
<<@pendex/compile — the only clack caller for compile>>
+render(theme, config) Promise~void~
}
CompileView --> CompileService
CompileView --> FileScanner
class SplitView {
<<@pendex/split — the only clack caller for split>>
+render(theme, config) Promise~void~
}
SplitView --> SplitService
class CompileService {
<<@pendex/compile — pure logic, no rendering>>
+runCompile(config) Promise~CompileSummary~
+compileJob(...) Promise~CompileJobResult~
+writeManifest(...) Promise~void~
}
CompileService --> FileScanner
CompileService --> ArchiveFormat
class SplitService {
<<@pendex/split — pure logic, no rendering>>
+runSplit(outputDir, rebuiltDir) Promise~SplitSummary~
+splitArchiveFile(...) Promise~SplitFileOutcome~
}
SplitService --> ArchiveFormat
class FileScanner {
<<@pendex/core — glob resolution, no I/O writes>>
+loadIgnorePatterns(path) Promise~string[]~
+resolveJobFiles(job, excludes, claimed) Promise~string[]~
+findEmptyDirectories(cwd, excludes) Promise~string[]~
}
class ArchiveFormat {
<<@pendex/core — single source of truth for the archive text format>>
+buildArchiveEntry(path, content) string
+joinArchiveEntries(entries) string
+parseArchive(rawText) ArchivedFile[]
}
class ConfigManager {
<<@pendex/core — Singleton>>
+static getInstance() Promise~ConfigManager~
+get() Config
+reload() Promise~Config~
+save() Promise~void~
}
class bootstrap {
<<@pendex/core — resolveRunnerDeps()>>
+resolveRunnerDeps() Promise~ResolvedDeps~
}
bootstrap --> ConfigManager
bootstrap --> ThemeManager
class Application {
<<src/components/App.ts — root, intentionally unsplit>>
+run() Promise~void~
}
Application "1" o-- "*" Command
Application --> bootstrap
class ThemeManager {
<<@pendex/theme — Singleton>>
+static getInstance(name) Promise~ThemeManager~
+get() Theme
+extended() PendexTheme
}
class ThemePalette {
<<@pendex/theme — theme data, TOML parsing/validation>>
}
class useTheme {
<<@pendex/theme — Proxy-based chaining, palette-agnostic>>
}
ThemeManager --> ThemePalette
ThemeManager --> useTheme
ArchiveFormat.tsexists becauseCompileandSplitused to each have half-knowledge of the same text format — banners were written in one place and parsed with separately-maintained logic in another. That's the exact failure mode this prevents: two places that must agree on one thing, with nothing enforcing it. Now there's one file, in@pendex/core, that owns the format in both directions — and both@pendex/compileand@pendex/splitdepend on it rather than on each other.CompileService/SplitServicenever import@clack/prompts. That's the actual test of the boundary — if a service needed the terminal to do its job, the split wouldn't be real. It also meansrunCompile()/runSplit()work headlessly (used by both packages' standaloneimport.meta.mainentry points) without dragging along interactive-only rendering.Exit.tsis deliberately not split into command/service/view. It's oneoutro()call and aprocess.exit()— three files for that would be separation for its own sake. See the comment at the top ofsrc/commands/Exit.ts.Application(src/components/App.ts) is deliberately not split either. It's the root — in the React analogy, the thing that mounts everything else — and its only real behavior (the menu select-loop) is inherently both state and render at once. Forcing that apart would add indirection without adding clarity.ThemePalette.ts/useTheme.tssplit the same way: palette (what colors mean) from the Proxy-based chaining engine (how composition works).useTheme.tsdoesn't know what a color is;ThemePalette.tsdoesn't know how chaining works.bootstrap.resolveRunnerDeps()lives in@pendex/core, not the shell package, specifically so@pendex/compileand@pendex/splitcan resolve their own{ config, theme, categoryColors }for standalone runs without depending on the shell — the same reasoning that putArchiveFormat.tshere rather than in either service.
The whole Pendex website is one Vite + React + Tailwind CSS v4 + DaisyUI package, deployed to GitHub Pages as a single build:
/— the marketing home page./docs— the user guide, plus a generated API reference (via TypeDoc) covering all five library packages (color,theme,core,compile,split).
bun run dev # dev server, serves both / and /docs
bun run build # builds the whole site into packages/home/distSee packages/home/README.md for
details on the build pipeline (TypeDoc → tsc -b → vite build).
bun install # install everything, once
bun run typecheck # tsc --noEmit at root + every package's own typecheck
bun run test # bun test at root (CLI shell) + every package's own tests
bun run lint # oxlint --fix . ; oxfmt .
bun run lint:check # oxlint . (no fixes — for CI)
bun run format:check # oxfmt --check . (no fixes — for CI)
bun run clean # removes build output, coverage, and generated API docsLinting and formatting use Oxlint and
Oxfmt (not ESLint/Prettier) — configured in
.oxlintrc.json and .oxfmtrc.json at the repo root.
- No
any. The whole tree type-checks understrictwith zero explicit-anyusage, outside two intentionally out-of-scope standalone utilities (see Header comments below). - Bun over Node, everywhere — including path manipulation
(
joinPath/dirName/baseName/extNameinConstants.tsare small local string helpers, notnode:path). - User-facing strings live as a
STRINGSfield on the class that uses them, not in a shared strings module. Every command and view has its ownprivate readonly STRINGS = { ... } as constat the top — the tradeoff is intentional: it favors "one obvious place to edit this copy" over textbook copy/logic separation.
For a deeper dive into project-specific gotchas, known-fixed bugs, and
guidance aimed specifically at coding agents working in this repo, see
AGENTS.md.
src/utils/HeaderComments.ts is a separate, standalone utility —
not part of the px command set, and out of scope for the layered
architecture above — that injects or strips a
// FILE-PATH: <path>comment at the top of project files. Run it directly:
bun run src/utils/HeaderComments.tspackages/
color/src/ ANSI styling — Colors.ts (picocolors-compatible + truecolor)
theme/
src/ ThemeManager.ts, ThemePalette.ts, useTheme.ts
themes/ pendex.toml (canonical) + dracula/tokyonight/onedark/monokaipro
core/
src/ types.ts, Config.ts, Constants.ts, ArchiveFormat.ts,
FileScanner.ts, View.ts, bootstrap.ts
config/config.toml default Config
compile/src/ Compile.ts, CompileService.ts, CompileView.ts
split/src/ Split.ts, SplitService.ts, SplitView.ts
home/ the website — see packages/home/README.md
src/
index.ts CLI process entry point
components/App.ts root orchestrator (intentionally unsplit)
commands/Exit.ts (intentionally unsplit — see file comment)
utils/ standalone, out of scope for the layered architecture
HeaderComments.ts
update-config.ts
validators.ts
tests/ root-level tests: CLI shell + cross-package integration
AGENTS.md guidance for coding agents working in this repo