Adding a Platform
Adding a fourth platform (e.g. Cursor, Gemini Code) is mostly a registry entry plus a template tree. The command bodies in init.rs / unload.rs / load.rs / remove.rs / upgrade.rs iterate the same &[&Platform] slice; no command-body refactoring required.
The Platform struct
Defined in crates/ark-core/src/platforms.rs:
#![allow(unused)] fn main() { pub struct Platform { pub id: &'static str, // canonical id, e.g. "cursor" pub cli_flag: &'static str, // the --<flag> name, e.g. "cursor" pub templates: &'static include_dir::Dir<'static>, pub dest_dir: &'static str, // e.g. ".cursor/commands/ark" pub removal_root: &'static str, // e.g. ".cursor" pub managed_block_target: Option<&'static str>, // e.g. Some("CURSOR.md") or None pub hook_file: Option<HookFileSpec>, // None if the platform has no hook contract pub extra_files: &'static [(&'static str, &'static str)], // (relative_path, contents) } }
The template tree at templates/cursor/... is captured at compile time via include_dir!. The dest_dir and removal_root should be cousin paths: dest_dir is where templates are extracted (a leaf), removal_root is where ark remove stops (a parent — usually the platform's top-level dir).
Steps
-
Add a template tree. Create
templates/cursor/commands/ark/{quick,design,archive,record}.md. Each one carries Cursor's frontmatter shape (whatever that platform expects) and a body translated from the Claude originals. -
Add an
include_dir!constant. Incrates/ark-core/src/templates.rs, addpub static CURSOR_TEMPLATES: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../templates/cursor/commands");. -
Add a
Platformconst. Incrates/ark-core/src/platforms.rs:#![allow(unused)] fn main() { pub const CURSOR_PLATFORM: Platform = Platform { id: "cursor", cli_flag: "cursor", templates: &crate::templates::CURSOR_TEMPLATES, dest_dir: ".cursor/commands/ark", removal_root: ".cursor", managed_block_target: Some(".cursor/RULES.md"), // or whatever Cursor reads hook_file: None, // or Some(HookFileSpec { ... }) if Cursor has hooks extra_files: &[], }; } -
Register it. Add
&CURSOR_PLATFORMto thePLATFORMSslice. Order matters — the canonical iteration order is preserved acrossinit/upgrade/unload/load/remove. New platforms append. -
Add CLI flags. In
crates/ark-cli/src/main.rs'sInitArgs, add--cursor/--no-cursorflags. Update theflags()mapping to route"cursor"to thePlatformFlagstate. -
Update parity tests. If
templates/cursor/commands/ark/<name>.mdshould track Claude commands 1:1, add a parity test incrates/ark-core/src/templates.rsmatching the existingevery_claude_command_has_a_codex_skill_siblingshape.
Hook file specs
If your platform has a hook contract (a JSON / YAML file Ark needs to install entries into):
#![allow(unused)] fn main() { pub const CURSOR_HOOK_SPEC: HookFileSpec = HookFileSpec { path: ".cursor/hooks.json", hooks_array_key: "SessionStart", // the array under root.hooks that carries entries identity_key: "command", // the field on each entry that identifies it entry_builder: ark_cursor_hook_entry, // fn() -> serde_json::Value }; }
update_hook_file and remove_hook_file are parameterized over (path, hooks_array_key, identity_key); they handle the JSON surgery without you touching serde_json directly.
The hook file is not hash-tracked — it's re-applied unconditionally on every ark init / ark load / ark upgrade, like Claude's settings.json and Codex's hooks.json.
Managed-block file
If your platform reads a markdown file that Ark needs to inject content into (parallel to Claude's CLAUDE.md and Codex/OpenCode's AGENTS.md):
#![allow(unused)] fn main() { managed_block_target: Some(".cursor/RULES.md"), }
init writes a <!-- ARK --> managed block to that file (or merges into existing content). unload captures the block; load restores it; remove deletes it surgically (delimiters and all, surrounding content preserved).
Multiple platforms can share a managed-block target. The manifest dedupes on (file, marker), so e.g. Codex and OpenCode both writing to AGENTS.md results in one block on disk and one entry in the manifest.
Extra files
Files that need to ship but don't fit the template-tree shape (e.g. OpenCode's TypeScript plugin):
#![allow(unused)] fn main() { extra_files: &[(".cursor/plugins/ark.ts", CURSOR_ARK_PLUGIN_TS)], }
Each tuple is (relative_path, contents). The contents are baked into the binary at compile time. init writes them; upgrade re-applies them unconditionally (not hash-tracked).
Testing
The source-scan invariant tests in commands/init.rs, commands/upgrade.rs, etc. enforce that:
- All filesystem ops route through
LayoutandPathExt. - No bare
std::fs::*calls. - No hand-joined
.cursor/(or.ark/, etc.) literal paths in command bodies.
If you add a new platform, you probably don't need to touch these tests — they scan command bodies, not platform definitions.
Add unit tests for your platform's shape (shape, apply_managed_state round-trip, capture_hook / remove_hook semantics) in platforms.rs's test module. Pattern-match the existing opencode_* tests.
What you don't have to touch
- The command bodies in
init.rs,unload.rs, etc. iteratePLATFORMSand call methods on each&Platform. They never name a platform directly. ark contextand the slash-command flow are platform-neutral once your hook is installed.- The book's Platforms section gets a new chapter (per platform), but no other docs need surgery.
The whole point of the registry is that adding a platform is a registry entry, not a refactor.