Skip to content

Stage 0: declarative extension tier — one koine-extension.json manifest for themes, snippets, templates, commands, settings, MCP servers and presets #1937

Description

@phmatray

Problem / motivation

Persona: a Koine Studio user who wants to install a theme, a snippet pack or a starter domain someone else made — and the community member who wants to ship one.

Studio's contribution points are closed, hardcoded structures. Every one of them is a static literal a third party cannot reach:

Surface File Shape today
Settings tooling/koine-studio/src/settings/settingsSchema.ts SETTINGS_FIELDS: readonly FieldDef[] → generated SETTINGS_JSON_SCHEMA
⌘K palette src/launcher/catalog.ts CatalogEntry, Category union, static GROUPS / MODES / KIND
MCP servers src/mcp/mcp.ts MCP_CLIENTS: readonly McpClientRecipe[]
Themes src/settings/theme.ts, appearance.ts single palette (ADR 0004)
Templates templates/*/template.json already declarative and CI-validated — the one that got it right

templates/ is the proof the pattern works here: a folder of data files plus a template.json validated against templates/template.schema.json, with TemplatesValidationTests compiling every template green and validating every manifest against the schema on each dotnet test. It has three consumers already (the demo, Studio's template gallery, the website playground). What it lacks is generality — it describes exactly one kind of thing.

Nothing else in Studio can be contributed by anyone outside the repo.

Proposed solution

Generalize the templates/ pattern into one extension manifest, koine-extension.json, validated by a published JSON Schema, describing an extension that contributes one or more declarative things. Pure data — no runtime, no sandbox, no toolchain, no executable code anywhere in this stage.

{
  "$schema": "https://koine.dev/extensions/koine-extension.schema.json",
  "id": "acme-dark",
  "name": "Acme Dark",
  "version": "1.2.0",
  "apiVersion": "0.1.0",
  "description": "A dark theme tuned for long modelling sessions.",
  "author": "Acme",
  "license": "MIT",
  "capabilities": [],
  "contributes": {
    "themes":            [{ "id": "acme-dark", "label": "Acme Dark", "path": "./themes/acme-dark.json" }],
    "snippets":          [{ "language": "koine", "path": "./snippets/aggregates.json" }],
    "templates":         [{ "path": "./templates/subscriptions" }],
    "commands":          [{ "id": "acme.openDocs", "title": "Acme: Open docs", "group": "action", "runs": "koine.openUrl", "args": { "url": "https://acme.example/docs" } }],
    "settings":          [{ "key": "acme.compactMode", "type": "boolean", "default": false, "label": "Compact mode", "group": "appearance" }],
    "mcpServers":        [{ "id": "acme-mcp", "label": "Acme MCP", "command": "npx", "args": ["-y", "@acme/mcp"] }],
    "exportPresets":     [{ "id": "acme-svg", "label": "Acme SVG", "format": "svg", "options": { "scale": 2 } }],
    "severityProfiles":  [{ "id": "acme-strict", "label": "Acme strict", "rules": { "KOI1203": "error" } }]
  }
}

Each contributes key maps onto one existing Studio registry, which stops being a static literal and becomes built-ins + contributions. A commands entry deliberately cannot carry code — it names an existing command id plus arguments, which is what keeps this tier declarative.

The manifest is the single contract artifact for the whole epic: the executable extension kinds are reserved in the schema (and rejected by the validator with a clear "not yet supported" diagnostic) so that Stage 2 is additive rather than a redesign.

Alternatives considered

  • A separate manifest per extension kind (one for themes, one for snippets, …). Simpler individually, but a user installs an extension, not a theme-file, and the gallery, capability model and registry would each need N code paths. One manifest, N contribution keys, is what VS Code and Zed both converged on.
  • Reuse template.json directly by adding keys to it. Tempting, but it would conflate "a Koine example domain" with "an installable extension" — templates would gain fields meaningless to them, and the 8 in-repo templates would all need migrating. Better: koine-extension.json can contain a templates contribution that points at a folder whose template.json is unchanged.
  • TOML, like Zed's extension.toml. JSON is what this repo already validates with (template.schema.json, SETTINGS_JSON_SCHEMA, settings.json), what Studio's settings editor already syntax-highlights, and what needs no new parser in the browser build.

Area

Studio (IDE)


Related: #1936 (epic), #101 (the templates/ system this generalizes), #96 (MCP enable/disable prior art), #282 (backend-derived emit-target list)

🧠 Brainstorm

Problem / context

The epic (#1936) establishes that a declarative-first model is the right shape, because three independently-verified constraints rule out hosting untrusted code in the near term (.NET cannot host wasm components; browsers cannot run them natively and the jco workaround is AOT-only; no IDE ecosystem actually ships a hard runtime sandbox). This stage is the part of that plan that carries the product — the tier that works today, in every host, with no security surface at all.

Zed's own documentation concedes the point: "most extensions will work properly without any Rust code present. In particular, only language server, context server and debugger extensions require the presence of custom Rust." Themes, icon themes, snippets and query-only language extensions ship as extension.toml + assets and nothing else.

The critical property, and the reason this stage is worth doing first: pure data loads identically in all three Koine hosts. The Tauri webview, the browser tab and the koine CLI can each consume a JSON manifest with no host-specific runtime, no native dependency, and no per-host capability gate. Nothing else in the epic has that property.

Approaches

A — One manifest, N contribution keys. ✅ Recommended. koine-extension.json with a contributes object. Matches VS Code's contributes and Zed's extension.toml sections. One schema, one loader, one gallery entry, one capability list per extension.

B — Per-kind manifests. A theme.json, a snippets.json, etc., discovered by convention. Less upfront schema design, but the unit a user installs and toggles is an extension, not a file — so the gallery, the enable/disable state, the capability grant and the registry entry would all need to invent a grouping that (A) gets for free.

C — Extend template.json in place. Lowest new surface, but conflates two concepts and forces migration of the 8 existing templates. Rejected.

Recommendation

Approach A. The contributes-map shape is what both comparable systems converged on independently, it gives the gallery and capability model a natural unit, and it leaves room for the executable kinds to be added as further keys without touching anything built here.

Two design choices worth calling out explicitly, both taken from verified evidence:

  • commands contributions name an existing command id, not code. This is what keeps the tier declarative and is the boundary that stops "declarative" quietly becoming "we invented a scripting language."
  • Unknown contributes keys are a validation error, not ignored. Reserving the executable kinds now, and failing loudly on them, means a Stage 2 extension installed into a Stage 0 Studio gets a clear message rather than silently doing nothing.
📋 Spec

Goal

One validated extension manifest that lets a third party contribute themes, snippets, project templates, palette entries, settings, MCP servers, export presets and diagnostic severity profiles — as pure data, loading identically in the Tauri webview, the browser tab and the CLI.

Scope

  • koine-extension.schema.json — the published JSON Schema (mirrors how templates/template.schema.json is published and validated).
  • A .NET manifest model + parser/validator in Koine.Compiler, so the CLI and both Studio hosts share one implementation.
  • Opening each of the eight Studio registries from static literal to built-ins + contributions.
  • Schema-validation tests in the style of TemplatesValidationTests.

Non-goals

  • No gallery UI, no install/uninstall, no registry — Stage 3.
  • No capability enforcement — Stage 1 (this stage parses and validates the capabilities array but grants nothing, because nothing declarative needs a grant).
  • No executable contributions — reserved in the schema, rejected by the validator.

Manifest shape

Required: id (must equal the containing folder name, mirroring template.json's rule), name, version (semver), apiVersion (semver, checked against the floor in Stage 1), description, license. Optional: author, repository, icon, tags, capabilities (default []), contributes (default {}).

additionalProperties: false throughout — the same choice template.schema.json makes, and what makes reserved-but-unimplemented keys detectable.

Contribution keys

Key Target registry Notes
themes settings/theme.ts palette must satisfy the ADR 0004 Concept-Colors contract
snippets editor snippet source per-language
templates templates/ gallery points at a folder containing an unmodified template.json
commands launcher/catalog.ts runs names an existing command id; args is a JSON object
settings settings/settingsSchema.ts one FieldDef-shaped entry per key; namespaced by extension id
mcpServers mcp/mcp.ts command + args, the shape Zed uses for context servers
exportPresets export/diagramExport.ts named option bundles over existing formats
severityProfiles koine.config [diagnostics] maps KOIxxxx → severity

Validation rules

  1. id matches ^[a-z0-9][a-z0-9-]*$ and equals the folder name.
  2. version and apiVersion are valid semver.
  3. Every path resolves inside the extension folder — rejected if it escapes, even at this tier where nothing executes (see the cross-cutting path-containment issue; the rule is cheaper to enforce from day one than to retrofit).
  4. A commands[].runs value that names no known command is an error.
  5. A settings[].key not prefixed with the extension id is an error (prevents settings collisions).
  6. Unknown contributes keys are an error naming the key and stating it is reserved.
  7. Two contributions of the same kind with the same id, within one manifest, are an error.

Assumptions

  • Contributions are additive only — an extension cannot remove or override a built-in in this stage. Overrides are a Stage 3 conversation once precedence has a UI to express it.
  • Settings contributed by an extension live under its id namespace and are dropped from the generated schema when the extension is disabled.
  • The .NET side owns validation so there is exactly one implementation; Studio consumes results through the existing compiler plumbing rather than re-implementing the schema in TypeScript.

🛠️ Implementation plan

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: One validated koine-extension.json manifest contributing eight declarative kinds, loading in all three hosts.

Architecture: A JSON Schema plus a .NET manifest model/validator in Koine.Compiler (single implementation, shared by CLI and both Studio hosts). Each Studio registry changes from a static literal to built-ins + contributions. Pure data end to end.

Tech Stack: .NET 10 / C# (System.Text.Json), JSON Schema draft 2020-12, TypeScript + Preact (registry wiring), xUnit v3 + Shouldly.

Global Constraints

  • Keep Ast/ target-agnostic — the extension manifest is a tooling concept and must not leak into the semantic model.
  • Do not set TreatWarningsAsErrors.
  • New public Koine.Compiler API goes in PublicAPI.Unshipped.txt (RS0016 gate).
  • Do not hand-edit Directory.Build.props <Version>.
  • Commit identity: git -c user.email=phmatray@gmail.com -c user.name="Philippe Matray".
  • Studio front-end tests run with npx vitest run --project '!storybook' locally.
  • Single-suite .NET filter: dotnet test --filter "FullyQualifiedName~<Suite>".

Task 1: Manifest model + JSON Schema

Files:

  • Create: src/Koine.Compiler/Extensions/ExtensionManifest.cs
  • Create: extensions/koine-extension.schema.json
  • Test: tests/Koine.Compiler.Tests/ExtensionManifestTests.cs

Interfaces:

  • Produces: public sealed record ExtensionManifest(string Id, string Name, string Version, string ApiVersion, string Description, string License, string? Author, string? Repository, string? Icon, IReadOnlyList<string> Tags, IReadOnlyList<ExtensionCapability> Capabilities, ExtensionContributions Contributes); public sealed record ExtensionContributions(IReadOnlyList<ThemeContribution> Themes, IReadOnlyList<SnippetContribution> Snippets, IReadOnlyList<TemplateContribution> Templates, IReadOnlyList<CommandContribution> Commands, IReadOnlyList<SettingContribution> Settings, IReadOnlyList<McpServerContribution> McpServers, IReadOnlyList<ExportPresetContribution> ExportPresets, IReadOnlyList<SeverityProfileContribution> SeverityProfiles).

  • Step 1: Write the failing test in ExtensionManifestTests.cs — deserialize a minimal manifest (id/name/version/apiVersion/description/license) and assert Contributes defaults to every-list-empty and Capabilities to empty.

[Fact]
public void MinimalManifestDeserializesWithEmptyContributions()
{
    const string json = """
    {"id":"acme-dark","name":"Acme Dark","version":"1.0.0","apiVersion":"0.1.0",
     "description":"d","license":"MIT"}
    """;

    ExtensionManifest m = ExtensionManifest.Parse(json);

    m.Id.ShouldBe("acme-dark");
    m.Capabilities.ShouldBeEmpty();
    m.Contributes.Themes.ShouldBeEmpty();
    m.Contributes.Commands.ShouldBeEmpty();
}
  • Step 2: Run dotnet test --filter "FullyQualifiedName~ExtensionManifestTests" → FAIL (ExtensionManifest not found).
  • Step 3: Implement ExtensionManifest + ExtensionContributions + the eight contribution records, with Parse(string json) over System.Text.Json and [JsonPropertyName] camelCase mapping. Default every collection to Array.Empty<T>().
  • Step 4: Author extensions/koine-extension.schema.json — draft 2020-12, additionalProperties: false at every level, required = the six fields above, mirroring the structure and prose style of templates/template.schema.json.
  • Step 5: Add the new public types to src/Koine.Compiler/PublicAPI.Unshipped.txt.
  • Step 6: Run the filter → PASS.
  • Step 7: Commit: feat(cli): add the koine-extension.json manifest model and JSON Schema

Task 2: Manifest validator with the seven rules

Files:

  • Create: src/Koine.Compiler/Extensions/ExtensionManifestValidator.cs
  • Test: tests/Koine.Compiler.Tests/ExtensionManifestValidatorTests.cs

Interfaces:

  • Consumes: ExtensionManifest from Task 1.

  • Produces: public static IReadOnlyList<Diagnostic> Validate(ExtensionManifest manifest, string extensionFolder, IReadOnlySet<string> knownCommandIds).

  • Step 1: Write failing tests — one per rule: id/folder mismatch, bad semver, a path escaping the folder ("../../etc/passwd"), an unknown runs command id, an un-namespaced settings key, an unknown contributes key, and a duplicate contribution id.

[Fact]
public void PathEscapingTheExtensionFolderIsRejected()
{
    ExtensionManifest m = ManifestWithThemePath("../../etc/passwd");

    IReadOnlyList<Diagnostic> diags =
        ExtensionManifestValidator.Validate(m, "/ext/acme-dark", KnownCommands);

    diags.ShouldContain(d => d.Code == "KOI5003");
}
  • Step 2: Run dotnet test --filter "FullyQualifiedName~ExtensionManifestValidatorTests" → FAIL (validator not found).
  • Step 3: Implement Validate, allocating a KOI50xx diagnostic code per rule and registering each in DiagnosticCodes with a description, matching how existing codes are catalogued.
  • Step 4: Implement path containment by resolving the combined path and asserting it starts with the canonicalized extension folder — reject on escape. Do not hand-roll string prefix checks on un-normalized input.
  • Step 5: Run the filter → PASS.
  • Step 6: Commit: feat(cli): validate extension manifests with containment and namespacing rules

Task 3: Extension discovery + a koine ext validate command

Files:

  • Create: src/Koine.Compiler/Extensions/ExtensionLoader.cs
  • Modify: src/Koine.Cli/Program.cs (register the ext command branch)
  • Test: tests/Koine.Compiler.Tests/ExtensionLoaderTests.cs

Interfaces:

  • Consumes: ExtensionManifest, ExtensionManifestValidator.

  • Produces: public static IReadOnlyList<LoadedExtension> Discover(string extensionsRoot); public sealed record LoadedExtension(ExtensionManifest Manifest, string FolderPath, IReadOnlyList<Diagnostic> Diagnostics).

  • Step 1: Write the failing test — a temp dir with two extension folders, one valid and one whose id disagrees with its folder; assert both are returned and only the second carries diagnostics.

  • Step 2: Run dotnet test --filter "FullyQualifiedName~ExtensionLoaderTests" → FAIL.

  • Step 3: Implement Discover — enumerate immediate subfolders, read koine-extension.json, parse, validate, and return one LoadedExtension each. A malformed manifest yields a diagnostic, never an exception (mirroring EmitterLoader's never-crash-the-compile contract).

  • Step 4: Wire koine ext validate <path> in Program.cs to print diagnostics and exit non-zero when any are errors.

  • Step 5: Run the filter → PASS.

  • Step 6: Commit: feat(cli): discover extensions and add koine ext validate

Task 4: Open the Studio settings + palette registries to contributions

Files:

  • Modify: tooling/koine-studio/src/settings/settingsSchema.ts
  • Modify: tooling/koine-studio/src/launcher/catalog.ts
  • Create: tooling/koine-studio/src/extensions/contributions.ts
  • Test: tooling/koine-studio/src/extensions/contributions.test.ts

Interfaces:

  • Produces: export interface ExtensionContributionSet { settings: FieldDef[]; commands: CatalogEntry[]; themes: ThemeDef[]; snippets: SnippetDef[]; mcpServers: McpClientRecipe[]; exportPresets: ExportPreset[]; severityProfiles: SeverityProfile[] }; export function mergeContributions(builtIns: ExtensionContributionSet, contributed: ExtensionContributionSet[]): ExtensionContributionSet.

  • Step 1: Write the failing test in contributions.test.ts — assert mergeContributions appends a contributed FieldDef after built-ins, preserves built-in order, and drops contributions from a disabled extension.

it('appends contributed settings after built-ins and preserves built-in order', () => {
  const merged = mergeContributions(builtIns, [acmeContributions]);
  expect(merged.settings.slice(0, builtIns.settings.length)).toEqual(builtIns.settings);
  expect(merged.settings.at(-1)?.key).toBe('acme.compactMode');
});
  • Step 2: Run npx vitest run --project '!storybook' src/extensions/contributions.test.ts → FAIL (module not found).
  • Step 3: Implement contributions.ts with mergeContributions, additive-only and deterministic (built-ins first, then contributions in extension-id order).
  • Step 4: Change SETTINGS_FIELDS and the launcher GROUPS/catalog construction to read through mergeContributions instead of being consumed directly, keeping the exported names stable so no call site changes.
  • Step 5: Run npx vitest run --project '!storybook' → PASS (the whole suite, to catch call sites that assumed a static array).
  • Step 6: Commit: feat(studio): open the settings and palette registries to extension contributions

Task 5: Open the remaining registries + schema-validation test suite

Files:

  • Modify: tooling/koine-studio/src/mcp/mcp.ts, src/settings/theme.ts, src/export/diagramExport.ts
  • Create: tests/Koine.Compiler.Tests/ExtensionsValidationTests.cs
  • Create: extensions/examples/acme-dark/koine-extension.json (a real, valid worked example)

Interfaces:

  • Consumes: mergeContributions from Task 4; ExtensionLoader.Discover from Task 3.

  • Step 1: Write the failing test in ExtensionsValidationTests.cs — discover every extension under extensions/examples/, assert each validates against koine-extension.schema.json with zero error diagnostics. Model it on TemplatesValidationTests.

  • Step 2: Run dotnet test --filter "FullyQualifiedName~ExtensionsValidationTests" → FAIL (no example extension exists yet).

  • Step 3: Author extensions/examples/acme-dark/koine-extension.json exercising all eight contribution kinds, with the asset files it references.

  • Step 4: Route mcp.ts, theme.ts and diagramExport.ts through mergeContributions the same way Task 4 did for settings and the palette.

  • Step 5: Run dotnet test --filter "FullyQualifiedName~ExtensionsValidationTests" and npx vitest run --project '!storybook' → both PASS.

  • Step 6: Commit: feat(studio): route themes, MCP servers and export presets through extension contributions

Task 6: Documentation

Files:

  • Create: website/src/content/docs/extensions/authoring.md
  • Modify: README.md (construct/feature table — add the extension manifest)

Interfaces: none (docs only).

  • Step 1: Write authoring.md — the manifest reference, one worked example per contribution kind, the validation rules and their KOI50xx codes, and an explicit statement that the executable tier is reserved and not yet supported.
  • Step 2: Add the extension manifest to README.md's capability table, linking the docs page.
  • Step 3: Build the docs site to verify (npm run build-samples && npm run build-docs && npx astro build in website/ — the plain npm run build needs the CI-only wasm workload).
  • Step 4: Commit: docs: document authoring a declarative Koine extension

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: studioKoine Studio IDE (tooling/koine-studio)effort: LLarge — ~1-2 weeks / cross-layerenhancementNew feature or requestpriority: mediumTier 2 — expected capability, partial or absentstudio: extensionsStudio extension system: manifest, capabilities, gallery, registry

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions