Skip to main content

dstack/
cmd_init.rs

1//! Plugin scaffolding codegen — generates platform-specific config files
2//! for Claude Code, Cursor, Pawan, Codex, OpenCode, and Gemini CLI.
3
4use std::fs;
5use std::path::Path;
6
7/// Supported platforms for plugin scaffolding
8pub const PLATFORMS: &[&str] = &[
9    "claude-code",
10    "cursor",
11    "pawan",
12    "codex",
13    "opencode",
14    "gemini",
15];
16
17/// Initialize a dstack plugin in the given directory with all platform configs.
18pub fn init_plugin(dir: &str, name: &str, description: &str, author: &str) -> anyhow::Result<()> {
19    let base = Path::new(dir);
20    fs::create_dir_all(base)?;
21
22    eprintln!("Initializing dstack plugin '{}' in {}", name, dir);
23
24    // Core files
25    write_package_json(base, name, description, author)?;
26    write_claude_md(base, name)?;
27    write_agents_md(base, name)?;
28    write_changelog(base, name)?;
29
30    // Platform manifests
31    write_claude_plugin(base, name, description, author)?;
32    write_cursor_plugin(base, name, description, author)?;
33    write_pawan_plugin(base, name, description)?;
34    write_gemini_extension(base, name, description)?;
35    write_gemini_md(base)?;
36
37    // Install docs
38    write_codex_install(base, name)?;
39    write_opencode_install(base, name)?;
40    write_pawan_install(base, name)?;
41    write_opencode_shim(base, name)?;
42
43    // Hooks
44    write_hooks(base)?;
45
46    // Skeleton skill
47    write_skeleton_skill(base, name)?;
48
49    eprintln!("Created plugin scaffold with {} platform configs", PLATFORMS.len());
50    eprintln!("\nGenerated:");
51    eprintln!("  .claude-plugin/plugin.json    (Claude Code)");
52    eprintln!("  .cursor-plugin/plugin.json    (Cursor)");
53    eprintln!("  .pawan/plugin.toml            (Pawan)");
54    eprintln!("  .codex/INSTALL.md             (Codex)");
55    eprintln!("  .opencode/INSTALL.md          (OpenCode)");
56    eprintln!("  gemini-extension.json         (Gemini CLI)");
57    eprintln!("  hooks/hooks.json              (Claude Code hooks)");
58    eprintln!("  hooks/hooks-cursor.json       (Cursor hooks)");
59    eprintln!("  hooks/session-start           (session bootstrap)");
60    eprintln!("  skills/using-{}/SKILL.md      (starter skill)", name);
61
62    Ok(())
63}
64
65/// List what would be generated without writing.
66pub fn init_dry_run(dir: &str, name: &str) {
67    eprintln!("Would generate dstack plugin '{}' in {}:", name, dir);
68    let files = [
69        "package.json",
70        "CLAUDE.md",
71        "AGENTS.md",
72        "GEMINI.md",
73        "CHANGELOG.md",
74        ".claude-plugin/plugin.json",
75        ".cursor-plugin/plugin.json",
76        ".pawan/plugin.toml",
77        ".pawan/INSTALL.md",
78        ".codex/INSTALL.md",
79        ".opencode/INSTALL.md",
80        ".opencode/plugins/{name}.js",
81        "gemini-extension.json",
82        "hooks/hooks.json",
83        "hooks/hooks-cursor.json",
84        "hooks/session-start",
85        "skills/using-{name}/SKILL.md",
86    ];
87    for f in &files {
88        eprintln!("  {}", f.replace("{name}", name));
89    }
90}
91
92// === File generators ===
93
94fn write_package_json(base: &Path, name: &str, desc: &str, author: &str) -> anyhow::Result<()> {
95    let content = format!(
96        r#"{{
97  "name": "{}",
98  "version": "0.1.0",
99  "description": "{}",
100  "author": "{}",
101  "license": "MIT",
102  "homepage": "https://github.com/{}/{}",
103  "repository": {{
104    "type": "git",
105    "url": "https://github.com/{}/{}"
106  }},
107  "keywords": ["dstack-plugin"]
108}}
109"#,
110        name, desc, author, author, name, author, name
111    );
112    fs::write(base.join("package.json"), content)?;
113    Ok(())
114}
115
116fn write_claude_plugin(base: &Path, name: &str, desc: &str, author: &str) -> anyhow::Result<()> {
117    let dir = base.join(".claude-plugin");
118    fs::create_dir_all(&dir)?;
119    let content = format!(
120        r#"{{
121  "name": "{}",
122  "description": "{}",
123  "version": "0.1.0",
124  "author": {{
125    "name": "{}"
126  }},
127  "license": "MIT",
128  "keywords": ["dstack-plugin"]
129}}
130"#,
131        name, desc, author
132    );
133    fs::write(dir.join("plugin.json"), content)?;
134    Ok(())
135}
136
137fn write_cursor_plugin(base: &Path, name: &str, desc: &str, author: &str) -> anyhow::Result<()> {
138    let dir = base.join(".cursor-plugin");
139    fs::create_dir_all(&dir)?;
140    let content = format!(
141        r#"{{
142  "name": "{}",
143  "displayName": "{}",
144  "description": "{}",
145  "version": "0.1.0",
146  "author": {{
147    "name": "{}"
148  }},
149  "license": "MIT",
150  "skills": "./skills/",
151  "commands": "./commands/",
152  "hooks": "./hooks/hooks-cursor.json"
153}}
154"#,
155        name, name, desc, author
156    );
157    fs::write(dir.join("plugin.json"), content)?;
158    Ok(())
159}
160
161fn write_pawan_plugin(base: &Path, name: &str, desc: &str) -> anyhow::Result<()> {
162    let dir = base.join(".pawan");
163    fs::create_dir_all(&dir)?;
164    let content = format!(
165        r#"# {} plugin configuration for Pawan coding agent
166
167[plugin]
168name = "{}"
169version = "0.1.0"
170description = "{}"
171
172[skills]
173path = "../skills"
174
175[commands]
176path = "../commands"
177
178[hooks]
179session_start = "../hooks/session-start"
180"#,
181        name, name, desc
182    );
183    fs::write(dir.join("plugin.toml"), content)?;
184    Ok(())
185}
186
187fn write_gemini_extension(base: &Path, name: &str, desc: &str) -> anyhow::Result<()> {
188    let content = format!(
189        r#"{{
190  "name": "{}",
191  "description": "{}",
192  "version": "0.1.0",
193  "contextFileName": "GEMINI.md"
194}}
195"#,
196        name, desc
197    );
198    fs::write(base.join("gemini-extension.json"), content)?;
199    Ok(())
200}
201
202fn write_gemini_md(base: &Path) -> anyhow::Result<()> {
203    fs::write(
204        base.join("GEMINI.md"),
205        "@./skills/using-dstack/SKILL.md\n",
206    )?;
207    Ok(())
208}
209
210fn write_claude_md(base: &Path, name: &str) -> anyhow::Result<()> {
211    let content = format!(
212        "# {} Plugin\n\nThis plugin provides skills and hooks for AI-assisted development.\n\n## Available Skills\n\nRun `/skill list` to see available skills.\n",
213        name
214    );
215    fs::write(base.join("CLAUDE.md"), content)?;
216    Ok(())
217}
218
219fn write_agents_md(base: &Path, name: &str) -> anyhow::Result<()> {
220    let content = format!(
221        "# {} Plugin\n\nThis plugin provides skills and hooks for AI-assisted development.\n\n## Available Skills\n\nRun `/skill list` to see available skills.\n",
222        name
223    );
224    fs::write(base.join("AGENTS.md"), content)?;
225    Ok(())
226}
227
228fn write_changelog(base: &Path, name: &str) -> anyhow::Result<()> {
229    let content = format!(
230        "# Changelog\n\n## v0.1.0\n\n- Initial {} plugin scaffold\n- Generated by `dstack init`\n",
231        name
232    );
233    fs::write(base.join("CHANGELOG.md"), content)?;
234    Ok(())
235}
236
237fn write_codex_install(base: &Path, name: &str) -> anyhow::Result<()> {
238    let dir = base.join(".codex");
239    fs::create_dir_all(&dir)?;
240    let content = format!(
241        r#"# Installing {} for Codex
242
243## Installation
244
2451. **Clone the repository:**
246   ```bash
247   git clone https://github.com/OWNER/{}.git ~/.codex/{}
248   ```
249
2502. **Create the skills symlink:**
251   ```bash
252   mkdir -p ~/.agents/skills
253   ln -s ~/.codex/{}/skills ~/.agents/skills/{}
254   ```
255
2563. **Restart Codex** to discover the skills.
257
258## Updating
259
260```bash
261cd ~/.codex/{} && git pull
262```
263"#,
264        name, name, name, name, name, name
265    );
266    fs::write(dir.join("INSTALL.md"), content)?;
267    Ok(())
268}
269
270fn write_opencode_install(base: &Path, name: &str) -> anyhow::Result<()> {
271    let dir = base.join(".opencode");
272    fs::create_dir_all(&dir)?;
273    let content = format!(
274        r#"# Installing {} for OpenCode
275
276## Installation
277
278Add to the `plugin` array in your `opencode.json`:
279
280```json
281{{
282  "plugin": ["{}@git+https://github.com/OWNER/{}.git"]
283}}
284```
285
286Restart OpenCode. The plugin auto-installs and registers all skills.
287"#,
288        name, name, name
289    );
290    fs::write(dir.join("INSTALL.md"), content)?;
291    Ok(())
292}
293
294fn write_pawan_install(base: &Path, name: &str) -> anyhow::Result<()> {
295    let dir = base.join(".pawan");
296    fs::create_dir_all(&dir)?;
297    let content = format!(
298        r#"# Installing {} for Pawan
299
300## Installation
301
302```bash
303mkdir -p ~/.config/pawan/plugins
304git clone https://github.com/OWNER/{}.git ~/.config/pawan/plugins/{}
305```
306
307Or symlink:
308
309```bash
310ln -s /path/to/{}/plugin ~/.config/pawan/plugins/{}
311```
312
313Restart Pawan to discover skills.
314"#,
315        name, name, name, name, name
316    );
317    fs::write(dir.join("INSTALL.md"), content)?;
318    Ok(())
319}
320
321fn write_opencode_shim(base: &Path, name: &str) -> anyhow::Result<()> {
322    let dir = base.join(".opencode").join("plugins");
323    fs::create_dir_all(&dir)?;
324    let content = format!(
325        r#"// {} plugin for OpenCode.ai
326const path = require("path");
327const fs = require("fs");
328
329module.exports = {{
330  name: "{}",
331  version: "0.1.0",
332  init(context) {{
333    const skillsDir = path.join(path.dirname(__dirname), "..", "skills");
334    if (context.registerSkillsPath) {{
335      context.registerSkillsPath("{}", skillsDir);
336    }}
337  }},
338  toolMapping: {{
339    Bash: "bash", Read: "read", Edit: "edit",
340    Write: "write", Glob: "glob", Grep: "grep",
341  }},
342}};
343"#,
344        name, name, name
345    );
346    fs::write(dir.join(format!("{}.js", name)), content)?;
347    Ok(())
348}
349
350fn write_hooks(base: &Path) -> anyhow::Result<()> {
351    let dir = base.join("hooks");
352    fs::create_dir_all(&dir)?;
353
354    // Claude Code hooks
355    let hooks_json = r#"{
356  "hooks": {
357    "SessionStart": [
358      {
359        "matcher": "startup|clear|compact",
360        "hooks": [
361          {
362            "type": "command",
363            "command": "./hooks/session-start"
364          }
365        ]
366      }
367    ]
368  }
369}
370"#;
371    fs::write(dir.join("hooks.json"), hooks_json)?;
372
373    // Cursor hooks
374    let cursor_hooks = r#"{
375  "version": 1,
376  "hooks": {
377    "sessionStart": [
378      {
379        "command": "./hooks/session-start"
380      }
381    ]
382  }
383}
384"#;
385    fs::write(dir.join("hooks-cursor.json"), cursor_hooks)?;
386
387    // Session start script
388    let session_start = r#"#!/bin/bash
389# Session bootstrap — loaded at conversation start
390echo '{"hookSpecificOutput": "dstack plugin loaded. Use /skill list to see available skills."}'
391"#;
392    fs::write(dir.join("session-start"), session_start)?;
393
394    // Make executable
395    #[cfg(unix)]
396    {
397        use std::os::unix::fs::PermissionsExt;
398        fs::set_permissions(dir.join("session-start"), fs::Permissions::from_mode(0o755))?;
399    }
400
401    Ok(())
402}
403
404fn write_skeleton_skill(base: &Path, name: &str) -> anyhow::Result<()> {
405    let skill_name = format!("using-{}", name);
406    let dir = base.join("skills").join(&skill_name);
407    fs::create_dir_all(&dir)?;
408    let content = format!(
409        r#"---
410name: using-{}
411description: Session orientation — available skills and commands
412---
413
414# Using {}
415
416This plugin provides skills for AI-assisted development.
417
418## Available Skills
419
420Use the Skill tool to invoke any skill by name.
421
422## Getting Started
423
424Start by describing what you want to accomplish. The plugin will suggest relevant skills.
425"#,
426        name, name
427    );
428    fs::write(dir.join("SKILL.md"), content)?;
429    Ok(())
430}