1use std::{
2 collections::HashSet,
3 fs,
4 io::ErrorKind,
5 path::{Path, PathBuf},
6};
7
8use anyhow::{Context, Result};
9use colored::Colorize;
10
11use crate::addons::{cache::AddonsCache, manifest::AddonManifest};
12
13pub fn install_completions(shell: &str) -> Result<()> {
15 match shell {
16 "bash" => install_bash(),
17 "zsh" => install_zsh(),
18 "fish" => install_fish(),
19 "powershell" => install_powershell(),
20 other => {
21 eprintln!("Unsupported shell '{other}'. Supported: bash, zsh, fish, powershell");
22 Ok(())
23 }
24 }
25}
26
27fn install_bash() -> Result<()> {
28 let dir = bash_completions_dir()?;
31 fs::create_dir_all(&dir)
32 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
33 let dest = dir.join("oxide");
34 fs::write(&dest, BASH_SCRIPT).with_context(|| format!("Failed to write {}", dest.display()))?;
35
36 println!(
37 "{} Bash completions installed to {}",
38 "✔".green(),
39 dest.display()
40 );
41 println!(
42 " Open a new terminal, or run: {}",
43 format!("source {}", dest.display()).cyan()
44 );
45 println!(
46 " Note: requires the {} package to be installed.",
47 "bash-completion".cyan()
48 );
49 Ok(())
50}
51
52fn bash_completions_dir() -> Result<PathBuf> {
53 let home = dirs::home_dir().context("Could not determine home directory")?;
54 Ok(home.join(".local/share/bash-completion/completions"))
55}
56
57fn install_zsh() -> Result<()> {
58 if let Some(dir) = zdotdir_completions_dir() {
62 fs::create_dir_all(&dir)
63 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
64 let dest = dir.join("oxide.zsh");
65 fs::write(&dest, ZSH_SOURCED_SCRIPT)
66 .with_context(|| format!("Failed to write {}", dest.display()))?;
67 println!(
68 "{} Zsh completions installed to {}",
69 "✔".green(),
70 dest.display()
71 );
72 println!(" Restart your terminal or open a new tab — completions are active immediately.");
73 } else {
74 let dir = home_zfunc_dir()?;
75 fs::create_dir_all(&dir)
76 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
77 let dest = dir.join("_oxide");
78 fs::write(&dest, ZSH_FPATH_SCRIPT)
79 .with_context(|| format!("Failed to write {}", dest.display()))?;
80 println!(
81 "{} Zsh completions installed to {}",
82 "✔".green(),
83 dest.display()
84 );
85 println!(" Ensure your ~/.zshrc contains:");
86 println!(" {}", "fpath=(~/.zfunc $fpath)".cyan());
87 println!(" {}", "autoload -Uz compinit && compinit".cyan());
88 }
89 Ok(())
90}
91
92fn zdotdir_completions_dir() -> Option<PathBuf> {
95 let zdotdir = std::env::var("ZDOTDIR").map(PathBuf::from).ok()?;
99 let dir = zdotdir.join("completions");
100 if !dir.is_dir() {
101 return None;
102 }
103 let is_hyde = zdotdir.join(".hyde.zshrc").exists() || which::which("hyde-cli").is_ok();
105 if is_hyde { Some(dir) } else { None }
106}
107
108fn home_zfunc_dir() -> Result<PathBuf> {
109 let home = dirs::home_dir().context("Could not determine home directory")?;
110 Ok(home.join(".zfunc"))
111}
112
113fn install_fish() -> Result<()> {
114 let dir = fish_completions_dir()?;
116 fs::create_dir_all(&dir)
117 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
118 let dest = dir.join("oxide.fish");
119 fs::write(&dest, FISH_SCRIPT).with_context(|| format!("Failed to write {}", dest.display()))?;
120
121 println!(
122 "{} Fish completions installed to {}",
123 "✔".green(),
124 dest.display()
125 );
126 println!(" Completions are active immediately in new fish sessions.");
127 Ok(())
128}
129
130fn fish_completions_dir() -> Result<PathBuf> {
131 let config_dir = std::env::var("XDG_CONFIG_HOME")
133 .map(PathBuf::from)
134 .unwrap_or_else(|_| {
135 dirs::home_dir()
136 .expect("Could not determine home directory")
137 .join(".config")
138 });
139 Ok(config_dir.join("fish/completions"))
140}
141
142fn install_powershell() -> Result<()> {
143 let script_path = powershell_script_path()?;
144 write_completion_script(&script_path, POWERSHELL_SCRIPT)?;
145
146 let profiles = powershell_profile_paths()?;
147 for profile in &profiles {
148 upsert_powershell_profile(profile, &script_path)?;
149 }
150
151 println!(
152 "{} PowerShell completions installed to {}",
153 "✔".green(),
154 script_path.display()
155 );
156 for profile in &profiles {
157 println!(" Registered in {}", profile.display());
158 }
159 println!(" Open a new PowerShell session to use completions.");
160 Ok(())
161}
162
163fn write_completion_script(path: &Path, script: &str) -> Result<()> {
164 let dir = path
165 .parent()
166 .context("Completion script path is missing a parent directory")?;
167 fs::create_dir_all(dir)
168 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
169 fs::write(path, script).with_context(|| format!("Failed to write {}", path.display()))?;
170 Ok(())
171}
172
173fn powershell_script_path() -> Result<PathBuf> {
174 let home = dirs::home_dir().context("Could not determine home directory")?;
175 Ok(home.join(".oxide").join("completions").join("oxide.ps1"))
176}
177
178fn powershell_profile_paths() -> Result<Vec<PathBuf>> {
179 let documents_dir = dirs::document_dir()
180 .or_else(|| dirs::home_dir().map(|home| home.join("Documents")))
181 .context("Could not determine documents directory")?;
182 Ok(powershell_profile_paths_in(&documents_dir))
183}
184
185fn powershell_profile_paths_in(documents_dir: &Path) -> Vec<PathBuf> {
186 vec![
187 documents_dir
188 .join("PowerShell")
189 .join("Microsoft.PowerShell_profile.ps1"),
190 documents_dir
191 .join("WindowsPowerShell")
192 .join("Microsoft.PowerShell_profile.ps1"),
193 ]
194}
195
196fn upsert_powershell_profile(profile_path: &Path, script_path: &Path) -> Result<()> {
197 let dir = profile_path
198 .parent()
199 .context("Profile path is missing a parent directory")?;
200 fs::create_dir_all(dir)
201 .with_context(|| format!("Failed to create directory {}", dir.display()))?;
202
203 let existing = match fs::read_to_string(profile_path) {
204 Ok(content) => content,
205 Err(err) if err.kind() == ErrorKind::NotFound => String::new(),
206 Err(err) => {
207 return Err(err).with_context(|| format!("Failed to read {}", profile_path.display()));
208 }
209 };
210
211 let updated = upsert_managed_block(
212 &existing,
213 &powershell_profile_snippet(script_path),
214 POWERSHELL_PROFILE_START_MARKER,
215 POWERSHELL_PROFILE_END_MARKER,
216 );
217
218 if updated != existing {
219 fs::write(profile_path, updated)
220 .with_context(|| format!("Failed to write {}", profile_path.display()))?;
221 }
222
223 Ok(())
224}
225
226fn powershell_profile_snippet(script_path: &Path) -> String {
227 let script_path = powershell_single_quote(script_path);
228 format!(
229 "{POWERSHELL_PROFILE_START_MARKER}\n\
230$oxideCompletionScript = '{script_path}'\n\
231if (Test-Path $oxideCompletionScript) {{\n\
232 . $oxideCompletionScript\n\
233}}\n\
234{POWERSHELL_PROFILE_END_MARKER}"
235 )
236}
237
238#[doc(hidden)]
239pub fn powershell_profile_paths_in_for_tests(documents_dir: &Path) -> Vec<PathBuf> {
240 powershell_profile_paths_in(documents_dir)
241}
242
243#[doc(hidden)]
244pub fn powershell_profile_snippet_for_tests(script_path: &Path) -> String {
245 powershell_profile_snippet(script_path)
246}
247
248#[doc(hidden)]
249pub fn upsert_managed_block_for_tests(
250 content: &str,
251 block: &str,
252 start_marker: &str,
253 end_marker: &str,
254) -> String {
255 upsert_managed_block(content, block, start_marker, end_marker)
256}
257
258#[doc(hidden)]
259pub fn powershell_script_for_tests() -> &'static str {
260 POWERSHELL_SCRIPT
261}
262
263fn powershell_single_quote(path: &Path) -> String {
264 path.to_string_lossy().replace('\'', "''")
265}
266
267fn upsert_managed_block(
268 content: &str,
269 block: &str,
270 start_marker: &str,
271 end_marker: &str,
272) -> String {
273 let mut content = content.replace("\r\n", "\n");
274 let block = format!("{block}\n");
275
276 if let Some(start) = content.find(start_marker)
277 && let Some(end_rel) = content[start..].find(end_marker)
278 {
279 let end_marker_end = start + end_rel + end_marker.len();
280 let block_end = content[end_marker_end..]
281 .find('\n')
282 .map(|idx| end_marker_end + idx + 1)
283 .unwrap_or(content.len());
284 content.replace_range(start..block_end, &block);
285 return content;
286 }
287
288 if !content.is_empty() && !content.ends_with('\n') {
289 content.push('\n');
290 }
291 if !content.is_empty() {
292 content.push('\n');
293 }
294 content.push_str(&block);
295 content
296}
297
298pub fn print_dynamic_completions(addons_dir: &Path, addon_id: Option<&str>) {
305 match addon_id {
306 None => print_addon_ids(addons_dir),
307 Some(id) => print_addon_commands(addons_dir, id),
308 }
309}
310
311fn print_addon_ids(addons_dir: &Path) {
312 let index = addons_dir.join("oxide-addons.json");
313 let Ok(content) = std::fs::read_to_string(&index) else {
314 return;
315 };
316 let Ok(cache) = serde_json::from_str::<AddonsCache>(&content) else {
317 return;
318 };
319 for addon in &cache.addons {
320 println!("{}", addon.id);
321 }
322}
323
324fn print_addon_commands(addons_dir: &Path, addon_id: &str) {
325 let manifest_path = addons_dir.join(addon_id).join("oxide.addon.json");
326 let Ok(content) = std::fs::read_to_string(&manifest_path) else {
327 return;
328 };
329 let Ok(manifest) = serde_json::from_str::<AddonManifest>(&content) else {
330 return;
331 };
332 let mut seen: HashSet<String> = HashSet::new();
333 for variant in &manifest.variants {
334 for cmd in &variant.commands {
335 if seen.insert(cmd.name.clone()) {
336 println!("{}", cmd.name);
337 }
338 }
339 }
340}
341
342const BASH_SCRIPT: &str = r#"# oxide shell completions for bash
345# Source this file or append it to ~/.bashrc:
346# oxide completions bash >> ~/.bashrc
347
348_oxide_completions() {
349 local cur prev
350 cur="${COMP_WORDS[COMP_CWORD]}"
351 prev="${COMP_WORDS[COMP_CWORD-1]}"
352
353 local static_top="new template addon login logout account completions"
354 local template_subs="install list remove publish update"
355 local addon_subs="install list remove publish update"
356
357 case $COMP_CWORD in
358 1)
359 local addon_ids
360 addon_ids=$(oxide _complete 2>/dev/null)
361 COMPREPLY=($(compgen -W "$static_top $addon_ids" -- "$cur"))
362 ;;
363 2)
364 case $prev in
365 template|t) COMPREPLY=($(compgen -W "$template_subs" -- "$cur")) ;;
366 addon|a) COMPREPLY=($(compgen -W "$addon_subs" -- "$cur")) ;;
367 *)
368 local addon_cmds
369 addon_cmds=$(oxide _complete "$prev" 2>/dev/null)
370 if [ -n "$addon_cmds" ]; then
371 COMPREPLY=($(compgen -W "$addon_cmds" -- "$cur"))
372 fi
373 ;;
374 esac
375 ;;
376 esac
377}
378
379complete -F _oxide_completions oxide
380"#;
381
382const ZSH_SOURCED_SCRIPT: &str = r#"# oxide shell completions for zsh
385# Auto-installed by: oxide completions zsh
386# Loaded automatically by your shell config via _load_completions().
387
388if command -v oxide &>/dev/null; then
389 _oxide() {
390 local state
391
392 _arguments \
393 '1: :->cmd' \
394 '2: :->subcmd' && return 0
395
396 case $state in
397 cmd)
398 local static_cmds=(
399 'new:Create a new project from a template (oxide n)'
400 'template:Manage templates (oxide t)'
401 'addon:Manage addons (oxide a)'
402 'login:Log in to your Oxide account (oxide in)'
403 'logout:Log out of your Oxide account (oxide out)'
404 'account:Show account information'
405 'completions:Install shell completion script'
406 )
407 local addon_ids
408 addon_ids=(${(f)"$(oxide _complete 2>/dev/null)"})
409 _describe 'command' static_cmds
410 [[ ${#addon_ids[@]} -gt 0 ]] && compadd -- "${addon_ids[@]}"
411 ;;
412 subcmd)
413 case ${words[2]} in
414 template|t)
415 local subs=(
416 'install:Download and cache a template (i)'
417 'list:List installed templates (l)'
418 'remove:Remove a template from cache (r)'
419 'publish:Publish a GitHub repository as a template (p)'
420 'update:Update a published template (u)'
421 )
422 _describe 'subcommand' subs
423 ;;
424 addon|a)
425 local subs=(
426 'install:Install and cache an addon (i)'
427 'list:List installed addons (l)'
428 'remove:Remove a cached addon (r)'
429 'publish:Publish a GitHub repository as an addon (p)'
430 'update:Update a published addon (u)'
431 )
432 _describe 'subcommand' subs
433 ;;
434 *)
435 local addon_cmds
436 addon_cmds=(${(f)"$(oxide _complete "${words[2]}" 2>/dev/null)"})
437 [[ ${#addon_cmds[@]} -gt 0 ]] && compadd -- "${addon_cmds[@]}"
438 ;;
439 esac
440 ;;
441 esac
442 }
443
444 compdef _oxide oxide
445fi
446"#;
447
448const ZSH_FPATH_SCRIPT: &str = r#"#compdef oxide
450# oxide shell completions for zsh
451# Auto-installed by: oxide completions zsh
452# Requires ~/.zfunc in your fpath and compinit called in ~/.zshrc.
453
454_oxide() {
455 local state
456
457 _arguments \
458 '1: :->cmd' \
459 '2: :->subcmd' && return 0
460
461 case $state in
462 cmd)
463 local static_cmds=(
464 'new:Create a new project from a template (oxide n)'
465 'template:Manage templates (oxide t)'
466 'addon:Manage addons (oxide a)'
467 'login:Log in to your Oxide account (oxide in)'
468 'logout:Log out of your Oxide account (oxide out)'
469 'account:Show account information'
470 'completions:Install shell completion script'
471 )
472 local addon_ids
473 addon_ids=(${(f)"$(oxide _complete 2>/dev/null)"})
474 _describe 'command' static_cmds
475 [[ ${#addon_ids[@]} -gt 0 ]] && compadd -- "${addon_ids[@]}"
476 ;;
477 subcmd)
478 case ${words[2]} in
479 template|t)
480 local subs=(
481 'install:Download and cache a template (i)'
482 'list:List installed templates (l)'
483 'remove:Remove a template from cache (r)'
484 'publish:Publish a GitHub repository as a template (p)'
485 'update:Update a published template (u)'
486 )
487 _describe 'subcommand' subs
488 ;;
489 addon|a)
490 local subs=(
491 'install:Install and cache an addon (i)'
492 'list:List installed addons (l)'
493 'remove:Remove a cached addon (r)'
494 'publish:Publish a GitHub repository as an addon (p)'
495 'update:Update a published addon (u)'
496 )
497 _describe 'subcommand' subs
498 ;;
499 *)
500 local addon_cmds
501 addon_cmds=(${(f)"$(oxide _complete "${words[2]}" 2>/dev/null)"})
502 [[ ${#addon_cmds[@]} -gt 0 ]] && compadd -- "${addon_cmds[@]}"
503 ;;
504 esac
505 ;;
506 esac
507}
508
509_oxide
510"#;
511
512const FISH_SCRIPT: &str = r#"# oxide shell completions for fish
513# Save to your fish completions directory:
514# oxide completions fish > ~/.config/fish/completions/oxide.fish
515
516# Disable file completions for oxide globally
517complete -c oxide -f
518
519# ── Top-level subcommands ──────────────────────────────────────────────────────
520complete -c oxide -n '__fish_use_subcommand' -a 'new' -d 'Create a new project from a template (oxide n)'
521complete -c oxide -n '__fish_use_subcommand' -a 'template' -d 'Manage templates (oxide t)'
522complete -c oxide -n '__fish_use_subcommand' -a 'addon' -d 'Manage addons (oxide a)'
523complete -c oxide -n '__fish_use_subcommand' -a 'login' -d 'Log in to your Oxide account (oxide in)'
524complete -c oxide -n '__fish_use_subcommand' -a 'logout' -d 'Log out of your Oxide account (oxide out)'
525complete -c oxide -n '__fish_use_subcommand' -a 'account' -d 'Show account information'
526complete -c oxide -n '__fish_use_subcommand' -a 'completions' -d 'Install shell completion script'
527
528# Dynamic addon IDs from cache (automatically updated as addons are installed)
529complete -c oxide -n '__fish_use_subcommand' -a '(oxide _complete 2>/dev/null)'
530
531# ── template subcommands ───────────────────────────────────────────────────────
532complete -c oxide -n '__fish_seen_subcommand_from template t' -a 'install' -d 'Download and cache a template (i)'
533complete -c oxide -n '__fish_seen_subcommand_from template t' -a 'list' -d 'List installed templates (l)'
534complete -c oxide -n '__fish_seen_subcommand_from template t' -a 'remove' -d 'Remove a template from cache (r)'
535complete -c oxide -n '__fish_seen_subcommand_from template t' -a 'publish' -d 'Publish a GitHub repository as a template (p)'
536complete -c oxide -n '__fish_seen_subcommand_from template t' -a 'update' -d 'Update a published template (u)'
537
538# ── addon subcommands ──────────────────────────────────────────────────────────
539complete -c oxide -n '__fish_seen_subcommand_from addon a' -a 'install' -d 'Install and cache an addon (i)'
540complete -c oxide -n '__fish_seen_subcommand_from addon a' -a 'list' -d 'List installed addons (l)'
541complete -c oxide -n '__fish_seen_subcommand_from addon a' -a 'remove' -d 'Remove a cached addon (r)'
542complete -c oxide -n '__fish_seen_subcommand_from addon a' -a 'publish' -d 'Publish a GitHub repository as an addon (p)'
543complete -c oxide -n '__fish_seen_subcommand_from addon a' -a 'update' -d 'Update a published addon (u)'
544
545# ── Dynamic addon commands ─────────────────────────────────────────────────────
546# When the second token is an installed addon ID, complete with its commands.
547# This fires automatically after every `oxide addon install <id>` — no extra steps needed.
548complete -c oxide \
549 -n 'test (count (commandline -opc)) -eq 2; and not contains -- (commandline -opc)[2] new template addon login logout account completions' \
550 -a '(oxide _complete (commandline -opc)[2] 2>/dev/null)'
551"#;
552
553const POWERSHELL_PROFILE_START_MARKER: &str = "# oxide completions start";
554const POWERSHELL_PROFILE_END_MARKER: &str = "# oxide completions end";
555
556const POWERSHELL_SCRIPT: &str = r#"# oxide shell completions for PowerShell
557
558function New-OxideCompletionResult {
559 param(
560 [string] $Value,
561 [string] $ToolTip
562 )
563
564 [System.Management.Automation.CompletionResult]::new($Value, $Value, 'ParameterValue', $ToolTip)
565}
566
567$registerOxideCompleter = @{
568 CommandName = 'oxide'
569 ScriptBlock = {
570 param($wordToComplete, $commandAst, $cursorPosition)
571
572 if ($null -eq $wordToComplete) {
573 $wordToComplete = ''
574 }
575
576 $commandName = if ($commandAst.CommandElements.Count -gt 0) {
577 $commandAst.CommandElements[0].Extent.Text
578 } else {
579 'oxide'
580 }
581
582 $tokens = @($commandAst.CommandElements | Select-Object -Skip 1 | ForEach-Object {
583 $_.Extent.Text
584 })
585
586 $topLevel = @(
587 @{ Value = 'new'; ToolTip = 'Create a new project from a template (oxide n)' }
588 @{ Value = 'template'; ToolTip = 'Manage templates (oxide t)' }
589 @{ Value = 'addon'; ToolTip = 'Manage addons (oxide a)' }
590 @{ Value = 'login'; ToolTip = 'Log in to your Oxide account (oxide in)' }
591 @{ Value = 'logout'; ToolTip = 'Log out of your Oxide account (oxide out)' }
592 @{ Value = 'account'; ToolTip = 'Show account information' }
593 @{ Value = 'completions'; ToolTip = 'Install shell completions' }
594 @{ Value = 'n'; ToolTip = 'Alias for new' }
595 @{ Value = 't'; ToolTip = 'Alias for template' }
596 @{ Value = 'a'; ToolTip = 'Alias for addon' }
597 @{ Value = 'in'; ToolTip = 'Alias for login' }
598 @{ Value = 'out'; ToolTip = 'Alias for logout' }
599 )
600
601 $templateSubcommands = @(
602 @{ Value = 'install'; ToolTip = 'Download and cache a template (i)' }
603 @{ Value = 'list'; ToolTip = 'List installed templates (l)' }
604 @{ Value = 'remove'; ToolTip = 'Remove a template from cache (r)' }
605 @{ Value = 'publish'; ToolTip = 'Publish a GitHub repository as a template (p)' }
606 @{ Value = 'update'; ToolTip = 'Update a published template (u)' }
607 )
608
609 $addonSubcommands = @(
610 @{ Value = 'install'; ToolTip = 'Install and cache an addon (i)' }
611 @{ Value = 'list'; ToolTip = 'List installed addons (l)' }
612 @{ Value = 'remove'; ToolTip = 'Remove a cached addon (r)' }
613 @{ Value = 'publish'; ToolTip = 'Publish a GitHub repository as an addon (p)' }
614 @{ Value = 'update'; ToolTip = 'Update a published addon (u)' }
615 )
616
617 $completionShells = @(
618 @{ Value = 'bash'; ToolTip = 'Install bash completions' }
619 @{ Value = 'zsh'; ToolTip = 'Install zsh completions' }
620 @{ Value = 'fish'; ToolTip = 'Install fish completions' }
621 @{ Value = 'powershell'; ToolTip = 'Install PowerShell completions' }
622 )
623
624 function Complete-OxideItems {
625 param([object[]] $Items)
626
627 foreach ($item in $Items) {
628 if ($item.Value -like "$wordToComplete*") {
629 New-OxideCompletionResult -Value $item.Value -ToolTip $item.ToolTip
630 }
631 }
632 }
633
634 if ($tokens.Count -le 1) {
635 Complete-OxideItems $topLevel
636 $addonIds = & $commandName _complete 2>$null
637 foreach ($addonId in $addonIds) {
638 if ($addonId -like "$wordToComplete*") {
639 New-OxideCompletionResult -Value $addonId -ToolTip 'Installed addon'
640 }
641 }
642 return
643 }
644
645 $first = $tokens[0]
646 if ($tokens.Count -eq 2) {
647 switch ($first) {
648 'template' { Complete-OxideItems $templateSubcommands; return }
649 't' { Complete-OxideItems $templateSubcommands; return }
650 'addon' { Complete-OxideItems $addonSubcommands; return }
651 'a' { Complete-OxideItems $addonSubcommands; return }
652 'completions' { Complete-OxideItems $completionShells; return }
653 default {
654 $addonCommands = & $commandName _complete $first 2>$null
655 foreach ($addonCommand in $addonCommands) {
656 if ($addonCommand -like "$wordToComplete*") {
657 New-OxideCompletionResult -Value $addonCommand -ToolTip 'Addon command'
658 }
659 }
660 return
661 }
662 }
663 }
664 }
665}
666
667if ((Get-Command Register-ArgumentCompleter).Parameters.ContainsKey('Native')) {
668 $registerOxideCompleter.Native = $true
669}
670
671Register-ArgumentCompleter @registerOxideCompleter
672"#;