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
3 changes: 3 additions & 0 deletions .github/workflows/claude-code-review.yml
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ jobs:
- name: Install dependencies
run: bun install --frozen-lockfile

- name: Ensure setup-bun's bun takes precedence
run: echo "$(dirname $(which bun))" >> $GITHUB_PATH

- name: Run Claude Code Review
id: claude-review
uses: anthropics/claude-code-action@v1
Expand Down
1 change: 1 addition & 0 deletions GEMINI.md
28 changes: 27 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,32 @@ See `config.example.json` for the full structure. The config maps space names to

**Including spaces from other configs:** Use `includeSpacesFrom` to import space definitions from other config files. This is useful for aggregating spaces from multiple projects into a central config, reducing the need to specify `--config` on CLI commands. Duplicate space names are not allowed.

**Plugins:** Use `plugins` to load parse plugins that read spaces from non-markdown sources. The built-in markdown plugin is always available without any declaration. Plugins are tried in order; the first to return a result wins. The `plugins` field is a map of plugin name to plugin config, and can be declared at the top level (applies to all spaces) or per-space (overrides the top level):

```json
{
"spaces": [
{
"name": "ProductX",
"path": "/path/to/space",
"plugins": {
"markdown": { "fieldMap": { "record_type": "type" } }
}
}
],
"plugins": {
"ost-tools-confluence": { "baseUrl": "https://example.atlassian.net" }
}
}
```

All plugin names must start with `ost-tools-` (the prefix is optional in config and normalised on load). The special name `markdown` refers to the built-in markdown plugin. External plugins are resolved in order: config-adjacent (`{configDir}/plugins/{name}`), then npm. Each plugin must export a `configSchema` JSON Schema; config is validated against it on load. Fields annotated `format: 'path'` in a plugin's `configSchema` are resolved relative to the config file directory.

**Markdown plugin config** fields (set under `plugins.markdown` per space):
- `templateDir` — directory containing template files (used by `template-sync`)
- `templatePrefix` — filename prefix for templates (default blank)
- `fieldMap` — maps file/frontmatter field names to canonical schema field names (e.g. `{ "record_type": "type" }`)

### Spaces

A space is a named directory or single file registered in the config. Spaces let you reference content by name instead of path:
Expand Down Expand Up @@ -228,7 +254,7 @@ ost-tools template-sync [--space name] [--schema path/to/my-schema.json] [--crea
Keeps Obsidian template files in sync with schema examples:
- Matches markdown files in the template directory (defined in config) by `type` field
- Rewrites frontmatter using description fields and property `examples`
- `templatePrefix` in config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`.
- `templatePrefix` in `plugins.markdown` config (default blank) sets a naming convention for templates (`{templatePrefix}{type}.md`). This will be used to check existing filenames, and create new templates with `--create-missing`.
- `--dry-run` previews changes without writing files

## Development
Expand Down
22 changes: 0 additions & 22 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

13 changes: 8 additions & 5 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,14 @@
"path": "/path/to/ProductX/Opportunity Solution Tree",
"schema": "/path/to/custom/schemas/my-projectx-schema.json",
"miroBoardId": "iAmAB04rdId",
"miroFrameId": "1234567789"
// "templateDir": "../templates",
// "templatePrefix": "prodx-"
"miroFrameId": "1234567789",
"plugins": {
"markdown": {
"templateDir": "../templates",
"templatePrefix": "prodx-"
}
}
}
],
"schema": "/path/to/custom/schemas/my-general-schema.json",
"templateDir": "/path/to/ProductX/Templates"
"schema": "/path/to/custom/schemas/my-general-schema.json"
}
16 changes: 16 additions & 0 deletions docs/concepts.md
Original file line number Diff line number Diff line change
Expand Up @@ -260,3 +260,19 @@ An **anchor** is a block anchor (e.g. `^goal1`) appended to a heading in a `type
`identified` → `wondering` → `exploring` → `active` → `paused` → `completed` → `archived`

Status is required on all node types at _validation_ time. Note however that currently the `space on a page` parser chooses to apply a default.


---

## Plugin

A **plugin** is a module that extends the tool's capabilities.

Plugins that support parsing produce raw `SpaceNode[]` results given a suitable configuration. Graph edge resolution (`resolveGraphEdges`) is called by the core afterwards.

The `plugins` field in config is a **map** from plugin name to plugin config object. All plugin names must start with `ost-tools-`, but the prefix is optional in config and normalised on load. Built-in plugins take precedence and all are loaded by default. External plugin names specified in config are then resolved in order:

1. Config-adjacent: `{configDir}/plugins/{ost-tools-name}`
2. npm: a package matching `ost-tools-*`

Each plugin declares a `configSchema` JSON Schema; the loader validates the config block against it before invoking the plugin. Config fields annotated with `format: 'path'` are resolved relative to `configDir` by the loader.
2 changes: 0 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,10 +38,8 @@
"ajv": "^8.18.0",
"chokidar": "^5.0.0",
"commander": "^14.0.3",
"glob": "^13.0.6",
"gray-matter": "^4.0.3",
"js-yaml": "^4.1.1",
"json5": "^2.2.3",
"jsonata": "^2.1.0",
"mdast-util-to-string": "^4.0.0",
"remark-gfm": "^4.0.1",
Expand Down
28 changes: 28 additions & 0 deletions skills/ost-tools/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ dump Output parsed node data as JSON
diagram Generate Mermaid diagram
miro-sync Sync to Miro board (requires MIRO_TOKEN env var + miroBoardId in config)
template-sync Sync Obsidian templates from schema examples
plugins List available plugins
```

Run `bunx ost-tools --help` or `bunx ost-tools <command> --help` for flags.
Expand Down Expand Up @@ -94,6 +95,33 @@ what the rule actually sees in the `current` object, then adjust the rule in the
| `could not find node '[[Title]]'` | Broken wikilink | Fix the title in the link or ensure the target file exists and has that title |
| `JSONata error: ...` | Syntax error in schema `$metadata.rules` | Verify the expression with `dump` and a JSONata tester |

## Plugins

ost-tools supports **plugins** for extending capabilities. Currently, parse plugins allow reading spaces from sources other than markdown (which is a built-in plugin).

Declare plugins in config as a of plugin name → config object:

```json
{
"spaces": [
{
"name": "PDFSpace",
"path": "https://...",
"plugins": {
"ost-tools-pdf": { "baseUrl": "https://example.pdfstore.net" }
}
}
]
}
```

All plugin names must start with `ost-tools-` (the prefix is optional in config and normalised on load). External plugins are resolved in order: config-adjacent (`{configDir}/plugins/{name}`), then npm.

**Markdown plugin config** (under `plugins.markdown` in a space entry):
- `templateDir` — directory for template files used by `template-sync`, and to exclude templates when parsing and validating
- `templatePrefix` — filename prefix for templates (default blank)
- `fieldMap` — maps file field names to canonical schema field names (e.g. `{ "record_type": "type" }`)

## References

- **`references/schema-authoring.md`** — schema file structure, `$metadata`, `fieldMap`, JSONata rules
Expand Down
14 changes: 4 additions & 10 deletions src/commands/diagram.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,20 +20,14 @@ function safeNodeId(id: string): string {
return id.replace(/[^a-zA-Z0-9_-]/g, '_');
}

export async function diagram(
path: string,
options: { schema: string; output?: string; templateDir?: string },
): Promise<void> {
export async function diagram(path: string, options: { schema: string; output?: string }): Promise<void> {
const { schema, validator } = loadSchema(options.schema);
const hierarchyLevels = schema.metadata.hierarchy?.levels ?? [];

const readResult = await readSpace(path, {
schemaPath: options.schema,
templateDir: options.templateDir,
});
const readResult = await readSpace(path, { schemaPath: options.schema });
const spaceNodes: SpaceNode[] = readResult.nodes;
const skipped = readResult.kind === 'directory' ? readResult.skipped : [];
const nonSpace = readResult.kind === 'directory' ? readResult.nonSpace : [];
const skipped = (readResult.diagnostics?.skipped as string[]) ?? [];
const nonSpace = (readResult.diagnostics?.nonSpace as string[]) ?? [];

// Validate nodes
const validNodes: SpaceNode[] = [];
Expand Down
12 changes: 3 additions & 9 deletions src/commands/dump.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,7 @@
import JSON5 from 'json5';
import { JSON5 } from 'bun';
import { readSpace } from '../read/read-space';

export async function dump(path: string) {
const result = await readSpace(path);
if (result.kind === 'page') {
const { nodes, diagnostics } = result;
console.log(JSON5.stringify({ nodes, diagnostics }, null, 2));
} else {
const { nodes, skipped, nonSpace } = result;
console.log(JSON5.stringify({ nodes, skipped, nonSpace }, null, 2));
}
const { nodes, source, diagnostics } = await readSpace(path);
console.log(JSON5.stringify({ nodes, source, diagnostics }, null, 2));
}
24 changes: 24 additions & 0 deletions src/commands/plugins.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import { existsSync, readdirSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { configPath } from '../config';
import { builtinPlugins } from '../plugins';
import { CONFIG_PLUGINS_DIR, PLUGIN_PREFIX } from '../plugins/util';

export function listPlugins(): void {
console.log('Built-in plugins:');
for (const plugin of builtinPlugins) {
console.log(` ${plugin.name}`);
}

const cfgDir = dirname(resolve(configPath()));
const pluginsDir = join(cfgDir, CONFIG_PLUGINS_DIR);
if (existsSync(pluginsDir)) {
const entries = readdirSync(pluginsDir).filter((e) => e.startsWith(PLUGIN_PREFIX));
if (entries.length > 0) {
console.log('\nConfig-adjacent plugins:');
for (const entry of entries) {
console.log(` ${entry}`);
}
}
}
}
32 changes: 20 additions & 12 deletions src/commands/spaces.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { basename, join, resolve } from 'node:path';
import { basename, resolve } from 'node:path';
import { configPath, loadConfig, resolveSchema } from '../config';

function renderConfigValue(v: unknown): string {
if (typeof v === 'object' && v !== null) return JSON.stringify(v);
return String(v);
}

export function listSpaces(): void {
const path = resolve(configPath());
const config = loadConfig();
Expand All @@ -9,18 +14,21 @@ export function listSpaces(): void {
console.log(`${space.name}`);
console.log(` path: ${space.path}`);
console.log(` schema: ${basename(resolveSchema(undefined, config, space))}`);
const templateDir = space.templateDir ?? config.templateDir;
if (templateDir) {
const templateFormat = space.templatePrefix ?? config.templatePrefix ?? '';
const fullTemplatePath = templateFormat ? join(templateDir, `${templateFormat}*.md`) : templateDir;
console.log(` templates: ${fullTemplatePath}`);
}
if (space.miroBoardId) console.log(` miro: configured`);
if (space.fieldMap && Object.keys(space.fieldMap).length > 0) {
const mappings = Object.entries(space.fieldMap)
.map(([k, v]) => `${k} → ${v}`)
.join(', ');
console.log(` fieldMap: ${mappings}`);
const plugins = space.plugins ?? {};
if (Object.keys(plugins).length > 0) {
console.log(' plugins:');
for (const [name, cfg] of Object.entries(plugins)) {
const entries = Object.entries(cfg);
if (entries.length === 0) {
console.log(` ${name}`);
} else {
console.log(` ${name}:`);
for (const [k, v] of entries) {
console.log(` ${k}: ${renderConfigValue(v)}`);
}
}
}
}
console.log('');
}
Expand Down
18 changes: 10 additions & 8 deletions src/commands/template-sync.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { AnySchemaObject } from 'ajv';
import { glob } from 'glob';
import { Glob } from 'bun';
import matter from 'gray-matter';
import yaml from 'js-yaml';
import { invertFieldMap } from '../config';
import { getMarkdownConfig } from '../plugins/markdown';
import { invertFieldMap } from '../plugins/markdown/util';
import { loadSchema } from '../schema/schema';
import { mergeVariantProperties, resolveRef } from '../schema/schema-refs';
import type { HierarchyLevel, Relationship, SchemaWithMetadata } from '../types';
Expand Down Expand Up @@ -249,23 +250,24 @@ export function generateNewContent(
}

export async function templateSync(
templateDir: string,
plugins: Record<string, Record<string, unknown>> | undefined,
options: {
schema: string;
templatePrefix: string;
dryRun?: boolean;
createMissing?: boolean;
fieldMap?: Record<string, string>;
},
) {
const { templateDir, templatePrefix = '', fieldMap = {} } = getMarkdownConfig(plugins);
if (!templateDir) {
console.error('Error: templateDir not set in plugins.ost-tools-markdown config for this space');
process.exit(1);
}
const { schema, registry } = loadSchema(options.schema);
const templatePrefix = options.templatePrefix;
const fieldMap = options.fieldMap ?? {};

const typeVariants = getTypeVariants(schema, registry);
const matchedTypes = new Set<string>();

const files = await glob('*.md', { cwd: templateDir, absolute: true });
const files = await Array.fromAsync(new Glob('*.md').scan({ cwd: templateDir, absolute: true }));
const dryRun = options.dryRun ?? false;
let filesModified = 0;
let filesCreated = 0;
Expand Down
Loading
Loading