Skip to main content

harmont_cli/commands/
init.rs

1use std::io::IsTerminal;
2use std::path::Path;
3
4use anyhow::{Context, Result, bail};
5use hm_dsl_engine::detect;
6
7use crate::cli::init::{InitArgs, TemplateKind};
8
9const SKILL_VALIDATE_CI: &str = include_str!("init_templates/skill_validate_ci.md");
10const SKILL_WRITE_PIPELINE: &str = include_str!("init_templates/skill_write_pipeline.md");
11const SKILL_CONVERT_GHA: &str = include_str!("init_templates/skill_convert_gha.md");
12
13struct Template {
14    label: &'static str,
15    filename: &'static str,
16    content: &'static str,
17}
18
19impl TemplateKind {
20    const fn meta(self) -> Template {
21        match self {
22            Self::Cmake => Template {
23                label: "CMake",
24                filename: "pipeline.py",
25                content: include_str!("init_templates/cmake.py"),
26            },
27            Self::Elixir => Template {
28                label: "Elixir",
29                filename: "pipeline.py",
30                content: include_str!("init_templates/elixir.py"),
31            },
32            Self::Nextjs => Template {
33                label: "Next.js",
34                filename: "pipeline.ts",
35                content: include_str!("init_templates/nextjs.ts"),
36            },
37            Self::Js => Template {
38                label: "JavaScript / TypeScript",
39                filename: "pipeline.ts",
40                content: include_str!("init_templates/js.ts"),
41            },
42            Self::Rust => Template {
43                label: "Rust",
44                filename: "pipeline.py",
45                content: include_str!("init_templates/rust.py"),
46            },
47            Self::Zig => Template {
48                label: "Zig",
49                filename: "pipeline.ts",
50                content: include_str!("init_templates/zig.ts"),
51            },
52            Self::Python => Template {
53                label: "Python",
54                filename: "pipeline.py",
55                content: include_str!("init_templates/python.py"),
56            },
57        }
58    }
59}
60
61const ALL: &[TemplateKind] = &[
62    TemplateKind::Cmake,
63    TemplateKind::Elixir,
64    TemplateKind::Nextjs,
65    TemplateKind::Js,
66    TemplateKind::Rust,
67    TemplateKind::Zig,
68    TemplateKind::Python,
69];
70
71fn pick_interactive() -> Result<TemplateKind> {
72    let labels: Vec<&str> = ALL.iter().map(|k| k.meta().label).collect();
73    let i = dialoguer::Select::new()
74        .with_prompt("Select a project template")
75        .items(&labels)
76        .default(0)
77        .interact()
78        .context("template selection cancelled")?;
79    Ok(ALL[i])
80}
81
82fn prompt_skills() -> Result<bool> {
83    let install = dialoguer::Confirm::new()
84        .with_prompt("Install Claude Code skills for hm?")
85        .default(true)
86        .interact()
87        .context("skills prompt cancelled")?;
88    Ok(install)
89}
90
91/// Prompt the user to link this repo to a Harmont Cloud organization.
92///
93/// Flow:
94/// - If not logged in → offer to log in first (Confirm, default no).
95/// - If logged in (or just logged in) → fetch orgs → Select with "No, skip" as first item.
96/// - On org selection → write a sparse `.hm/config.toml` with `backend = "cloud"` and the org slug.
97///
98/// Silently returns `Ok(())` on any user-cancellation (Esc, Ctrl-C on a prompt).
99async fn prompt_cloud_registration(dir: &std::path::Path) -> Result<()> {
100    let cfg = hm_config::Config::load(None).unwrap_or_default();
101    let api_url = &cfg.cloud.api_url;
102    let is_logged_in = hm_config::creds::cloud_token(api_url).is_some();
103
104    if !is_logged_in {
105        let want_login = dialoguer::Confirm::new()
106            .with_prompt("You are not logged in to Harmont Cloud. Log in now?")
107            .default(false)
108            .interact()
109            .unwrap_or(false);
110
111        if !want_login {
112            return Ok(());
113        }
114
115        hm_plugin_cloud::login_interactive().await?;
116    }
117
118    let (client, _ctx) = hm_plugin_cloud::settings::client()
119        .context("could not build authenticated cloud client")?;
120
121    let orgs = client
122        .raw()
123        .list_organizations(None, None)
124        .await
125        .map_err(hm_plugin_cloud::settings::map_raw)
126        .context("fetching organizations")?
127        .into_inner();
128
129    if orgs.data.is_empty() {
130        tracing::warn!("no organizations found — create one at https://app.harmont.dev");
131        return Ok(());
132    }
133
134    let mut items: Vec<String> = vec!["No, skip".to_string()];
135    items.extend(orgs.data.iter().map(|o| format!("{} ({})", o.name, o.slug)));
136
137    let selection = dialoguer::Select::new()
138        .with_prompt("Link this repo to Harmont Cloud?")
139        .items(&items)
140        .default(0)
141        .interact()
142        .unwrap_or(0);
143
144    if selection == 0 {
145        return Ok(());
146    }
147
148    let chosen = &orgs.data[selection - 1];
149    write_cloud_project_config(dir, &chosen.slug)?;
150    tracing::info!(
151        "linked to {} ({}) — `hm run` will now use Harmont Cloud by default",
152        chosen.name,
153        chosen.slug,
154    );
155    Ok(())
156}
157
158fn write_cloud_project_config(dir: &std::path::Path, org_slug: &str) -> Result<()> {
159    let config_path = dir.join(".hm/config.toml");
160    let content = format!(
161        "backend = \"cloud\"\n\
162         \n\
163         [cloud]\n\
164         org = \"{org_slug}\"\n"
165    );
166    std::fs::write(&config_path, &content)
167        .with_context(|| format!("writing {}", config_path.display()))?;
168    Ok(())
169}
170
171fn write_template(dir: &Path, tmpl: &Template, force: bool) -> Result<bool> {
172    let harmont_dir = dir.join(".hm");
173    let already_has_pipeline = detect::has_pipeline_files(dir);
174
175    if harmont_dir.exists() && already_has_pipeline && !force {
176        tracing::warn!(
177            "pipeline already exists in {}/.hm/ — skipping template\n  \
178             hint: use --force to overwrite",
179            dir.display()
180        );
181        return Ok(false);
182    }
183
184    // `--force` overwrites only the single target template file. We never
185    // wipe the whole `.hm/` directory: that would also delete config.toml,
186    // .gitignore, and any co-resident pipeline (e.g. a repo with both
187    // pipeline.py and deploy.py). `std::fs::write` clobbers just the target.
188    std::fs::create_dir_all(&harmont_dir)
189        .with_context(|| format!("creating {}", harmont_dir.display()))?;
190    let dest = harmont_dir.join(tmpl.filename);
191    std::fs::write(&dest, tmpl.content).with_context(|| format!("writing {}", dest.display()))?;
192    ensure_gitignore_entry(&harmont_dir, "node_modules/")?;
193    ensure_gitignore_entry(&harmont_dir, "__pycache__/")?;
194    Ok(true)
195}
196
197fn write_skills(dir: &Path, force: bool) -> Result<()> {
198    let skills: &[(&str, &str)] = &[
199        ("validate-ci", SKILL_VALIDATE_CI),
200        ("write-pipeline", SKILL_WRITE_PIPELINE),
201        ("convert-gha", SKILL_CONVERT_GHA),
202    ];
203    for (slug, content) in skills {
204        let skill_dir = dir.join(format!(".claude/skills/{slug}"));
205        let dest = skill_dir.join("SKILL.md");
206
207        // Never silently clobber a customized skill. If the file is already
208        // present and the user edited it, leave it alone unless --force is set.
209        if dest.exists() && !force {
210            let existing = std::fs::read_to_string(&dest)
211                .with_context(|| format!("reading {}", dest.display()))?;
212            if existing == *content {
213                continue;
214            }
215            tracing::warn!(
216                "skill .claude/skills/{slug}/SKILL.md already exists with local edits — skipping\n  \
217                 hint: pass --force to overwrite it with the bundled version"
218            );
219            continue;
220        }
221
222        let updated = dest.exists();
223        std::fs::create_dir_all(&skill_dir)
224            .with_context(|| format!("creating {}", skill_dir.display()))?;
225        std::fs::write(&dest, content).with_context(|| format!("writing {}", dest.display()))?;
226        if updated {
227            tracing::info!("overwrote Claude Code skill: .claude/skills/{slug}/SKILL.md");
228        } else {
229            tracing::info!("installed Claude Code skill: .claude/skills/{slug}/SKILL.md");
230        }
231    }
232    Ok(())
233}
234
235fn ensure_gitignore_entry(dir: &Path, entry: &str) -> Result<()> {
236    let gitignore = dir.join(".gitignore");
237    if gitignore.exists() {
238        let content = std::fs::read_to_string(&gitignore)
239            .with_context(|| format!("reading {}", gitignore.display()))?;
240        if content.lines().any(|l| l.trim() == entry) {
241            return Ok(());
242        }
243        let sep = if content.ends_with('\n') { "" } else { "\n" };
244        std::fs::write(&gitignore, format!("{content}{sep}{entry}\n"))
245            .with_context(|| format!("updating {}", gitignore.display()))?;
246    } else {
247        std::fs::write(&gitignore, format!("{entry}\n"))
248            .with_context(|| format!("creating {}", gitignore.display()))?;
249    }
250    Ok(())
251}
252
253fn has_github_workflows(dir: &Path) -> bool {
254    let workflows = dir.join(".github/workflows");
255    workflows.is_dir()
256        && std::fs::read_dir(&workflows).is_ok_and(|entries| {
257            entries.filter_map(Result::ok).any(|e| {
258                let p = e.path();
259                matches!(p.extension().and_then(|x| x.to_str()), Some("yml" | "yaml"))
260            })
261        })
262}
263
264/// # Errors
265///
266/// Returns an error if the target directory is unwritable, or if no template
267/// can be determined in a non-interactive context.
268pub async fn handle(args: InitArgs) -> Result<()> {
269    let tty = std::io::stdin().is_terminal();
270    let has_pipeline = detect::has_pipeline_files(&args.dir);
271
272    // Skip template selection entirely when a pipeline already exists and the
273    // user didn't force an overwrite: they're re-running `hm init` to install
274    // Claude skills, not to replace their pipeline.
275    let skip_template = args.template.is_none() && has_pipeline && !args.force;
276
277    if skip_template {
278        tracing::info!("existing pipeline detected in .hm/ — skipping template selection");
279    } else {
280        let kind = if let Some(k) = args.template {
281            k
282        } else {
283            if !tty {
284                bail!(
285                    "no template specified and no terminal available\n  \
286                     hint: pass --template <name> in non-interactive contexts"
287                );
288            }
289            pick_interactive()?
290        };
291        let tmpl = kind.meta();
292        let wrote_pipeline = write_template(&args.dir, &tmpl, args.force)?;
293        if wrote_pipeline {
294            let dsl = match kind {
295                TemplateKind::Nextjs | TemplateKind::Js | TemplateKind::Zig => "TypeScript",
296                _ => "Python",
297            };
298            tracing::info!(
299                "created .hm/{} ({dsl} pipeline, template: {kind:?})",
300                tmpl.filename
301            );
302        }
303    }
304
305    if tty && let Err(e) = prompt_cloud_registration(&args.dir).await {
306        tracing::warn!("cloud registration skipped: {e:#}");
307    }
308
309    if has_github_workflows(&args.dir) {
310        tracing::info!(
311            "detected GitHub Actions workflows in .github/workflows/\n  \
312             hint: use the `convert-gha` Claude Code skill to migrate them to Harmont"
313        );
314    }
315
316    // Skills are offered whenever a terminal is present, independent of
317    // whether a template flag was passed.
318    if tty && prompt_skills()? {
319        write_skills(&args.dir, args.force)?;
320    }
321
322    let project_config = hm_config::Config::project_config_path(&args.dir);
323    if project_config.exists() {
324        let cfg =
325            hm_config::Config::load_from_paths(None, Some(&project_config)).unwrap_or_default();
326        match cfg.backend {
327            hm_config::Backend::Cloud => {
328                tracing::info!("next step: run `hm run` to execute your pipeline on Harmont Cloud");
329            }
330            hm_config::Backend::Docker => {
331                tracing::info!("next step: run `hm run` to execute your pipeline locally");
332            }
333        }
334    } else {
335        tracing::info!("next step: run `hm run` to execute your pipeline locally");
336    }
337    Ok(())
338}
339
340#[cfg(test)]
341mod tests {
342    #![allow(clippy::unwrap_used)]
343
344    use super::*;
345
346    fn skill_path(dir: &Path, slug: &str) -> std::path::PathBuf {
347        dir.join(format!(".claude/skills/{slug}/SKILL.md"))
348    }
349
350    #[test]
351    fn write_skills_installs_when_absent() {
352        let dir = tempfile::tempdir().unwrap();
353        write_skills(dir.path(), false).unwrap();
354
355        let dest = skill_path(dir.path(), "validate-ci");
356        assert!(dest.exists());
357        assert_eq!(std::fs::read_to_string(&dest).unwrap(), SKILL_VALIDATE_CI);
358    }
359
360    #[test]
361    fn write_skills_preserves_customized_file_without_force() {
362        let dir = tempfile::tempdir().unwrap();
363        let dest = skill_path(dir.path(), "validate-ci");
364        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
365        std::fs::write(&dest, "# my local edits").unwrap();
366
367        write_skills(dir.path(), false).unwrap();
368
369        assert_eq!(
370            std::fs::read_to_string(&dest).unwrap(),
371            "# my local edits",
372            "a customized skill must not be clobbered without --force"
373        );
374        // Other skills, which were absent, are still installed.
375        assert!(skill_path(dir.path(), "write-pipeline").exists());
376    }
377
378    #[test]
379    fn write_skills_force_overwrites_customized_file() {
380        let dir = tempfile::tempdir().unwrap();
381        let dest = skill_path(dir.path(), "validate-ci");
382        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
383        std::fs::write(&dest, "# my local edits").unwrap();
384
385        write_skills(dir.path(), true).unwrap();
386
387        assert_eq!(
388            std::fs::read_to_string(&dest).unwrap(),
389            SKILL_VALIDATE_CI,
390            "--force must overwrite a customized skill with the bundled version"
391        );
392    }
393
394    #[test]
395    fn write_skills_skips_unchanged_file_idempotently() {
396        let dir = tempfile::tempdir().unwrap();
397        let dest = skill_path(dir.path(), "validate-ci");
398        std::fs::create_dir_all(dest.parent().unwrap()).unwrap();
399        std::fs::write(&dest, SKILL_VALIDATE_CI).unwrap();
400
401        // Re-running with an identical, bundled file is a silent no-op.
402        write_skills(dir.path(), false).unwrap();
403        assert_eq!(std::fs::read_to_string(&dest).unwrap(), SKILL_VALIDATE_CI);
404    }
405}