feat(gui): automatically install and setup the cli/shell (#2139)

* feat(gui): automatically install and setup the cli/shell

* add shell config and toasts
This commit is contained in:
Ellie Huxtable 2024-06-17 15:36:38 +01:00 committed by GitHub
parent 39b424f3b1
commit 88633b8994
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
17 changed files with 595 additions and 53 deletions

1
Cargo.lock generated
View File

@ -300,6 +300,7 @@ dependencies = [
name = "atuin-common"
version = "18.3.0"
dependencies = [
"directories",
"eyre",
"lazy_static",
"pretty_assertions",

View File

@ -22,6 +22,7 @@ eyre = { workspace = true }
sqlx = { workspace = true }
semver = { workspace = true }
thiserror = { workspace = true }
directories = { workspace = true }
sysinfo = "0.30.7"
lazy_static = "1.4.0"

View File

@ -59,6 +59,25 @@ impl Shell {
Shell::from_string(shell.to_string())
}
pub fn config_file(&self) -> Option<std::path::PathBuf> {
let mut path = if let Some(base) = directories::BaseDirs::new() {
base.home_dir().to_owned()
} else {
return None;
};
// TODO: handle all shells
match self {
Shell::Bash => path.push(".bashrc"),
Shell::Zsh => path.push(".zshrc"),
Shell::Fish => path.push(".config/fish/config.fish"),
_ => return None,
};
Some(path)
}
/// Best-effort attempt to determine the default shell
/// This implementation will be different across different platforms
/// Caller should ensure to handle Shell::Unknown correctly

2
ui/backend/Cargo.lock generated
View File

@ -392,6 +392,7 @@ dependencies = [
name = "atuin-common"
version = "18.3.0"
dependencies = [
"directories",
"eyre",
"lazy_static",
"rand 0.8.5",
@ -6122,6 +6123,7 @@ dependencies = [
"tauri-plugin-single-instance",
"tauri-plugin-sql",
"time",
"tokio",
"uuid",
]

View File

@ -25,7 +25,8 @@ time = "0.3.36"
uuid = "1.7.0"
syntect = "5.2.0"
tauri-plugin-http = "2.0.0-beta"
tauri-plugin-single-instance = "2.0.0-beta.9"
tauri-plugin-single-instance = "2.0.0-beta"
tokio = "1.38.0"
[dependencies.sqlx]
version = "0.7"

View File

@ -19,5 +19,5 @@
"allow": ["https://api.atuin.sh/*"]
}
],
"platforms": ["linux", "macOS", "windows", "android", "iOS"]
"platforms": ["linux", "macOS", "windows"]
}

67
ui/backend/src/install.rs Normal file
View File

@ -0,0 +1,67 @@
// Handle installing the Atuin CLI
// We can use the standard install script for this
use std::process::Command;
use tokio::{
fs::{read_to_string, OpenOptions},
io::AsyncWriteExt,
};
use atuin_common::shell::Shell;
#[tauri::command]
pub(crate) async fn install_cli() -> Result<(), String> {
let output = Command::new("sh")
.arg("-c")
.arg("curl --proto '=https' --tlsv1.2 -LsSf https://github.com/atuinsh/atuin/releases/latest/download/atuin-installer.sh | sh")
.output().map_err(|e|format!("Failed to execute Atuin installer: {e}"));
Ok(())
}
#[tauri::command]
pub(crate) async fn is_cli_installed() -> Result<bool, String> {
let shell = Shell::default_shell().map_err(|e| format!("Failed to get default shell: {e}"))?;
let output = shell
.run_interactive(&["atuin --version && echo 'ATUIN FOUND'"])
.map_err(|e| format!("Failed to run interactive command"))?;
Ok(output.contains("ATUIN FOUND"))
}
#[tauri::command]
pub(crate) async fn setup_cli() -> Result<(), String> {
let shell = Shell::default_shell().map_err(|e| format!("Failed to get default shell: {e}"))?;
let config_file_path = shell.config_file();
if config_file_path.is_none() {
return Err("Failed to fetch default config file".to_string());
}
let config_file_path = config_file_path.unwrap();
let config_file = read_to_string(config_file_path.clone())
.await
.map_err(|e| format!("Failed to read config file: {e}"))?;
if config_file.contains("atuin init") {
return Ok(());
}
let mut file = OpenOptions::new()
.write(true)
.append(true)
.open(config_file_path)
.await
.unwrap();
let config = format!(
"if [ -x \"$(command -v atuin)\" ]; then eval \"$(atuin init {})\"; fi",
shell.to_string()
);
file.write_all(config.as_bytes())
.await
.map_err(|e| format!("Failed to write Atuin shell init: {e}"));
Ok(())
}

View File

@ -8,6 +8,7 @@ use time::format_description::well_known::Rfc3339;
mod db;
mod dotfiles;
mod install;
mod store;
use atuin_client::settings::Settings;
@ -189,6 +190,9 @@ fn main() {
session,
login,
register,
install::install_cli,
install::is_cli_installed,
install::setup_cli,
dotfiles::aliases::import_aliases,
dotfiles::aliases::delete_alias,
dotfiles::aliases::set_alias,

View File

@ -1,51 +0,0 @@
// While technically using a "self update" crate, we can actually use the same method
// for managing a CLI install. Neat!
// This should still be locked to the same version as the UI. Drift there could lead to issues.
// In the future we can follow semver and allow for minor version drift.
// If you'd like to follow the conventions of your OS, distro, etc, then I would suggest
// following the CLI install instructions. This is intended to streamline install UX
use eyre::{eyre, Result};
use std::{
ffi::{OsStr, OsString},
path::Path,
};
pub fn install(version: &str, path: &str) -> Result<()> {
let dir = std::path::PathBuf::from(path);
std::fs::create_dir_all(path)?;
let bin = dir.join("atuin");
let releases = self_update::backends::github::ReleaseList::configure()
.repo_owner("atuinsh")
.repo_name("atuin")
.build()?
.fetch()?;
let release = releases
.iter()
.find(|r| r.version == version)
.ok_or_else(|| eyre!("No release found for version: {}", version))?;
let asset = release
.asset_for(&self_update::get_target(), None)
.ok_or_else(|| eyre!("No asset found for target"))?;
let tmp_dir = tempfile::Builder::new().prefix("atuin").tempdir()?;
let tmp_tarball_path = tmp_dir.path().join(&asset.name);
let tmp_tarball = std::fs::File::create(&tmp_tarball_path)?;
println!("{:?}", tmp_tarball_path);
self_update::Download::from_url(&asset.download_url).download_to(&tmp_tarball)?;
let root = asset.name.replace(".tar.gz", "");
let bin_name = std::path::PathBuf::from(format!("{}/atuin", root,));
self_update::Extract::from_source(&tmp_tarball_path)
.archive(self_update::ArchiveKind::Tar(Some(
self_update::Compression::Gz,
)))
.extract_file(&bin, &bin_name)?;
Ok(())
}

View File

@ -15,6 +15,7 @@
"@radix-ui/react-dialog": "^1.0.5",
"@radix-ui/react-dropdown-menu": "^2.0.6",
"@radix-ui/react-slot": "^1.0.2",
"@radix-ui/react-toast": "^1.1.5",
"@tailwindcss/forms": "^0.5.7",
"@tanstack/react-table": "^8.17.3",
"@tanstack/react-virtual": "^3.5.1",

View File

@ -20,6 +20,9 @@ dependencies:
'@radix-ui/react-slot':
specifier: ^1.0.2
version: 1.0.2(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-toast':
specifier: ^1.1.5
version: 1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@tailwindcss/forms':
specifier: ^0.5.7
version: 0.5.7(tailwindcss@3.4.4)
@ -1111,6 +1114,38 @@ packages:
react: 18.3.1
dev: false
/@radix-ui/react-toast@1.1.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-fRLn227WHIBRSzuRzGJ8W+5YALxofH23y0MlPLddaIpLpCDqdE0NZlS2NRQDRiptfxDeeCjgFIpexB1/zkxDlw==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.7
'@radix-ui/primitive': 1.0.1
'@radix-ui/react-collection': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-context': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.3)(react@18.3.1)
'@radix-ui/react-visually-hidden': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.3)(react@18.3.1):
resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==}
peerDependencies:
@ -1199,6 +1234,27 @@ packages:
react: 18.3.1
dev: false
/@radix-ui/react-visually-hidden@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1):
resolution: {integrity: sha512-D4w41yN5YRKtu464TLnByKzMDG/JlMPHtfZgQAu9v6mNakUqGUI9vUrfQKz8NK41VMm/xbZbh76NUTVtIYqOMA==}
peerDependencies:
'@types/react': '*'
'@types/react-dom': '*'
react: ^16.8 || ^17.0 || ^18.0
react-dom: ^16.8 || ^17.0 || ^18.0
peerDependenciesMeta:
'@types/react':
optional: true
'@types/react-dom':
optional: true
dependencies:
'@babel/runtime': 7.24.7
'@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.3)(react-dom@18.3.1)(react@18.3.1)
'@types/react': 18.3.3
'@types/react-dom': 18.3.0
react: 18.3.1
react-dom: 18.3.1(react@18.3.1)
dev: false
/@radix-ui/rect@1.0.1:
resolution: {integrity: sha512-fyrgCaedtvMg9NK3en0pnOYJdtfwxUcNolezkNPUsoX57X8oQk+NkqcvzHXD2uKNij6GXmWU9NDru2IWjrO4BQ==}
dependencies:

View File

@ -4,6 +4,7 @@ import { useState, ReactElement } from "react";
import { useStore } from "@/state/store";
import Button, { ButtonStyle } from "@/components/Button";
import { Toaster } from "@/components/ui/toaster";
import { Dialog, DialogContent, DialogTrigger } from "@/components/ui/dialog";
@ -124,6 +125,7 @@ function App() {
</div>
{renderMain(section)}
<Toaster />
</div>
);
}

View File

@ -0,0 +1,59 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const alertVariants = cva(
"relative w-full rounded-lg border p-4 [&>svg~*]:pl-7 [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground",
{
variants: {
variant: {
default: "bg-background text-foreground",
destructive:
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Alert = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
>(({ className, variant, ...props }, ref) => (
<div
ref={ref}
role="alert"
className={cn(alertVariants({ variant }), className)}
{...props}
/>
))
Alert.displayName = "Alert"
const AlertTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h5
ref={ref}
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
{...props}
/>
))
AlertTitle.displayName = "AlertTitle"
const AlertDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm [&_p]:leading-relaxed", className)}
{...props}
/>
))
AlertDescription.displayName = "AlertDescription"
export { Alert, AlertTitle, AlertDescription }

View File

@ -0,0 +1,127 @@
import * as React from "react"
import * as ToastPrimitives from "@radix-ui/react-toast"
import { cva, type VariantProps } from "class-variance-authority"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const ToastProvider = ToastPrimitives.Provider
const ToastViewport = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Viewport>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Viewport
ref={ref}
className={cn(
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
className
)}
{...props}
/>
))
ToastViewport.displayName = ToastPrimitives.Viewport.displayName
const toastVariants = cva(
"group pointer-events-auto relative flex w-full items-center justify-between space-x-4 overflow-hidden rounded-md border p-6 pr-8 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
{
variants: {
variant: {
default: "border bg-background text-foreground",
destructive:
"destructive group border-destructive bg-destructive text-destructive-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
const Toast = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Root>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root> &
VariantProps<typeof toastVariants>
>(({ className, variant, ...props }, ref) => {
return (
<ToastPrimitives.Root
ref={ref}
className={cn(toastVariants({ variant }), className)}
{...props}
/>
)
})
Toast.displayName = ToastPrimitives.Root.displayName
const ToastAction = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Action>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Action
ref={ref}
className={cn(
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium ring-offset-background transition-colors hover:bg-secondary focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
className
)}
{...props}
/>
))
ToastAction.displayName = ToastPrimitives.Action.displayName
const ToastClose = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Close>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Close
ref={ref}
className={cn(
"absolute right-2 top-2 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-2 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
className
)}
toast-close=""
{...props}
>
<X className="h-4 w-4" />
</ToastPrimitives.Close>
))
ToastClose.displayName = ToastPrimitives.Close.displayName
const ToastTitle = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Title>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Title
ref={ref}
className={cn("text-sm font-semibold", className)}
{...props}
/>
))
ToastTitle.displayName = ToastPrimitives.Title.displayName
const ToastDescription = React.forwardRef<
React.ElementRef<typeof ToastPrimitives.Description>,
React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description>
>(({ className, ...props }, ref) => (
<ToastPrimitives.Description
ref={ref}
className={cn("text-sm opacity-90", className)}
{...props}
/>
))
ToastDescription.displayName = ToastPrimitives.Description.displayName
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>
type ToastActionElement = React.ReactElement<typeof ToastAction>
export {
type ToastProps,
type ToastActionElement,
ToastProvider,
ToastViewport,
Toast,
ToastTitle,
ToastDescription,
ToastClose,
ToastAction,
}

View File

@ -0,0 +1,33 @@
import {
Toast,
ToastClose,
ToastDescription,
ToastProvider,
ToastTitle,
ToastViewport,
} from "@/components/ui/toast"
import { useToast } from "@/components/ui/use-toast"
export function Toaster() {
const { toasts } = useToast()
return (
<ToastProvider>
{toasts.map(function ({ id, title, description, action, ...props }) {
return (
<Toast key={id} {...props}>
<div className="grid gap-1">
{title && <ToastTitle>{title}</ToastTitle>}
{description && (
<ToastDescription>{description}</ToastDescription>
)}
</div>
{action}
<ToastClose />
</Toast>
)
})}
<ToastViewport />
</ToastProvider>
)
}

View File

@ -0,0 +1,192 @@
// Inspired by react-hot-toast library
import * as React from "react"
import type {
ToastActionElement,
ToastProps,
} from "@/components/ui/toast"
const TOAST_LIMIT = 1
const TOAST_REMOVE_DELAY = 1000000
type ToasterToast = ToastProps & {
id: string
title?: React.ReactNode
description?: React.ReactNode
action?: ToastActionElement
}
const actionTypes = {
ADD_TOAST: "ADD_TOAST",
UPDATE_TOAST: "UPDATE_TOAST",
DISMISS_TOAST: "DISMISS_TOAST",
REMOVE_TOAST: "REMOVE_TOAST",
} as const
let count = 0
function genId() {
count = (count + 1) % Number.MAX_SAFE_INTEGER
return count.toString()
}
type ActionType = typeof actionTypes
type Action =
| {
type: ActionType["ADD_TOAST"]
toast: ToasterToast
}
| {
type: ActionType["UPDATE_TOAST"]
toast: Partial<ToasterToast>
}
| {
type: ActionType["DISMISS_TOAST"]
toastId?: ToasterToast["id"]
}
| {
type: ActionType["REMOVE_TOAST"]
toastId?: ToasterToast["id"]
}
interface State {
toasts: ToasterToast[]
}
const toastTimeouts = new Map<string, ReturnType<typeof setTimeout>>()
const addToRemoveQueue = (toastId: string) => {
if (toastTimeouts.has(toastId)) {
return
}
const timeout = setTimeout(() => {
toastTimeouts.delete(toastId)
dispatch({
type: "REMOVE_TOAST",
toastId: toastId,
})
}, TOAST_REMOVE_DELAY)
toastTimeouts.set(toastId, timeout)
}
export const reducer = (state: State, action: Action): State => {
switch (action.type) {
case "ADD_TOAST":
return {
...state,
toasts: [action.toast, ...state.toasts].slice(0, TOAST_LIMIT),
}
case "UPDATE_TOAST":
return {
...state,
toasts: state.toasts.map((t) =>
t.id === action.toast.id ? { ...t, ...action.toast } : t
),
}
case "DISMISS_TOAST": {
const { toastId } = action
// ! Side effects ! - This could be extracted into a dismissToast() action,
// but I'll keep it here for simplicity
if (toastId) {
addToRemoveQueue(toastId)
} else {
state.toasts.forEach((toast) => {
addToRemoveQueue(toast.id)
})
}
return {
...state,
toasts: state.toasts.map((t) =>
t.id === toastId || toastId === undefined
? {
...t,
open: false,
}
: t
),
}
}
case "REMOVE_TOAST":
if (action.toastId === undefined) {
return {
...state,
toasts: [],
}
}
return {
...state,
toasts: state.toasts.filter((t) => t.id !== action.toastId),
}
}
}
const listeners: Array<(state: State) => void> = []
let memoryState: State = { toasts: [] }
function dispatch(action: Action) {
memoryState = reducer(memoryState, action)
listeners.forEach((listener) => {
listener(memoryState)
})
}
type Toast = Omit<ToasterToast, "id">
function toast({ ...props }: Toast) {
const id = genId()
const update = (props: ToasterToast) =>
dispatch({
type: "UPDATE_TOAST",
toast: { ...props, id },
})
const dismiss = () => dispatch({ type: "DISMISS_TOAST", toastId: id })
dispatch({
type: "ADD_TOAST",
toast: {
...props,
id,
open: true,
onOpenChange: (open) => {
if (!open) dismiss()
},
},
})
return {
id: id,
dismiss,
update,
}
}
function useToast() {
const [state, setState] = React.useState<State>(memoryState)
React.useEffect(() => {
listeners.push(setState)
return () => {
const index = listeners.indexOf(setState)
if (index > -1) {
listeners.splice(index, 1)
}
}
}, [state])
return {
...state,
toast,
dismiss: (toastId?: string) => dispatch({ type: "DISMISS_TOAST", toastId }),
}
}
export { useToast, toast }

View File

@ -2,6 +2,8 @@ import { useEffect } from "react";
import { formatRelative } from "date-fns";
import { useStore } from "@/state/store";
import { useToast } from "@/components/ui/use-toast";
import { invoke } from "@tauri-apps/api/core";
function Stats({ stats }: any) {
return (
@ -47,10 +49,36 @@ export default function Home() {
const user = useStore((state) => state.user);
const refreshHomeInfo = useStore((state) => state.refreshHomeInfo);
const refreshUser = useStore((state) => state.refreshUser);
const { toast } = useToast();
useEffect(() => {
refreshHomeInfo();
refreshUser();
let setup = async () => {
let installed = await invoke("is_cli_installed");
console.log("CLI installation status:", installed);
if (!installed) {
toast({
title: "Atuin CLI",
description: "Started CLI setup and installation...",
});
console.log("Installing CLI...");
await invoke("install_cli");
console.log("Setting up plugin...");
await invoke("setup_cli");
toast({
title: "Atuin CLI",
description: "Installation complete",
});
}
};
setup();
}, []);
if (!homeInfo) {