Refine config schema to discriminated union, document OpenCode options passthrough

Config is now a discriminated union on source.type:
- FileSource: { type: 'file', tasksPath?: string }
- ApiSource (future): { type: 'api', url: string }
- No config → default FileSource('tasks'), silent if missing

OpenCode passes plugin options as raw Record<string, unknown> with
no validation — the plugin validates with TypeBox at startup. This
means no extra config files, everything in opencode.json.

Updated plugin entry code to show resolveConfig with TypeBox Check/
Cast, type-appropriate error handling, and source factory integration.
This commit is contained in:
2026-04-28 10:28:13 +00:00
parent 34d1802d30
commit f8b7a2fc1b
2 changed files with 99 additions and 54 deletions

View File

@@ -102,21 +102,34 @@ Operations map to `@alkdev/taskgraph` functions, reading tasks from a `TaskSourc
## Plugin Config ## Plugin Config
Optional config via `opencode.json`: Optional config via `opencode.json`. OpenCode passes the raw options object to the plugin — the plugin validates with TypeBox at startup.
```jsonc ```jsonc
// No config = default FileSource("tasks"), silent if directory missing
{
"plugin": ["@alkdev/open-tasks"]
}
// Explicit file source with custom path
{ {
"plugin": [ "plugin": [
["@alkdev/open-tasks", { ["@alkdev/open-tasks", {
"tasksPath": "tasks" // relative to workspace root (default: "tasks") "source": { "type": "file", "tasksPath": "docs/tasks" }
}] }]
] ]
} }
// Future: API source (secrets via env vars, not config)
// {
// "plugin": [
// ["@alkdev/open-tasks", {
// "source": { "type": "api", "url": "https://api.example.com/tasks" }
// }]
// ]
// }
``` ```
If no config is provided, defaults to `"tasks"` (a `tasks/` directory relative to workspace root). Config is validated at runtime using TypeBox + `Value.Check`. The `source.type` field is a discriminated union — each source type has its own config shape. Defaults to `{ type: "file", tasksPath: "tasks" }` if no config is provided. Secrets (API keys) come from environment variables, not config files.
The `TaskSource` abstraction means operations never touch the filesystem directly — they call `source.load()`. This makes future sources (API endpoints, databases) swappable without changing any operation logic.
## Local Development & Testing ## Local Development & Testing

View File

@@ -83,38 +83,76 @@ src/
### Plugin Configuration ### Plugin Configuration
The plugin reads optional configuration from `opencode.json` under the plugin entry: OpenCode passes plugin options as a raw `Record<string, unknown>` directly from the `opencode.json` config tuple. There is no OpenCode-side validation — the plugin receives exactly what was in the config file. This means:
- The plugin validates its own config using TypeBox + `Value.Check()` at startup
- Invalid config produces a clear error and falls back to defaults
- No extra config files needed — everything lives in `opencode.json`
```jsonc ```jsonc
// opencode.json // No config = default FileSource("tasks"), do nothing if directory doesn't exist
{
"plugin": ["@alkdev/open-tasks"]
}
// Explicit file source with custom path
{ {
"plugin": [ "plugin": [
["@alkdev/open-tasks", { ["@alkdev/open-tasks", {
"tasksPath": "tasks" // relative to workspace root (default: "tasks") "source": { "type": "file", "tasksPath": "docs/tasks" }
}] }]
] ]
} }
// Future example: API source (secrets via env vars, not config)
// {
// "plugin": [
// ["@alkdev/open-tasks", {
// "source": { "type": "api", "url": "https://api.example.com/tasks" }
// }]
// ]
// }
``` ```
If no config is provided, the plugin defaults to `"tasks"` (a `tasks/` directory relative to the workspace root). ### Config Behavior
The config schema uses TypeBox (already a dependency via `@alkdev/taskgraph`), giving us: - **No config or no `source` key** → FileSource with `tasksPath: "tasks"`. If the directory doesn't exist, operations return an empty/graceful result rather than an error. The plugin does nothing silently — no crash, no noise.
- **`source` provided** → Factory resolves `source.type` to the matching TaskSource implementation. Unknown types produce a clear error at startup.
- **Secrets** (future API keys, tokens) are never stored in config files (which are committed to git). They come from environment variables at runtime (e.g., `TASKGRAPH_API_KEY`). Config holds only non-sensitive connection parameters (URLs, paths).
- **Compile-time types** — `Static<typeof ConfigSchema>` for TypeScript inference The `source.type` field is a discriminated union key. Each source type has its own config shape — one type, one set of properties. This avoids the flat "add more keys" anti-pattern where every new source type adds nullable fields to a growing config object.
- **Runtime validation** — `Value.Check(ConfigSchema, configObj)` to reject invalid config
- **JSON Schema export** — for tooling/IDE support ### Config Schema
```typescript ```typescript
import { Type, type Static } from "@alkdev/typebox" import { Type, type Static, Union, Literal, Object, String, Optional } from "@alkdev/typebox"
const FileSourceConfig = Type.Object({
type: Type.Literal("file"),
tasksPath: Type.Optional(Type.String({ default: "tasks", description: "Relative to workspace root" })),
})
const ApiSourceConfig = Type.Object({
type: Type.Literal("api"),
url: Type.String({ description: "Endpoint URL" }),
// API keys read from env vars: TASKGRAPH_API_KEY
// Not stored in config (committed to git)
})
export const SourceConfigSchema = Type.Union([FileSourceConfig, ApiSourceConfig])
export const ConfigSchema = Type.Object({ export const ConfigSchema = Type.Object({
tasksPath: Type.Optional(Type.String({ default: "tasks" })), source: Type.Optional(SourceConfigSchema), // defaults to FileSource("tasks")
}) })
export type Config = Static<typeof ConfigSchema> export type Config = Static<typeof ConfigSchema>
export type SourceConfig = Static<typeof SourceConfigSchema>
``` ```
This minimal schema is forward-looking. Future sources (API endpoints, databases) will add their own config keys. TypeBox gives us:
- **Compile-time types** — `Static<typeof ConfigSchema>` for TypeScript inference, discriminated union on `source.type`
- **Runtime validation** — `Value.Check(ConfigSchema, configObj)` rejects invalid config at startup
- **JSON Schema export** — `Value.Convert()` applies defaults, IDE autocomplete via `$schema`
### TaskSource Abstraction ### TaskSource Abstraction
@@ -162,6 +200,11 @@ class FileSource implements TaskSource {
} }
async load(): Promise<SourceResult> { async load(): Promise<SourceResult> {
// If directory doesn't exist, return empty result (not an error)
if (!existsSync(this.dirPath)) {
return { tasks: [], rawFiles: new Map(), errors: [] }
}
const glob = new Bun.Glob("**/*.md") const glob = new Bun.Glob("**/*.md")
const files = Array.from(glob.scanSync({ cwd: this.dirPath })) const files = Array.from(glob.scanSync({ cwd: this.dirPath }))
// ... read each file, parse with parseFrontmatter, collect results // ... read each file, parse with parseFrontmatter, collect results
@@ -169,48 +212,31 @@ class FileSource implements TaskSource {
} }
``` ```
**Why Bun.Glob instead of `parseTaskDirectory`?** The library's `parseTaskDirectory` uses `node:fs/promises.readdir` recursively and silently skips files with invalid frontmatter. We use `Bun.Glob` instead because: **Key behavior**: if the configured directory doesn't exist, `FileSource.load()` returns an empty `SourceResult` — no crash, no error. Operations that receive an empty task set produce a clear message ("No tasks found in `<path>`. Create a `tasks/` directory..."). This means the plugin is safe to install without setting anything up — it just does nothing until task files appear.
1. **We need raw file content** — the `show` operation returns the full markdown body, not just frontmatter. `parseTaskDirectory` only returns parsed `TaskInput` objects; we'd need a separate pass to read file contents. **Path resolution** for FileSource:
2. **We need error detail**`parseTaskDirectory` silently skips invalid files. We need to surface parse errors with filenames so the `validate` operation can report them.
3. **Single-pass I/O**`Bun.Glob` gives us file paths, then we read each file once with `Bun.file()` and parse with `parseFrontmatter`. One I/O pass, not two.
4. **Consistent runtime** — the plugin targets Bun. `Bun.Glob` and `Bun.file()` are the native APIs; no reason to use Node compat shims.
The library's `parseFrontmatter` (singular) is still the right tool for parsing individual file content. We just replace the directory-scanning and file-reading parts. 1. **Config `tasksPath`** — if provided, treated as relative to workspace root (from `ctx.directory` in `PluginInput`). Path traversal (`../../etc/`) is rejected.
2. **Default**`"tasks"` relative to workspace root.
3. **Directory missing** — returns empty result, operations explain how to create one.
### Data Flow No CWD fallback. The workspace root from the OpenCode plugin context is the authoritative base path.
Each operation follows the same pipeline: ### Source Factory
```typescript
function createSource(config: Config, workspaceDir: string): TaskSource {
switch (config.source?.type) {
case "file":
case undefined: // default
return new FileSource(resolve(workspaceDir, config.source?.tasksPath ?? "tasks"))
case "api":
return new ApiSource(config.source) // future
default:
throw new Error(`Unknown source type: ${config.source?.type}`)
}
}
``` ```
Agent calls tasks({tool: "list", args: {status: "pending"}})
├─ registry.ts validates tool name and args
├─ Operation handler:
│ │
│ ├─ source.load() → SourceResult (tasks, rawFiles, errors)
│ │
│ ├─ TaskGraph.fromTasks(sourceResult.tasks) → in-memory graph
│ │
│ ├─ Analysis function (e.g., parallelGroups(graph))
│ │
│ └─ format result as markdown
└─ Return formatted markdown to agent
```
The `source` is resolved once at plugin initialization (in `index.ts`) and passed to all operation handlers via the registry. Operations call `source.load()` to get fresh data — no caching between calls.
### Path Resolution
The plugin resolves its tasks directory from config with safe defaults:
1. **Config**`tasksPath` from plugin config (if provided). Treated as relative to workspace root. Path traversal is rejected.
2. **Default**`tasks/` relative to workspace root (from `ctx.directory` in `PluginInput`).
3. **No config, no directory** — operations return a clear message explaining how to create a `tasks/` directory.
There is no CWD fallback. The workspace root from the OpenCode plugin context is the authoritative base path.
## Operations Reference ## Operations Reference
@@ -314,14 +340,20 @@ const OpenTasksPlugin: Plugin = async (ctx, options) => {
} }
} }
// OpenCode passes the raw JSON object from opencode.json as PluginOptions.
// It's Record<string, unknown> — untyped. We validate with TypeBox and apply defaults.
function resolveConfig(options?: PluginOptions): Config { function resolveConfig(options?: PluginOptions): Config {
if (options && Object.keys(options).length > 0) { if (options && Object.keys(options).length > 0) {
// Validate against our schema. If invalid, log a warning and fall back to defaults.
if (!Value.Check(ConfigSchema, options)) { if (!Value.Check(ConfigSchema, options)) {
// Log warning, fall back to defaults console.warn("@alkdev/open-tasks: invalid config, using defaults", {
errors: [...Value.Errors(ConfigSchema, options)],
})
return { source: { type: "file", tasksPath: "tasks" } }
} }
return Value.Cast(ConfigSchema, options) as Config return Value.Cast(ConfigSchema, options) as Config
} }
return { tasksPath: "tasks" } return { source: { type: "file", tasksPath: "tasks" } }
} }
export default OpenTasksPlugin export default OpenTasksPlugin