diff --git a/docs/architecture/overview.md b/docs/architecture/overview.md
index 6a9e098..e3d823d 100644
--- a/docs/architecture/overview.md
+++ b/docs/architecture/overview.md
@@ -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
@@ -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
@@ -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
@@ -114,21 +132,21 @@ Enabled does not mean validly configured.
```mermaid
graph TD
Plugin[OpenCode plugin
src/opencode/plugin.ts + tool.ts + context.ts] --> Core[Core runtime
src/core/run.ts]
- CLI[CLI entrypoint
src/cli/entrypoint.ts + run.ts + parse.ts + help.ts] --> Core
+ CLI[CLI entrypoint
src/cli/entrypoint.ts + run.ts + parse.ts + help.ts + usage-error.ts] --> Core
- Core --> Config[Repo config
src/config/*.ts]
- Core --> Templates[Templates modules
src/templates/*.ts]
- Core --> Auth[Caller bot + auth config
src/config/auth-config.ts + src/github/installation-auth.ts]
+ Core --> Config[Repo config
src/config/index.ts + repo/*.ts]
+ Core --> Templates[Templates modules
src/templates.ts + src/templates/*.ts]
+ Core --> Auth[Caller bot + auth config
src/config/auth-config.ts + src/github/app-installation-auth.ts]
Core --> GitHub[GitHub client factory
src/github/client-factory.ts]
Core --> Registry[Generic command registry
src/commands/registry/index.ts]
Core --> Extensions[Installed extensions
optional + namespaced]
Registry --> Commands[Registered commands
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]
@@ -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:
@@ -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 `/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///`
diff --git a/src/cli/parse.ts b/src/cli/parse.ts
index 5f9b824..d139a92 100644
--- a/src/cli/parse.ts
+++ b/src/cli/parse.ts
@@ -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';
diff --git a/src/cli/run.ts b/src/cli/run.ts
index dc43c1f..34d55f2 100644
--- a/src/cli/run.ts
+++ b/src/cli/run.ts
@@ -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';
diff --git a/src/cli/usage-error.ts b/src/cli/usage-error.ts
new file mode 100644
index 0000000..8baa390
--- /dev/null
+++ b/src/cli/usage-error.ts
@@ -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');
+}
diff --git a/src/commands/issue/create/handler.ts b/src/commands/issue/create/handler.ts
index 7bc6e52..de130f3 100644
--- a/src/commands/issue/create/handler.ts
+++ b/src/commands/issue/create/handler.ts
@@ -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,
@@ -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 {
@@ -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;
diff --git a/src/commands/issue/update/handler.ts b/src/commands/issue/update/handler.ts
index 6f2e1ff..eaf1a40 100644
--- a/src/commands/issue/update/handler.ts
+++ b/src/commands/issue/update/handler.ts
@@ -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 {
@@ -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;
diff --git a/src/commands/pr/get-or-create/handler.ts b/src/commands/pr/get-or-create/handler.ts
index e39bc02..815b84b 100644
--- a/src/commands/pr/get-or-create/handler.ts
+++ b/src/commands/pr/get-or-create/handler.ts
@@ -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 {
@@ -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();
diff --git a/src/commands/pr/update/handler.ts b/src/commands/pr/update/handler.ts
index 32dedb1..9792664 100644
--- a/src/commands/pr/update/handler.ts
+++ b/src/commands/pr/update/handler.ts
@@ -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 {
@@ -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;
}
diff --git a/src/commands/shared/body-input.ts b/src/commands/shared/body-input.ts
deleted file mode 100644
index 8022590..0000000
--- a/src/commands/shared/body-input.ts
+++ /dev/null
@@ -1,31 +0,0 @@
-import { prepareArtifactBody } from '../../templates.js';
-import type { CommandContext } from '../../core/context.js';
-import type { CommandInput } from '../../core/types.js';
-
-export async function prepareIssueBodyFromInput(
- context: Pick,
-): Promise {
- const input = context.input as CommandInput;
- const preparedBody = await prepareArtifactBody({
- artifactType: 'issue',
- ...(typeof input.body === 'string' ? { body: input.body } : {}),
- ...(typeof input.template === 'string' ? { template: input.template } : {}),
- repoConfig: context.repoConfig,
- });
-
- return preparedBody?.body ?? (typeof input.body === 'string' ? input.body : undefined);
-}
-
-export async function preparePullRequestBodyFromInput(
- context: Pick,
-): Promise {
- const input = context.input as CommandInput;
- const preparedBody = await prepareArtifactBody({
- artifactType: 'pr',
- ...(typeof input.body === 'string' ? { body: input.body } : {}),
- ...(typeof input.template === 'string' ? { template: input.template } : {}),
- repoConfig: context.repoConfig,
- });
-
- return preparedBody?.body ?? (typeof input.body === 'string' ? input.body : undefined);
-}
diff --git a/src/config/auth-config.ts b/src/config/auth-config.ts
index 9f6c94e..7327112 100644
--- a/src/config/auth-config.ts
+++ b/src/config/auth-config.ts
@@ -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 {
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.`);
diff --git a/src/config/config-paths.ts b/src/config/config-paths.ts
new file mode 100644
index 0000000..6d8815d
--- /dev/null
+++ b/src/config/config-paths.ts
@@ -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 {
+ 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);
+}
diff --git a/src/config/index.ts b/src/config/index.ts
new file mode 100644
index 0000000..bf0128a
--- /dev/null
+++ b/src/config/index.ts
@@ -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';
diff --git a/src/config/json-file.ts b/src/config/json-file.ts
new file mode 100644
index 0000000..ce1f772
--- /dev/null
+++ b/src/config/json-file.ts
@@ -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 {
+ 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;
+}
diff --git a/src/config/project-defaults.ts b/src/config/project-defaults.ts
index 6e496cf..985c991 100644
--- a/src/config/project-defaults.ts
+++ b/src/config/project-defaults.ts
@@ -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;
diff --git a/src/config/repo-config.ts b/src/config/repo/config.ts
similarity index 90%
rename from src/config/repo-config.ts
rename to src/config/repo/config.ts
index 3a64e6a..29805e5 100644
--- a/src/config/repo-config.ts
+++ b/src/config/repo/config.ts
@@ -1,6 +1,6 @@
import path from 'node:path';
-import { OrfeError } from '../runtime/errors.js';
+import { OrfeError } from '../../runtime/errors.js';
import {
expectLiteralNumber,
@@ -8,16 +8,15 @@ import {
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 {
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.`);
diff --git a/src/config/repository-ref.ts b/src/config/repo/ref.ts
similarity index 87%
rename from src/config/repository-ref.ts
rename to src/config/repo/ref.ts
index 48292f7..ff762ea 100644
--- a/src/config/repository-ref.ts
+++ b/src/config/repo/ref.ts
@@ -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;
diff --git a/src/config/schema.ts b/src/config/schema.ts
new file mode 100644
index 0000000..5a0730a
--- /dev/null
+++ b/src/config/schema.ts
@@ -0,0 +1,37 @@
+import { OrfeError } from '../runtime/errors.js';
+
+export function expectLiteralNumber(value: unknown, expected: 1, label: string): 1 {
+ if (value !== expected) {
+ throw new OrfeError('config_invalid', `${label} must be ${expected}.`);
+ }
+
+ return expected;
+}
+
+export function expectString(value: unknown, label: string): string {
+ if (typeof value !== 'string' || value.trim().length === 0) {
+ throw new OrfeError('config_invalid', `${label} must be a non-empty string.`);
+ }
+
+ return value;
+}
+
+export function expectNumber(value: unknown, label: string): number {
+ if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
+ throw new OrfeError('config_invalid', `${label} must be a non-negative integer.`);
+ }
+
+ return value;
+}
+
+export function expectObject(value: unknown, label: string): Record {
+ if (!isObject(value)) {
+ throw new OrfeError('config_invalid', `${label} must be an object.`);
+ }
+
+ return value;
+}
+
+export function isObject(value: unknown): value is Record {
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
+}
diff --git a/src/config/shared.ts b/src/config/shared.ts
deleted file mode 100644
index afe07d5..0000000
--- a/src/config/shared.ts
+++ /dev/null
@@ -1,139 +0,0 @@
-import { readFile } from 'node:fs/promises';
-import path from 'node:path';
-
-import { OrfeError } from '../runtime/errors.js';
-import { expandUserPath, findUp, resolveFromCwd } from '../path/path.js';
-
-export const DEFAULT_REPO_CONFIG_PATH = '.orfe/config.json';
-export const DEFAULT_AUTH_CONFIG_PATH = '~/.config/orfe/auth.json';
-
-export interface RepoLocalConfig {
- configPath: string;
- version: 1;
- repository: {
- owner: string;
- name: string;
- defaultBranch: string;
- };
- callerToBot: Record;
- projects?: {
- default?: {
- owner?: string;
- projectNumber?: number;
- statusFieldName?: string;
- };
- };
-}
-
-export interface GitHubAppBotAuthConfig {
- provider: 'github-app';
- appId: number;
- appSlug: string;
- privateKeyPath: string;
-}
-
-export interface MachineAuthConfig {
- configPath: string;
- version: 1;
- bots: Record;
-}
-
-export interface LoadRepoConfigOptions {
- cwd?: string;
- configPath?: string;
-}
-
-export interface LoadAuthConfigOptions {
- cwd?: string;
- authConfigPath?: string;
- homeDirectory?: string;
-}
-
-export interface ProjectCommandOptions {
- add_to_project?: unknown;
- project_owner?: unknown;
- project_number?: unknown;
- status_field_name?: unknown;
- status?: unknown;
-}
-
-export interface ResolvedProjectConfig {
- projectOwner: string;
- projectNumber: number;
- statusFieldName: string;
-}
-
-export async function resolveRepoConfigPath(cwd: string, configPath?: string): Promise {
- 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);
-}
-
-export async function readJsonFile(filePath: string, label: string): Promise {
- 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.`);
- }
-}
-
-export function expectLiteralNumber(value: unknown, expected: 1, label: string): 1 {
- if (value !== expected) {
- throw new OrfeError('config_invalid', `${label} must be ${expected}.`);
- }
-
- return expected;
-}
-
-export function expectString(value: unknown, label: string): string {
- if (typeof value !== 'string' || value.trim().length === 0) {
- throw new OrfeError('config_invalid', `${label} must be a non-empty string.`);
- }
-
- return value;
-}
-
-export function expectNumber(value: unknown, label: string): number {
- if (typeof value !== 'number' || !Number.isInteger(value) || value < 0) {
- throw new OrfeError('config_invalid', `${label} must be a non-negative integer.`);
- }
-
- return value;
-}
-
-export function expectObject(value: unknown, label: string): Record {
- if (!isObject(value)) {
- throw new OrfeError('config_invalid', `${label} must be an object.`);
- }
-
- return value;
-}
-
-export function isObject(value: unknown): value is Record {
- return typeof value === 'object' && value !== null && !Array.isArray(value);
-}
-
-function isNodeError(error: unknown): error is NodeJS.ErrnoException {
- return error instanceof Error && 'code' in error;
-}
diff --git a/src/config/types.ts b/src/config/types.ts
new file mode 100644
index 0000000..38a6321
--- /dev/null
+++ b/src/config/types.ts
@@ -0,0 +1,55 @@
+export interface RepoLocalConfig {
+ configPath: string;
+ version: 1;
+ repository: {
+ owner: string;
+ name: string;
+ defaultBranch: string;
+ };
+ callerToBot: Record;
+ projects?: {
+ default?: {
+ owner?: string;
+ projectNumber?: number;
+ statusFieldName?: string;
+ };
+ };
+}
+
+export interface GitHubAppBotAuthConfig {
+ provider: 'github-app';
+ appId: number;
+ appSlug: string;
+ privateKeyPath: string;
+}
+
+export interface MachineAuthConfig {
+ configPath: string;
+ version: 1;
+ bots: Record;
+}
+
+export interface LoadRepoConfigOptions {
+ cwd?: string;
+ configPath?: string;
+}
+
+export interface LoadAuthConfigOptions {
+ cwd?: string;
+ authConfigPath?: string;
+ homeDirectory?: string;
+}
+
+export interface ProjectCommandOptions {
+ add_to_project?: unknown;
+ project_owner?: unknown;
+ project_number?: unknown;
+ status_field_name?: unknown;
+ status?: unknown;
+}
+
+export interface ResolvedProjectConfig {
+ projectOwner: string;
+ projectNumber: number;
+ statusFieldName: string;
+}
diff --git a/src/core/context.ts b/src/core/context.ts
index 95d2d43..7ffeada 100644
--- a/src/core/context.ts
+++ b/src/core/context.ts
@@ -1,5 +1,5 @@
-import type { GitHubAppBotAuthConfig, MachineAuthConfig, RepoLocalConfig } from '../config/shared.js';
-import type { RepoRef } from '../config/repository-ref.js';
+import type { GitHubAppBotAuthConfig, MachineAuthConfig, RepoLocalConfig } from '../config/types.js';
+import type { RepoRef } from '../config/repo/ref.js';
import type { GitHubClientAuthInfo, GitHubClients } from '../github/types.js';
import type { Logger } from '../logging/logger.js';
diff --git a/src/core/run.ts b/src/core/run.ts
index c6eb9f6..8c04b06 100644
--- a/src/core/run.ts
+++ b/src/core/run.ts
@@ -1,7 +1,7 @@
import { getCommandDefinition, validateCommandInput } from '../commands/registry/index.js';
import { getBotAuthConfig, loadAuthConfig } from '../config/auth-config.js';
-import { loadRepoConfig, resolveCallerBot } from '../config/repo-config.js';
-import { resolveRepository } from '../config/repository-ref.js';
+import { loadRepoConfig, resolveCallerBot } from '../config/repo/config.js';
+import { resolveRepository } from '../config/repo/ref.js';
import { GitHubClientFactory } from '../github/client-factory.js';
import type { GitHubClients } from '../github/types.js';
import { createLogger } from '../logging/logger.js';
diff --git a/src/core/types.ts b/src/core/types.ts
index db9e7f8..f5b8a2d 100644
--- a/src/core/types.ts
+++ b/src/core/types.ts
@@ -1,6 +1,6 @@
import type { Logger } from '../logging/logger.js';
import type { RuntimeEntrypoint } from '../version.js';
-import type { LoadAuthConfigOptions, LoadRepoConfigOptions, MachineAuthConfig, RepoLocalConfig } from '../config/shared.js';
+import type { LoadAuthConfigOptions, LoadRepoConfigOptions, MachineAuthConfig, RepoLocalConfig } from '../config/types.js';
import type { GitHubClientFactory } from '../github/client-factory.js';
import type { SuccessResponse } from '../runtime/response.js';
diff --git a/src/path/path.ts b/src/fs/path.ts
similarity index 100%
rename from src/path/path.ts
rename to src/fs/path.ts
diff --git a/src/github/installation-auth.ts b/src/github/app-installation-auth.ts
similarity index 100%
rename from src/github/installation-auth.ts
rename to src/github/app-installation-auth.ts
diff --git a/src/github/client-factory.ts b/src/github/client-factory.ts
index 9f9412c..19ad866 100644
--- a/src/github/client-factory.ts
+++ b/src/github/client-factory.ts
@@ -4,7 +4,7 @@ import { createOctokitLog } from '../logging/octokit-log.js';
import type { Logger } from '../logging/logger.js';
import { createGitHubAppJwt } from './jwt.js';
-import { createInstallationAuth } from './installation-auth.js';
+import { createInstallationAuth } from './app-installation-auth.js';
import {
GITHUB_API_VERSION,
type GitHubClientFactoryDependencies,
@@ -12,8 +12,8 @@ import {
type GitHubOctokitOptions,
} from './types.js';
-import type { GitHubAppBotAuthConfig } from '../config/shared.js';
-import type { RepoRef } from '../config/repository-ref.js';
+import type { GitHubAppBotAuthConfig } from '../config/types.js';
+import type { RepoRef } from '../config/repo/ref.js';
const USER_AGENT = 'orfe/0.1.2';
diff --git a/src/github/types.ts b/src/github/types.ts
index 862aabd..3b2ef6d 100644
--- a/src/github/types.ts
+++ b/src/github/types.ts
@@ -1,7 +1,7 @@
import type { Octokit } from 'octokit';
-import type { GitHubAppBotAuthConfig } from '../config/shared.js';
-import type { RepoRef } from '../config/repository-ref.js';
+import type { GitHubAppBotAuthConfig } from '../config/types.js';
+import type { RepoRef } from '../config/repo/ref.js';
import type { Logger } from '../logging/logger.js';
import type { OctokitLogAdapter } from '../logging/octokit-log.js';
diff --git a/src/runtime/errors.ts b/src/runtime/errors.ts
index 0825f97..cd10575 100644
--- a/src/runtime/errors.ts
+++ b/src/runtime/errors.ts
@@ -35,20 +35,6 @@ export class OrfeError extends Error {
}
}
-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 toOrfeError(error: unknown): OrfeError {
if (error instanceof OrfeError) {
return error;
@@ -60,7 +46,3 @@ export function toOrfeError(error: unknown): OrfeError {
return new OrfeError('internal_error', 'Unknown error.');
}
-
-export function formatCliUsageError(error: CliUsageError): string {
- return [`Error: ${error.message}`, `Usage: ${error.usage}`, `Example: ${error.example}`, `See: ${error.see}`].join('\n');
-}
diff --git a/src/templates.ts b/src/templates.ts
index 1359713..0103d48 100644
--- a/src/templates.ts
+++ b/src/templates.ts
@@ -20,5 +20,6 @@ export {
renderTemplateProvenance,
stripTemplateProvenance,
} from './templates/provenance.js';
+export { prepareIssueBodyFromInput, preparePullRequestBodyFromInput } from './templates/body-input.js';
export { prepareArtifactBody, validateArtifactBody } from './templates/prepare.js';
export { validateBodyAgainstTemplate, validateBodyAgainstTemplateDetailed } from './templates/body-validator.js';
diff --git a/src/templates/body-input.ts b/src/templates/body-input.ts
new file mode 100644
index 0000000..7f544c9
--- /dev/null
+++ b/src/templates/body-input.ts
@@ -0,0 +1,34 @@
+import type { RepoLocalConfig } from '../config/types.js';
+
+import { prepareArtifactBody } from './prepare.js';
+
+export interface ArtifactBodyInput {
+ body?: unknown;
+ template?: unknown;
+}
+
+export async function prepareIssueBodyFromInput(input: ArtifactBodyInput, repoConfig: RepoLocalConfig): Promise {
+ return prepareBodyFromInput('issue', input, repoConfig);
+}
+
+export async function preparePullRequestBodyFromInput(
+ input: ArtifactBodyInput,
+ repoConfig: RepoLocalConfig,
+): Promise {
+ return prepareBodyFromInput('pr', input, repoConfig);
+}
+
+async function prepareBodyFromInput(
+ artifactType: 'issue' | 'pr',
+ input: ArtifactBodyInput,
+ repoConfig: RepoLocalConfig,
+): Promise {
+ const preparedBody = await prepareArtifactBody({
+ artifactType,
+ ...(typeof input.body === 'string' ? { body: input.body } : {}),
+ ...(typeof input.template === 'string' ? { template: input.template } : {}),
+ repoConfig,
+ });
+
+ return preparedBody?.body ?? (typeof input.body === 'string' ? input.body : undefined);
+}
diff --git a/src/templates/loader.ts b/src/templates/loader.ts
index 5ef4b65..79f5363 100644
--- a/src/templates/loader.ts
+++ b/src/templates/loader.ts
@@ -2,7 +2,7 @@ import { readFile } from 'node:fs/promises';
import path from 'node:path';
import { OrfeError } from '../runtime/errors.js';
-import type { RepoLocalConfig } from '../config/shared.js';
+import type { RepoLocalConfig } from '../config/types.js';
import { formatTemplateRef } from './formatters.js';
import { resolveTemplatesRoot } from './root.js';
import { validateTemplateDefinition } from './schema.js';
diff --git a/src/templates/prepare.ts b/src/templates/prepare.ts
index 7bfb1ea..eb0ade3 100644
--- a/src/templates/prepare.ts
+++ b/src/templates/prepare.ts
@@ -1,5 +1,5 @@
import { OrfeError } from '../runtime/errors.js';
-import type { RepoLocalConfig } from '../config/shared.js';
+import type { RepoLocalConfig } from '../config/types.js';
import { validateBodyAgainstTemplateDetailed } from './body-validator.js';
import { throwFirstValidationIssue } from './errors.js';
import { loadTemplate } from './loader.js';
diff --git a/src/templates/root.ts b/src/templates/root.ts
index 274a229..cafc73a 100644
--- a/src/templates/root.ts
+++ b/src/templates/root.ts
@@ -1,7 +1,7 @@
import path from 'node:path';
import { fileURLToPath } from 'node:url';
-import { findUp } from '../path/path.js';
+import { findUp } from '../fs/path.js';
export async function resolveTemplatesRoot(configPath: string): Promise {
const configDirectory = path.dirname(configPath);
diff --git a/test/config.test.ts b/test/config.test.ts
index 4003d9b..fd6de18 100644
--- a/test/config.test.ts
+++ b/test/config.test.ts
@@ -4,11 +4,15 @@ import os from 'node:os';
import path from 'node:path';
import { test } from 'vitest';
+import {
+ getBotAuthConfig,
+ loadAuthConfig,
+ loadRepoConfig,
+ resolveCallerBot,
+ resolveProjectCommandConfig,
+ resolveRepository,
+} from '../src/config/index.js';
import { OrfeError } from '../src/runtime/errors.js';
-import { getBotAuthConfig, loadAuthConfig } from '../src/config/auth-config.js';
-import { resolveProjectCommandConfig } from '../src/config/project-defaults.js';
-import { resolveRepository } from '../src/config/repository-ref.js';
-import { loadRepoConfig, resolveCallerBot } from '../src/config/repo-config.js';
async function writeRepoConfig(repoDirectory: string, content: string): Promise {
await mkdir(path.join(repoDirectory, '.orfe'), { recursive: true });
diff --git a/test/logging.test.ts b/test/logging.test.ts
index 17d1e9e..dc5cd4c 100644
--- a/test/logging.test.ts
+++ b/test/logging.test.ts
@@ -2,10 +2,9 @@ import assert from 'node:assert/strict';
import { test } from 'vitest';
import { runCli } from '../src/cli/run.js';
+import type { GitHubAppBotAuthConfig, RepoRef } from '../src/config/index.js';
import { GitHubClientFactory, type GitHubOctokitOptions } from '../src/github/client-factory.js';
import { createLogger } from '../src/logging/logger.js';
-import type { GitHubAppBotAuthConfig } from '../src/config/shared.js';
-import type { RepoRef } from '../src/config/repository-ref.js';
import { executeOrfeTool } from '../src/opencode/tool.js';
class MemoryStream {
diff --git a/test/templates/runtime.test.ts b/test/templates/runtime.test.ts
index beb8665..89590bf 100644
--- a/test/templates/runtime.test.ts
+++ b/test/templates/runtime.test.ts
@@ -10,6 +10,7 @@ import {
extractTemplateProvenance,
loadTemplate,
prepareArtifactBody,
+ prepareIssueBodyFromInput,
renderTemplateProvenance,
validateArtifactBody,
validateBodyAgainstTemplate,
@@ -163,6 +164,25 @@ test('prepareArtifactBody validates a provenance-selected template when no expli
});
});
+test('prepareIssueBodyFromInput lives in the template layer and appends provenance', async () => {
+ const prepared = await prepareIssueBodyFromInput(
+ {
+ body: createValidIssueBody(),
+ template: 'formal-work-item@1.0.0',
+ },
+ createRepoConfig(),
+ );
+
+ assert.equal(
+ prepared,
+ `${createValidIssueBody()}\n\n${renderTemplateProvenance({
+ artifact_type: 'issue',
+ template_name: 'formal-work-item',
+ template_version: '1.0.0',
+ })}`,
+ );
+});
+
test('prepareArtifactBody rejects mismatched explicit and provenance templates', async () => {
await assert.rejects(
prepareArtifactBody({