Skip to content

Commit 9b5f662

Browse files
Finish ownership and module-structure cleanup after the vertical-slice refactor (#158)
2 parents 80a5596 + e02ccc2 commit 9b5f662

35 files changed

Lines changed: 306 additions & 252 deletions

docs/architecture/overview.md

Lines changed: 31 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,24 @@ The architecture is evolving toward a layered model of **core plus installable e
1212
Core remains the shared runtime substrate.
1313
Extensions are separately installed deterministic opinion layers that repositories may enable declaratively.
1414

15+
The repository is organized as a **feature-oriented vertical slice architecture** flavored by **Unix philosophy**: small, responsibility-named modules composed through explicit boundaries.
16+
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.
17+
1518
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.
1619

20+
## Feature verticals, command slices, and cross-cutting concerns
21+
22+
`orfe` distinguishes between a **feature vertical** and a **command slice**:
23+
24+
- a feature vertical is a domain-owned group such as `auth`, `issue`, `pr`, or `project`
25+
- a command slice is one executable command within that vertical, such as `issue create` or `project set-status`
26+
27+
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.
28+
29+
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.
30+
31+
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.
32+
1733
## Major runtime parts
1834

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

6076
### 4. Config layer
61-
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`.
77+
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`.
6278

6379
Responsibilities:
6480
- hold repo-local non-secret configuration
@@ -70,18 +86,20 @@ Responsibilities:
7086

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

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

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

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

86104
Responsibilities:
87105
- load machine-local per-bot GitHub App credentials
@@ -114,21 +132,21 @@ Enabled does not mean validly configured.
114132
```mermaid
115133
graph TD
116134
Plugin[OpenCode plugin<br/>src/opencode/plugin.ts + tool.ts + context.ts] --> Core[Core runtime<br/>src/core/run.ts]
117-
CLI[CLI entrypoint<br/>src/cli/entrypoint.ts + run.ts + parse.ts + help.ts] --> Core
135+
CLI[CLI entrypoint<br/>src/cli/entrypoint.ts + run.ts + parse.ts + help.ts + usage-error.ts] --> Core
118136
119-
Core --> Config[Repo config<br/>src/config/*.ts]
120-
Core --> Templates[Templates modules<br/>src/templates/*.ts]
121-
Core --> Auth[Caller bot + auth config<br/>src/config/auth-config.ts + src/github/installation-auth.ts]
137+
Core --> Config[Repo config<br/>src/config/index.ts + repo/*.ts]
138+
Core --> Templates[Templates modules<br/>src/templates.ts + src/templates/*.ts]
139+
Core --> Auth[Caller bot + auth config<br/>src/config/auth-config.ts + src/github/app-installation-auth.ts]
122140
Core --> GitHub[GitHub client factory<br/>src/github/client-factory.ts]
123141
Core --> Registry[Generic command registry<br/>src/commands/registry/index.ts]
124142
Core --> Extensions[Installed extensions<br/>optional + namespaced]
125143
126144
Registry --> Commands[Registered commands<br/>src/commands/index.ts]
127145
128-
Commands --> AuthGroup[auth]
129-
Commands --> IssueGroup[issue]
130-
Commands --> PrGroup[pr]
131-
Commands --> ProjectGroup[project]
146+
Commands --> AuthGroup[auth vertical]
147+
Commands --> IssueGroup[issue vertical]
148+
Commands --> PrGroup[pr vertical]
149+
Commands --> ProjectGroup[project vertical]
132150
133151
AuthGroup --> AuthToken[token]
134152
AuthToken --> AuthTokenDef[definition.ts]
@@ -169,6 +187,7 @@ graph TD
169187

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

173192
Canonical layout:
174193

@@ -206,6 +225,7 @@ graph LR
206225
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.
207226

208227
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`.
228+
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.
209229

210230
To add a new command:
211231
- create a new directory at `src/commands/<group>/<command>/`

src/cli/parse.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,9 @@ import {
77
type CommandOptionDefinition,
88
} from '../commands/registry/index.js';
99
import { getOrfeVersion } from '../version.js';
10-
import { CliUsageError } from '../runtime/errors.js';
1110

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

src/cli/run.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { CliUsageError, formatCliUsageError } from './usage-error.js';
12
import { runOrfeCore } from '../core/run.js';
23
import { createCliLogger } from '../logging/logger.js';
3-
import { CliUsageError, OrfeError, formatCliUsageError } from '../runtime/errors.js';
4+
import { OrfeError } from '../runtime/errors.js';
45
import { createErrorResponse } from '../runtime/response.js';
56

67
import { createLeafUsageError, parseInvocationForCli } from './parse.js';

src/cli/usage-error.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
export class CliUsageError extends Error {
2+
readonly usage: string;
3+
readonly example: string;
4+
readonly see: string;
5+
6+
constructor(message: string, details: { usage: string; example: string; see: string }) {
7+
super(message);
8+
this.name = 'CliUsageError';
9+
this.usage = details.usage;
10+
this.example = details.example;
11+
this.see = details.see;
12+
}
13+
}
14+
15+
export function formatCliUsageError(error: CliUsageError): string {
16+
return [`Error: ${error.message}`, `Usage: ${error.usage}`, `Example: ${error.example}`, `See: ${error.see}`].join('\n');
17+
}

src/commands/issue/create/handler.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
1-
import { resolveProjectCommandConfig } from '../../../config/project-defaults.js';
2-
import { OrfeError } from '../../../runtime/errors.js';
31
import type { CommandContext } from '../../../core/context.js';
42
import type { CommandInput } from '../../../core/types.js';
3+
import { resolveProjectCommandConfig } from '../../../config/project-defaults.js';
4+
import { prepareIssueBodyFromInput } from '../../../templates/body-input.js';
5+
import { OrfeError } from '../../../runtime/errors.js';
56
import {
67
addProjectItemByContentId,
78
type ProjectAddItemResult,
@@ -18,7 +19,6 @@ import {
1819
import {
1920
selectProjectStatusOption,
2021
} from '../../project/shared/status-field.js';
21-
import { prepareIssueBodyFromInput } from '../../shared/body-input.js';
2222
import type { IssueCreateData, IssueCreateProjectAssignmentData } from './output.js';
2323
import { getGitHubRequestStatus } from '../shared/github-errors.js';
2424
import {
@@ -166,7 +166,7 @@ async function buildIssueCreateMutation(context: CommandContext<'issue create'>)
166166
title: input.title as string,
167167
};
168168

169-
const body = await prepareIssueBodyFromInput(context);
169+
const body = await prepareIssueBodyFromInput(context.input, context.repoConfig);
170170

171171
if (typeof body === 'string') {
172172
mutation.body = body;

src/commands/issue/update/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { OrfeError } from '../../../runtime/errors.js';
22
import type { CommandContext } from '../../../core/context.js';
33
import type { CommandInput } from '../../../core/types.js';
4-
import { prepareIssueBodyFromInput } from '../../shared/body-input.js';
4+
import { prepareIssueBodyFromInput } from '../../../templates/body-input.js';
55
import type { IssueUpdateData } from './output.js';
66
import { getGitHubRequestStatus } from '../shared/github-errors.js';
77
import {
@@ -52,7 +52,7 @@ async function buildIssueUpdateMutation(context: CommandContext<'issue update'>)
5252
mutation.title = input.title;
5353
}
5454

55-
const body = await prepareIssueBodyFromInput(context);
55+
const body = await prepareIssueBodyFromInput(context.input, context.repoConfig);
5656

5757
if (typeof body === 'string') {
5858
mutation.body = body;

src/commands/pr/get-or-create/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { OrfeError } from '../../../runtime/errors.js';
22
import type { CommandContext } from '../../../core/context.js';
3-
import { preparePullRequestBodyFromInput } from '../../shared/body-input.js';
3+
import { preparePullRequestBodyFromInput } from '../../../templates/body-input.js';
44
import type { PullRequestGetOrCreateData } from './output.js';
55
import { getGitHubRequestStatus } from '../shared/github-errors.js';
66
import {
@@ -48,7 +48,7 @@ export async function handlePrGetOrCreate(context: CommandContext<'pr get-or-cre
4848
return normalizePullRequestGetOrCreateData(existingPullRequest, false);
4949
}
5050

51-
const body = await preparePullRequestBodyFromInput(context);
51+
const body = await preparePullRequestBodyFromInput(context.input, context.repoConfig);
5252

5353
try {
5454
const { rest } = await context.getGitHubClient();

src/commands/pr/update/handler.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { OrfeError } from '../../../runtime/errors.js';
22
import type { CommandContext } from '../../../core/context.js';
33
import type { CommandInput } from '../../../core/types.js';
4-
import { preparePullRequestBodyFromInput } from '../../shared/body-input.js';
4+
import { preparePullRequestBodyFromInput } from '../../../templates/body-input.js';
55
import type { PullRequestUpdateData } from './output.js';
66
import { getGitHubRequestStatus } from '../shared/github-errors.js';
77
import {
@@ -43,7 +43,7 @@ async function buildPullRequestUpdateMutation(context: CommandContext<'pr update
4343
mutation.title = input.title;
4444
}
4545

46-
const body = await preparePullRequestBodyFromInput(context);
46+
const body = await preparePullRequestBodyFromInput(context.input, context.repoConfig);
4747
if (typeof body === 'string') {
4848
mutation.body = body;
4949
}

src/commands/shared/body-input.ts

Lines changed: 0 additions & 31 deletions
This file was deleted.

src/config/auth-config.ts

Lines changed: 6 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,25 +1,19 @@
11
import path from 'node:path';
22

33
import { OrfeError } from '../runtime/errors.js';
4-
import { expandUserPath } from '../path/path.js';
4+
import { expandUserPath } from '../fs/path.js';
55

66
import {
7-
expectLiteralNumber,
8-
expectNumber,
9-
expectObject,
10-
expectString,
11-
isObject,
12-
readJsonFile,
137
resolveAuthConfigPath,
14-
type GitHubAppBotAuthConfig,
15-
type LoadAuthConfigOptions,
16-
type MachineAuthConfig,
17-
} from './shared.js';
8+
} from './config-paths.js';
9+
import { readConfigJsonFile } from './json-file.js';
10+
import { expectLiteralNumber, expectNumber, expectObject, expectString, isObject } from './schema.js';
11+
import type { GitHubAppBotAuthConfig, LoadAuthConfigOptions, MachineAuthConfig } from './types.js';
1812

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

2418
if (!isObject(parsed)) {
2519
throw new OrfeError('config_invalid', `Auth config at ${authConfigPath} must contain a JSON object.`);

0 commit comments

Comments
 (0)