Contributing
The principles a change must satisfy
Section titled “The principles a change must satisfy”These are not style preferences. A change that breaks one of them will be asked to change, because the whole architecture rests on them.
- Extreme object orientation. The design is objects sending messages, not functions operating on data.
- Everything is an instance. Plain objects must not exist. Anything entering from a boundary is converted to an instance at the shortest architecturally correct distance from where it is formed. A message is passed and received as an instance — a plain object cannot be handed to a function, because it is not treated as a first-class object.
- Where a class can serve as a type, use
classoverinterface. - Namespaces and nesting are used deliberately, and the folder structure reveals them. A class name must let you infer its file path.
- Verify against the real thing. Script mocks hide what real CLIs and real operating systems do. Three defects were only ever found by running the actual CLI, and one only by running Windows in CI.
What principle 2 looks like in practice
Section titled “What principle 2 looks like in practice”Everything arriving at a boundary becomes an instance at the shortest distance.
| Boundary | What arrives | The instance it becomes |
|---|---|---|
| A line of CLI stdout | A JSON string | An Event subclass |
| The result of assembling argv | string[] |
Argv |
| A permission request | control_request JSON |
PermissionRequest |
| A file path | string |
An AbstractPath subclass (one that knows about WSL conversion) |
Paths matter most here. A string travelling around forces every reader to re-derive “is this a Windows path or a WSL one?”, whereas a WindowsPath / WslPath instance holds the answer in its type.
Where a class belongs
Section titled “Where a class belongs”Principle 4 decides this, and it is not negotiable: the name gives the path.
ActiveCli.Adapter.Claude.ClaudeAdapter → src/ActiveCli/Adapter/Claude/ClaudeAdapter.tsOne class per file. Before adding a class, ask which namespace’s problem it solves:
| If it… | It belongs in |
|---|---|
| Is spelled differently by each CLI | Adapter/<Cli>/ |
| Solves an operating-system problem the same way for every CLI | Process/ |
| Answers a question by being itself, and imports nothing | Path/, or alongside PermissionMode / SessionId |
| Is something a CLI told us | Event/ |
| Is something we tell a CLI | Message/ |
An adapter must not reach into Process/ concerns like locating an executable, augmenting PATH, or killing trees. Those are universal, and adapters own only what their CLI spells differently.
There are no barrel files inside the tree. Every internal import names the file it wants, and src/index.ts is the only public surface — a new public class is added there explicitly.
Running the tests
Section titled “Running the tests”npm test # vitest run — 132 tests, 12 filesnpm run typechecknpm run build # tsc, then copy-assetsWriting a test the architecture already supports
Section titled “Writing a test the architecture already supports”| Technique | Where it is used | Why it is possible |
|---|---|---|
| Real processes | Session.test.ts runs node -e <script> as a scripted CLI |
The adapter contract is small enough to implement in a test |
| Pinned platform | Launcher, tree and adapter tests | Platform is a constructor parameter, never read directly |
| Pure logic extracted | WindowsLauncher.pick(), JobSpawner.buildCommandLine() |
Made public so the rule is assertable without a Windows host |
| Substituted collaborators | StubIdentity in registry tests |
Constructor-default injection throughout |
| Temp directories | CliRegistry tests |
The registry directory is the caller’s to choose |
| Prose fixtures | McpOutputParser.test.ts asserts against real claude mcp list output |
Parsing printed output is a contract worth pinning to samples |
If a new class needs process.platform, take it as a constructor parameter with a default. That is what keeps a Windows rule assertable from a Mac.
Three-OS CI
Section titled “Three-OS CI”The test workflow runs on ubuntu, macOS and windows-latest, and the Windows leg is not a formality.
On its first run it caught a defect that would never have surfaced on a Mac: an argument passing through cmd.exe as a&b|c<d>e was truncated to a, with cmd reporting 'b' is not recognized as an internal or external command. The common belief that Node’s CommandLineToArgvW quoting makes & literal is false — the original comment in the code said exactly that.
Since mcp add-json passes JSON dense with those very characters, Windows users’ MCP configuration was about to be silently truncated on save.
The fix is to caret-escape & | < > and nothing else. A first attempt that also escaped parentheses broke ordinary arguments like write("hi") — carets are only meaningful in unquoted context, and parentheses only in block syntax.
The lesson generalises: if a change touches process spawning, quoting, paths, or process trees, it needs the Windows leg to be green before it means anything.
Language
Section titled “Language”Documentation and discussion are in Korean; code, comments, identifiers and commit messages are in English as the shared language.
Before opening a pull request
Section titled “Before opening a pull request”-
npm testpasses -
npm run typecheckpasses -
npm run buildpasses, includingcopy-assets - CI is green on all three operating systems
- Every new class’s path matches its name
- No plain object crosses a boundary that should have produced an instance
- Any new public class is exported from
src/index.ts - Any behaviour that can only be confirmed against the real CLI was confirmed there