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
91async 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 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 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
264pub 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 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 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 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 write_skills(dir.path(), false).unwrap();
403 assert_eq!(std::fs::read_to_string(&dest).unwrap(), SKILL_VALIDATE_CI);
404 }
405}