Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 31 additions & 11 deletions docs/architecture/overview.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,24 @@ The architecture is evolving toward a layered model of **core plus installable e
Core remains the shared runtime substrate.
Extensions are separately installed deterministic opinion layers that repositories may enable declaratively.

The repository is organized as a **feature-oriented vertical slice architecture** flavored by **Unix philosophy**: small, responsibility-named modules composed through explicit boundaries.
The main command-facing feature verticals are `auth`, `issue`, `pr`, and `project` under `src/commands/`, while runtime, config, templates, auth, GitHub, CLI, and OpenCode layers support those verticals without taking over their feature ownership.

The current command architecture uses explicit vertical command slices under `src/commands/`. Each command owns its own metadata, validation, handler wiring, and co-located tests. A generic registry composes those slices into a single command catalog used by both the CLI and the core.

## Feature verticals, command slices, and cross-cutting concerns

`orfe` distinguishes between a **feature vertical** and a **command slice**:

- a feature vertical is a domain-owned group such as `auth`, `issue`, `pr`, or `project`
- a command slice is one executable command within that vertical, such as `issue create` or `project set-status`

Each vertical should remain understandable and refactorable on its own. Command slices inside that vertical own their contracts, handlers, and slice-local tests, while group-local shared helpers remain subordinate to the vertical rather than becoming alternate owners of behavior.

Cross-cutting concerns such as config loading, template handling, CLI formatting, filesystem helpers, auth token minting, and GitHub client construction exist to support slices through narrow interfaces. They should be named by responsibility and kept small enough that they do not turn into replacement dumping grounds.

When choosing between incidental deduplication and ownership clarity, prefer **slice autonomy**. Small duplication across slices is acceptable when it preserves encapsulation, keeps feature ownership obvious, and makes a slice easier to change or remove independently.

## Major runtime parts

### 1. OpenCode plugin entrypoint
Expand Down Expand Up @@ -58,7 +74,7 @@ The core is runtime-agnostic and must remain callable from both CLI and plugin e
It is also the future extension host, but it must preserve the same OpenCode tool/core and plain-data boundaries while doing so.

### 4. Config layer
Current examples include `src/config/repo-config.ts`, `src/config/auth-config.ts`, `src/config/project-defaults.ts`, `src/config/repository-ref.ts`, `src/config/shared.ts`, and `.orfe/config.json`.
Current examples include `src/config/index.ts`, `src/config/types.ts`, `src/config/schema.ts`, `src/config/json-file.ts`, `src/config/config-paths.ts`, `src/config/repo/config.ts`, `src/config/repo/ref.ts`, `src/config/auth-config.ts`, `src/config/project-defaults.ts`, and `.orfe/config.json`.

Responsibilities:
- hold repo-local non-secret configuration
Expand All @@ -70,18 +86,20 @@ Responsibilities:

Repo config is declarative and non-secret.
It may enable or configure extensions, but it must not ship executable extension code.
The config layer should be composed from narrow modules by responsibility, not a catch-all `shared.ts`.

### 5. Template layer
Current examples include `src/templates/index.ts`, `src/templates/prepare.ts`, `src/templates/loader.ts`, `src/commands/shared/body-input.ts`, and `.orfe/templates/`.
Current examples include `src/templates.ts`, `src/templates/body-input.ts`, `src/templates/prepare.ts`, `src/templates/loader.ts`, and `.orfe/templates/`.

Responsibilities:
- load versioned declarative issue and PR templates from the repository
- validate or minimally normalize issue and PR bodies deterministically
- append and read HTML comment provenance markers
- prepare command-shared issue/PR body input without placing template concerns under command ownership
- stay below repository workflow policy rather than interpreting ownership or orchestration semantics

### 6. Auth layer
Current examples include `src/github/installation-auth.ts`, `src/github/jwt.ts`, and machine-local auth config.
Current examples include `src/github/app-installation-auth.ts`, `src/github/jwt.ts`, and machine-local auth config.

Responsibilities:
- load machine-local per-bot GitHub App credentials
Expand Down Expand Up @@ -114,21 +132,21 @@ Enabled does not mean validly configured.
```mermaid
graph TD
Plugin[OpenCode plugin<br/>src/opencode/plugin.ts + tool.ts + context.ts] --> Core[Core runtime<br/>src/core/run.ts]
CLI[CLI entrypoint<br/>src/cli/entrypoint.ts + run.ts + parse.ts + help.ts] --> Core
CLI[CLI entrypoint<br/>src/cli/entrypoint.ts + run.ts + parse.ts + help.ts + usage-error.ts] --> Core

Core --> Config[Repo config<br/>src/config/*.ts]
Core --> Templates[Templates modules<br/>src/templates/*.ts]
Core --> Auth[Caller bot + auth config<br/>src/config/auth-config.ts + src/github/installation-auth.ts]
Core --> Config[Repo config<br/>src/config/index.ts + repo/*.ts]
Core --> Templates[Templates modules<br/>src/templates.ts + src/templates/*.ts]
Core --> Auth[Caller bot + auth config<br/>src/config/auth-config.ts + src/github/app-installation-auth.ts]
Core --> GitHub[GitHub client factory<br/>src/github/client-factory.ts]
Core --> Registry[Generic command registry<br/>src/commands/registry/index.ts]
Core --> Extensions[Installed extensions<br/>optional + namespaced]

Registry --> Commands[Registered commands<br/>src/commands/index.ts]

Commands --> AuthGroup[auth]
Commands --> IssueGroup[issue]
Commands --> PrGroup[pr]
Commands --> ProjectGroup[project]
Commands --> AuthGroup[auth vertical]
Commands --> IssueGroup[issue vertical]
Commands --> PrGroup[pr vertical]
Commands --> ProjectGroup[project vertical]

AuthGroup --> AuthToken[token]
AuthToken --> AuthTokenDef[definition.ts]
Expand Down Expand Up @@ -169,6 +187,7 @@ graph TD

Command behavior is organized as explicit vertical slices under `src/commands/`.
The registry is generic composition infrastructure; command semantics live with the slices themselves.
The vertical is the primary semantic owner; the individual command slice is the executable unit inside that vertical.

Canonical layout:

Expand Down Expand Up @@ -206,6 +225,7 @@ graph LR
Each `definition.ts` is the slice-owned contract. It defines the canonical command name, purpose, usage, examples, options, valid input example, success data example, optional validation hook, and the handler to execute. `src/commands/index.ts` explicitly registers those definitions in the `COMMANDS` array, and `src/commands/registry/index.ts` provides generic lookup, listing, grouping, and option validation over that array.

When multiple commands in one group reuse logic, place it under `<group>/shared/` using responsibility-named modules such as `github-response.ts`, `github-errors.ts`, `lookup.ts`, or `status-field.ts`. Avoid catch-all replacements like `shared.ts`, `types.ts`, or `utils.ts`.
If logic belongs to a cross-cutting layer such as templates, config, filesystem, CLI, or auth, keep it there instead of forcing it under command ownership just because multiple commands call it.

To add a new command:
- create a new directory at `src/commands/<group>/<command>/`
Expand Down
2 changes: 1 addition & 1 deletion src/cli/parse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,9 @@ import {
type CommandOptionDefinition,
} from '../commands/registry/index.js';
import { getOrfeVersion } from '../version.js';
import { CliUsageError } from '../runtime/errors.js';

import { renderGroupHelp, renderLeafHelp, renderRootHelp } from './help.js';
import { CliUsageError } from './usage-error.js';
import type { OrfeCommandGroup, ParsedInvocation } from './types.js';
import type { CommandInput } from '../core/types.js';

Expand Down
3 changes: 2 additions & 1 deletion src/cli/run.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CliUsageError, formatCliUsageError } from './usage-error.js';
import { runOrfeCore } from '../core/run.js';
import { createCliLogger } from '../logging/logger.js';
import { CliUsageError, OrfeError, formatCliUsageError } from '../runtime/errors.js';
import { OrfeError } from '../runtime/errors.js';
import { createErrorResponse } from '../runtime/response.js';

import { createLeafUsageError, parseInvocationForCli } from './parse.js';
Expand Down
17 changes: 17 additions & 0 deletions src/cli/usage-error.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
export class CliUsageError extends Error {
readonly usage: string;
readonly example: string;
readonly see: string;

constructor(message: string, details: { usage: string; example: string; see: string }) {
super(message);
this.name = 'CliUsageError';
this.usage = details.usage;
this.example = details.example;
this.see = details.see;
}
}

export function formatCliUsageError(error: CliUsageError): string {
return [`Error: ${error.message}`, `Usage: ${error.usage}`, `Example: ${error.example}`, `See: ${error.see}`].join('\n');
}
8 changes: 4 additions & 4 deletions src/commands/issue/create/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { resolveProjectCommandConfig } from '../../../config/project-defaults.js';
import { OrfeError } from '../../../runtime/errors.js';
import type { CommandContext } from '../../../core/context.js';
import type { CommandInput } from '../../../core/types.js';
import { resolveProjectCommandConfig } from '../../../config/project-defaults.js';
import { prepareIssueBodyFromInput } from '../../../templates/body-input.js';
import { OrfeError } from '../../../runtime/errors.js';
import {
addProjectItemByContentId,
type ProjectAddItemResult,
Expand All @@ -18,7 +19,6 @@ import {
import {
selectProjectStatusOption,
} from '../../project/shared/status-field.js';
import { prepareIssueBodyFromInput } from '../../shared/body-input.js';
import type { IssueCreateData, IssueCreateProjectAssignmentData } from './output.js';
import { getGitHubRequestStatus } from '../shared/github-errors.js';
import {
Expand Down Expand Up @@ -166,7 +166,7 @@ async function buildIssueCreateMutation(context: CommandContext<'issue create'>)
title: input.title as string,
};

const body = await prepareIssueBodyFromInput(context);
const body = await prepareIssueBodyFromInput(context.input, context.repoConfig);

if (typeof body === 'string') {
mutation.body = body;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/issue/update/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { OrfeError } from '../../../runtime/errors.js';
import type { CommandContext } from '../../../core/context.js';
import type { CommandInput } from '../../../core/types.js';
import { prepareIssueBodyFromInput } from '../../shared/body-input.js';
import { prepareIssueBodyFromInput } from '../../../templates/body-input.js';
import type { IssueUpdateData } from './output.js';
import { getGitHubRequestStatus } from '../shared/github-errors.js';
import {
Expand Down Expand Up @@ -52,7 +52,7 @@ async function buildIssueUpdateMutation(context: CommandContext<'issue update'>)
mutation.title = input.title;
}

const body = await prepareIssueBodyFromInput(context);
const body = await prepareIssueBodyFromInput(context.input, context.repoConfig);

if (typeof body === 'string') {
mutation.body = body;
Expand Down
4 changes: 2 additions & 2 deletions src/commands/pr/get-or-create/handler.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OrfeError } from '../../../runtime/errors.js';
import type { CommandContext } from '../../../core/context.js';
import { preparePullRequestBodyFromInput } from '../../shared/body-input.js';
import { preparePullRequestBodyFromInput } from '../../../templates/body-input.js';
import type { PullRequestGetOrCreateData } from './output.js';
import { getGitHubRequestStatus } from '../shared/github-errors.js';
import {
Expand Down Expand Up @@ -48,7 +48,7 @@ export async function handlePrGetOrCreate(context: CommandContext<'pr get-or-cre
return normalizePullRequestGetOrCreateData(existingPullRequest, false);
}

const body = await preparePullRequestBodyFromInput(context);
const body = await preparePullRequestBodyFromInput(context.input, context.repoConfig);

try {
const { rest } = await context.getGitHubClient();
Expand Down
4 changes: 2 additions & 2 deletions src/commands/pr/update/handler.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { OrfeError } from '../../../runtime/errors.js';
import type { CommandContext } from '../../../core/context.js';
import type { CommandInput } from '../../../core/types.js';
import { preparePullRequestBodyFromInput } from '../../shared/body-input.js';
import { preparePullRequestBodyFromInput } from '../../../templates/body-input.js';
import type { PullRequestUpdateData } from './output.js';
import { getGitHubRequestStatus } from '../shared/github-errors.js';
import {
Expand Down Expand Up @@ -43,7 +43,7 @@ async function buildPullRequestUpdateMutation(context: CommandContext<'pr update
mutation.title = input.title;
}

const body = await preparePullRequestBodyFromInput(context);
const body = await preparePullRequestBodyFromInput(context.input, context.repoConfig);
if (typeof body === 'string') {
mutation.body = body;
}
Expand Down
31 changes: 0 additions & 31 deletions src/commands/shared/body-input.ts

This file was deleted.

18 changes: 6 additions & 12 deletions src/config/auth-config.ts
Original file line number Diff line number Diff line change
@@ -1,25 +1,19 @@
import path from 'node:path';

import { OrfeError } from '../runtime/errors.js';
import { expandUserPath } from '../path/path.js';
import { expandUserPath } from '../fs/path.js';

import {
expectLiteralNumber,
expectNumber,
expectObject,
expectString,
isObject,
readJsonFile,
resolveAuthConfigPath,
type GitHubAppBotAuthConfig,
type LoadAuthConfigOptions,
type MachineAuthConfig,
} from './shared.js';
} from './config-paths.js';
import { readConfigJsonFile } from './json-file.js';
import { expectLiteralNumber, expectNumber, expectObject, expectString, isObject } from './schema.js';
import type { GitHubAppBotAuthConfig, LoadAuthConfigOptions, MachineAuthConfig } from './types.js';

export async function loadAuthConfig(options: LoadAuthConfigOptions = {}): Promise<MachineAuthConfig> {
const cwd = path.resolve(options.cwd ?? process.cwd());
const authConfigPath = resolveAuthConfigPath(cwd, options.authConfigPath, options.homeDirectory);
const parsed = await readJsonFile(authConfigPath, 'machine-local auth config');
const parsed = await readConfigJsonFile(authConfigPath, 'machine-local auth config');

if (!isObject(parsed)) {
throw new OrfeError('config_invalid', `Auth config at ${authConfigPath} must contain a JSON object.`);
Expand Down
21 changes: 21 additions & 0 deletions src/config/config-paths.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import path from 'node:path';

import { findUp, expandUserPath, resolveFromCwd } from '../fs/path.js';

export const DEFAULT_REPO_CONFIG_PATH = '.orfe/config.json';
export const DEFAULT_AUTH_CONFIG_PATH = '~/.config/orfe/auth.json';

export async function resolveRepoConfigPath(cwd: string, configPath?: string): Promise<string> {
if (configPath) {
return resolveFromCwd(cwd, configPath);
}

const foundPath = await findUp(cwd, DEFAULT_REPO_CONFIG_PATH);
return foundPath ?? path.resolve(cwd, DEFAULT_REPO_CONFIG_PATH);
}

export function resolveAuthConfigPath(cwd: string, authConfigPath?: string, homeDirectory?: string): string {
const targetPath = authConfigPath ?? DEFAULT_AUTH_CONFIG_PATH;
const expandedPath = expandUserPath(targetPath, homeDirectory);
return resolveFromCwd(cwd, expandedPath);
}
13 changes: 13 additions & 0 deletions src/config/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
export { getBotAuthConfig, loadAuthConfig } from './auth-config.js';
export { resolveProjectCommandConfig } from './project-defaults.js';
export { loadRepoConfig, resolveCallerBot } from './repo/config.js';
export { resolveRepository, type RepoRef } from './repo/ref.js';
export type {
GitHubAppBotAuthConfig,
LoadAuthConfigOptions,
LoadRepoConfigOptions,
MachineAuthConfig,
ProjectCommandOptions,
RepoLocalConfig,
ResolvedProjectConfig,
} from './types.js';
27 changes: 27 additions & 0 deletions src/config/json-file.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { readFile } from 'node:fs/promises';

import { OrfeError } from '../runtime/errors.js';

export async function readConfigJsonFile(filePath: string, label: string): Promise<unknown> {
let rawContents: string;

try {
rawContents = await readFile(filePath, 'utf8');
} catch (error) {
if (isNodeError(error) && error.code === 'ENOENT') {
throw new OrfeError('config_not_found', `${label} not found at ${filePath}.`);
}

throw new OrfeError('config_invalid', `Unable to read ${label} at ${filePath}.`);
}

try {
return JSON.parse(rawContents) as unknown;
} catch {
throw new OrfeError('config_invalid', `${label} at ${filePath} is not valid JSON.`);
}
}

function isNodeError(error: unknown): error is NodeJS.ErrnoException {
return error instanceof Error && 'code' in error;
}
2 changes: 1 addition & 1 deletion src/config/project-defaults.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OrfeError } from '../runtime/errors.js';

import type { ProjectCommandOptions, RepoLocalConfig, ResolvedProjectConfig } from './shared.js';
import type { ProjectCommandOptions, RepoLocalConfig, ResolvedProjectConfig } from './types.js';

type ProjectDefaults = RepoLocalConfig['projects'] extends infer T ? (T extends { default?: infer D } ? D : never) : never;

Expand Down
13 changes: 6 additions & 7 deletions src/config/repo-config.ts → src/config/repo/config.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,22 @@
import path from 'node:path';

import { OrfeError } from '../runtime/errors.js';
import { OrfeError } from '../../runtime/errors.js';

import {
expectLiteralNumber,
expectNumber,
expectObject,
expectString,
isObject,
readJsonFile,
resolveRepoConfigPath,
type LoadRepoConfigOptions,
type RepoLocalConfig,
} from './shared.js';
} from '../schema.js';
import { readConfigJsonFile } from '../json-file.js';
import { resolveRepoConfigPath } from '../config-paths.js';
import type { LoadRepoConfigOptions, RepoLocalConfig } from '../types.js';

export async function loadRepoConfig(options: LoadRepoConfigOptions = {}): Promise<RepoLocalConfig> {
const cwd = path.resolve(options.cwd ?? process.cwd());
const configPath = await resolveRepoConfigPath(cwd, options.configPath);
const parsed = await readJsonFile(configPath, 'repo-local config');
const parsed = await readConfigJsonFile(configPath, 'repo-local config');

if (!isObject(parsed)) {
throw new OrfeError('config_invalid', `Repo config at ${configPath} must contain a JSON object.`);
Expand Down
4 changes: 2 additions & 2 deletions src/config/repository-ref.ts → src/config/repo/ref.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { OrfeError } from '../runtime/errors.js';
import { OrfeError } from '../../runtime/errors.js';

import type { RepoLocalConfig } from './shared.js';
import type { RepoLocalConfig } from '../types.js';

export interface RepoRef {
owner: string;
Expand Down
Loading