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
19 changes: 16 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -335,13 +335,26 @@ sctx schemas show strategy_general --mermaid-erd
sctx miro-sync <space> [--new-frame <title>] [--dry-run] [--verbose]
```

Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `miroBoardId` set in the space's config entry.
Syncs space nodes to a Miro board as cards with connectors. Requires `MIRO_TOKEN` env var and `boardId` set in the `miro` plugin config for the space.

- `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `miroFrameId` back to the config file
- `--new-frame <title>` — create a new frame on the board and sync into it; auto-saves the resulting `frameId` back to the miro plugin config
- `--dry-run` — show what would change without touching Miro
- `--verbose` / `-v` — detailed per-card and per-connector output

On subsequent runs, the cached `miroFrameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.
Configure the miro plugin in the space's config entry:

```json
{
"plugins": {
"miro": {
"boardId": "your-board-id",
"frameId": "your-frame-id"
}
}
}
```

On subsequent runs, the cached `frameId` is used automatically. Cards are colour-coded by node type and linked by parent→child connectors. A local `.miro-cache/` directory tracks Miro IDs to enable incremental updates.

Sync is one-way (OST → Miro) and scoped to a single frame. Only cards and connectors created by this tool within that frame are managed — everything else on the board is left untouched. Card content and connectors are overwritten or recreated to match the markdown source; any edits made directly in Miro to managed cards will be lost on the next sync. Existing card positions are not changed.

Expand Down
6 changes: 4 additions & 2 deletions config.example.json
Original file line number Diff line number Diff line change
Expand Up @@ -5,12 +5,14 @@
"name": "ProductX",
"path": "/path/to/ProductX/Opportunity Solution Tree",
"schema": "/path/to/custom/schemas/my-projectx-schema.json",
"miroBoardId": "iAmAB04rdId",
"miroFrameId": "1234567789",
"plugins": {
"markdown": {
"templateDir": "../templates",
"templatePrefix": "prodx-"
},
"miro": {
"boardId": "iAmAB04rdId",
"frameId": "1234567789"
}
}
}
Expand Down
2 changes: 1 addition & 1 deletion plugin/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "structured-context",
"version": "0.10.0",
"version": "0.10.1",
"description": "Structured content authoring for Obsidian — automatic validation hooks, slash commands, and agent skills to keep markdown files compliant with your schema.",
"homepage": "https://github.com/mindsocket/structured-context",
"repository": "https://github.com/mindsocket/structured-context",
Expand Down
2 changes: 1 addition & 1 deletion plugin/skills/structured-context/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ validate <space> Validate space content against schema (--watch for live)
show <space> Output the node hierarchy
dump <space> Output parsed node data as JSON
diagram <space> Generate Mermaid diagram (--output <file>)
miro-sync <space> Sync to Miro board (requires MIRO_TOKEN env var + miroBoardId in config)
miro-sync <space> Sync to Miro board (requires MIRO_TOKEN env var + miro plugin config with boardId)
template-sync <space> Sync Obsidian templates from schema examples
plugins List available plugins
```
Expand Down
14 changes: 8 additions & 6 deletions plugin/skills/structured-context/references/commands.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,10 +119,10 @@ sctx miro-sync <space> [--new-frame <title>] [--dry-run] [--verbose] [--config <

Syncs space nodes to a Miro board. Requires:
- `MIRO_TOKEN` environment variable
- `miroBoardId` in the space's config entry
- `boardId` set in `plugins.miro` for the space

First sync: use `--new-frame "Frame Title"` to create a new frame; the resulting `miroFrameId`
is auto-saved to config. Subsequent syncs reuse the cached frame ID.
First sync: use `--new-frame "Frame Title"` to create a new frame; the resulting `frameId`
is auto-saved to the miro plugin config. Subsequent syncs reuse the cached frame ID.

Sync is one-way (structured-context → Miro). Manual edits to managed cards in Miro are overwritten.

Expand Down Expand Up @@ -172,10 +172,12 @@ sctx template-sync <space> --create-missing
},
templateDir: '../templates',
templatePrefix: '',
}
},
miro: {
boardId: 'xxx',
frameId: 'xxx', // auto-populated by --new-frame
},
},
miroBoardId: 'xxx',
miroFrameId: 'xxx', // auto-populated by --new-frame
views: {
'active-solutions': { expression: "WHERE resolvedType='solution' and status='active'" },
},
Expand Down
1 change: 1 addition & 0 deletions src/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
export type { AnySchemaObject, SchemaObject, ValidateFunction } from 'ajv';
export type { Config, SpaceConfig } from './config';
export { loadConfig, setConfigPath } from './config';
export { PLUGIN_PREFIX } from './constants';
export type {
ParseHook,
ParseResult,
Expand Down
11 changes: 11 additions & 0 deletions src/commands/miro-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import type { SyncOptions } from '../plugins/miro/sync';
import { executeRender } from '../render/render';
import type { SpaceContext } from '../types';

export async function miroSyncCommand(context: SpaceContext, options: SyncOptions): Promise<void> {
const summary = await executeRender('sctx-miro:board', context, {
filter: undefined,
data: options as Record<string, unknown>,
});
console.log(summary);
}
1 change: 0 additions & 1 deletion src/commands/spaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,6 @@ export function listSpaces(): void {
console.log(` path: ${space.path}`);
const schema = space.schema ?? config.schema;
console.log(` schema: ${schema ? basename(schema) : '(none)'}`);
if (space.miroBoardId) console.log(` miro: configured`);
const plugins = space.plugins ?? {};
if (Object.keys(plugins).length > 0) {
console.log(' plugins:');
Expand Down
29 changes: 14 additions & 15 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
import Ajv from 'ajv';
import JSON5 from 'json5';
import { ENV_CONFIG_VAR, XDG_CONFIG_DIR } from './constants';
import { normalizePluginName } from './plugins/util';
import { normalizePluginName, shortenPluginName } from './plugins/util';
import { bundledSchemasDir } from './schema/schema';

const CONFIG_SCHEMA = {
Expand All @@ -18,8 +18,6 @@ const CONFIG_SCHEMA = {
name: { type: 'string', pattern: '^[a-z0-9_-]+$' },
path: { type: 'string' },
schema: { type: 'string' },
miroBoardId: { type: 'string' },
miroFrameId: { type: 'string' },
plugins: { type: 'object', additionalProperties: { type: 'object' } },
views: {
type: 'object',
Expand All @@ -46,8 +44,6 @@ export type SpaceConfig = {
name: string;
path: string;
schema?: string;
miroBoardId?: string;
miroFrameId?: string;
/** Plugin name → plugin config map. Overrides top-level plugins when set. */
plugins?: Record<string, Record<string, unknown>>;
/** Named filter views for this space. Keys are view names; values contain the filter expression. */
Expand Down Expand Up @@ -209,19 +205,22 @@ export function resolveSchema(config: Config, space?: SpaceConfig): string {
return schema;
}

type StringFields<T> = { [K in keyof T]: T[K] extends string | undefined ? K : never }[keyof T];

/** Update a string field on a space entry and persist config. */
export function updateSpaceField(spaceName: string, field: StringFields<SpaceConfig>, value: string): void {
/** Update a string field on a space entry and persist config. When `plugin` is given, updates that field inside `space.plugins[plugin]` instead. */
export function updateSpaceField(spaceName: string, field: string, value: string, plugin?: string): void {
const sourcePath = _spaceSourceFiles.get(spaceName);
if (!sourcePath) {
throw new Error(`Space "${spaceName}" not found in any config file`);
}
if (!sourcePath) throw new Error(`Space "${spaceName}" not found in any config file`);
const config = _loadConfig(sourcePath);
const space = config.spaces?.find((s: SpaceConfig) => s.name === spaceName);
if (!space) {
throw new Error(`Unknown space config: "${spaceName}". Check config.`);
if (!space) throw new Error(`Unknown space config: "${spaceName}". Check config.`);
if (plugin !== undefined) {
if (!space.plugins) space.plugins = {};
const normalized = normalizePluginName(plugin);
const shortName = shortenPluginName(normalized);
const existingKey = Object.keys(space.plugins).find((k) => k === normalized || k === shortName) ?? normalized;
if (!space.plugins[existingKey]) space.plugins[existingKey] = {};
space.plugins[existingKey][field] = value;
} else {
(space as unknown as Record<string, unknown>)[field] = value;
}
(space as unknown as Record<string, unknown>)[field as string] = value;
writeFileSync(sourcePath, `${JSON5.stringify(config, null, 2)}\n`);
}
5 changes: 2 additions & 3 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { Command } from 'commander';
import { diagram } from './commands/diagram';
import { docs } from './commands/docs';
import { dump } from './commands/dump';
import { miroSyncCommand } from './commands/miro-sync';
import { listPlugins } from './commands/plugins';
import { render, renderList } from './commands/render';
import { listSchemas, showSchema } from './commands/schemas';
Expand All @@ -12,10 +13,8 @@ import { listSpaces } from './commands/spaces';
import { templateSync } from './commands/template-sync';
import { validate, watchValidate } from './commands/validate';
import { validateFileCommand } from './commands/validate-file';

import { getSpaceConfigDir, loadConfig, resolveSchema, setConfigPath } from './config';
import { CLI_NAME } from './constants';
import { miroSync } from './integrations/miro/sync';
import { loadSchema } from './schema/schema';
import type { SpaceContext } from './types';

Expand Down Expand Up @@ -109,7 +108,7 @@ program
.option('--new-frame <title>', 'Create a new frame on the board and sync into it')
.option('--dry-run', 'Show what would change without touching Miro')
.option('-v, --verbose', 'Detailed output')
.action((spaceName, options) => miroSync(buildSpaceContext(spaceName), options));
.action((spaceName, options) => miroSyncCommand(buildSpaceContext(spaceName), options));

program
.command('template-sync')
Expand Down
3 changes: 2 additions & 1 deletion src/plugins/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { markdownPlugin } from './markdown';
import { mermaidPlugin } from './mermaid';
import { miroPlugin } from './miro';
import type { StructuredContextPlugin } from './util';

/** All built-in plugins, in default load order. */
export const builtinPlugins: StructuredContextPlugin[] = [markdownPlugin, mermaidPlugin];
export const builtinPlugins: StructuredContextPlugin[] = [markdownPlugin, mermaidPlugin, miroPlugin];
10 changes: 8 additions & 2 deletions src/plugins/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,13 @@ import { dirname, isAbsolute, join, resolve } from 'node:path';
import Ajv, { type AnySchemaObject } from 'ajv';
import { getConfigSourceFiles } from '../config';
import { builtinPlugins } from '.';
import { CONFIG_PLUGINS_DIR, normalizePluginName, PLUGIN_PREFIX, type StructuredContextPlugin } from './util';
import {
CONFIG_PLUGINS_DIR,
normalizePluginName,
PLUGIN_PREFIX,
type StructuredContextPlugin,
shortenPluginName,
} from './util';

export type LoadedPlugin = {
plugin: StructuredContextPlugin;
Expand Down Expand Up @@ -110,7 +116,7 @@ export async function loadPlugins(

// Built-in plugins: always loaded, config taken from map if declared (with or without prefix)
for (const builtin of builtinPlugins) {
const rawConfig = pluginMap[builtin.name] ?? pluginMap[builtin.name.slice(PLUGIN_PREFIX.length)] ?? {};
const rawConfig = pluginMap[builtin.name] ?? pluginMap[shortenPluginName(builtin.name)] ?? {};
const pluginConfig = resolveConfigPaths(builtin.configSchema, rawConfig, configDir);
const validate = ajv.compile(builtin.configSchema);
if (!validate(pluginConfig)) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { createHash } from 'node:crypto';
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
import { join } from 'node:path';
import type { SpaceNode } from '../../types';
import type { SpaceNode } from '../../api';

export interface CachedNode {
miroCardId: string;
Expand Down
File renamed without changes.
28 changes: 28 additions & 0 deletions src/plugins/miro/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { PLUGIN_PREFIX, type StructuredContextPlugin } from '../../api';
import { miroSync } from './sync';

export const miroPlugin: StructuredContextPlugin = {
name: `${PLUGIN_PREFIX}miro`,
configSchema: {
type: 'object',
properties: {
boardId: { type: 'string' },
frameId: { type: 'string' },
},
additionalProperties: false,
},
render: {
formats: [{ name: 'board', description: 'Sync space to Miro board' }],
async render(context, graph, { format, data }) {
if (format === 'board') {
const options = {
newFrame: data?.newFrame as string | undefined,
dryRun: data?.dryRun as boolean | undefined,
verbose: data?.verbose as boolean | undefined,
};
return await miroSync(context, graph, options);
}
throw new Error(`Unknown miro render format: "${format}"`);
},
},
};
25 changes: 5 additions & 20 deletions src/integrations/miro/layout.ts → src/plugins/miro/layout.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HierarchyLevel, SpaceNode } from '../../types';
import type { HierarchyLevel, SpaceNode } from '../../api';

export const CARD_WIDTH = 320;
const CARD_HEIGHT = 160;
Expand All @@ -12,19 +12,6 @@ export interface LayoutResult {
bounds: { minX: number; minY: number; maxX: number; maxY: number };
}

/**
* DEPRECATED - likely not needed after migration to render plugin and SpaceGraph
* Build a depth map from hierarchy levels.
* The position in the hierarchy array determines the depth.
*/
function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map<string, number> {
const depthMap = new Map<string, number>();
for (const [i, level] of hierarchyLevels.entries()) {
depthMap.set(level.type, i);
}
return depthMap;
}

/**
* Compute positions for new cards only. Existing cards keep their Miro positions.
* New cards are laid out in rows grouped by OST type depth, starting below
Expand All @@ -37,11 +24,8 @@ function buildDepthMap(hierarchyLevels: HierarchyLevel[]): Map<string, number> {
export function layoutNewCards(
newNodes: SpaceNode[],
existingPositions: Map<string, { x: number; y: number }>,
hierarchyLevels: HierarchyLevel[],
hierarchyLevels: readonly HierarchyLevel[],
): LayoutResult {
// Build depth map from hierarchy levels (position in hierarchy = depth)
const depthMap = buildDepthMap(hierarchyLevels);

// Find the lowest y among existing cards
let lowestY = 0;
for (const pos of existingPositions.values()) {
Expand All @@ -52,10 +36,11 @@ export function layoutNewCards(

const startY = existingPositions.size > 0 ? lowestY + V_GAP * 2 : 0;

// Group new nodes by depth
// Group new nodes by depth (position in hierarchy levels array)
const byDepth = new Map<number, SpaceNode[]>();
for (const node of newNodes) {
const depth = depthMap.get(node.schemaData.type as string) ?? depthMap.size;
const idx = hierarchyLevels.findIndex((l) => l.type === (node.schemaData.type as string));
const depth = idx === -1 ? hierarchyLevels.length : idx;
if (!byDepth.has(depth)) byDepth.set(depth, []);
byDepth.get(depth)?.push(node);
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import type { HierarchyLevel, SpaceNode } from '../../types';
import type { HierarchyLevel, SpaceNode } from '../../api';

// Color palette for hierarchy levels (distinct, visually appealing colors)
const COLOR_PALETTE = [
Expand Down Expand Up @@ -31,7 +31,7 @@ const STATUS_ICONS: Record<string, string> = {
* @param type - The node's type string
* @param hierarchyLevels - Hierarchy levels from metadata
*/
export function getCardColor(type: string, hierarchyLevels: HierarchyLevel[]): string {
export function getCardColor(type: string, hierarchyLevels: readonly HierarchyLevel[]): string {
// Find the level index for this type
const levelIndex = hierarchyLevels.findIndex((level) => level.type === type);
if (levelIndex >= 0) {
Expand Down
Loading