Skip to content
Closed
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
93 changes: 93 additions & 0 deletions crates/goose-cli/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ use crate::commands::configure::handle_configure;
use crate::commands::info::handle_info;
use crate::commands::project::{handle_project_default, handle_projects_interactive};
use crate::commands::recipe::{handle_deeplink, handle_list, handle_open, handle_validate};
use crate::commands::term::{handle_term_init, handle_term_log, handle_term_run};

use crate::commands::schedule::{
handle_schedule_add, handle_schedule_cron_help, handle_schedule_list, handle_schedule_remove,
Expand All @@ -34,6 +35,14 @@ use std::io::Read;
use std::path::PathBuf;
use tracing::warn;

fn non_empty_string(s: &str) -> Result<String, String> {
if s.trim().is_empty() {
Err("Prompt cannot be empty".to_string())
} else {
Ok(s.to_string())
}
}

#[derive(Parser)]
#[command(author, version, display_name = "", about, long_about = None)]
struct Cli {
Expand Down Expand Up @@ -829,6 +838,61 @@ enum Command {
#[arg(long, help = "Authentication token to secure the web interface")]
auth_token: Option<String>,
},

/// Terminal-integrated session (one session per terminal)
#[command(
about = "Terminal-integrated goose session",
long_about = "Runs a goose session tied to your terminal window.\n\
Each terminal maintains its own persistent session that resumes automatically.\n\n\
Setup:\n \
eval \"$(goose term init zsh)\" # Add to ~/.zshrc\n\n\
Usage:\n \
goose term run \"list files in this directory\"\n \
gt \"create a python script\" # using alias"
)]
Term {
#[command(subcommand)]
command: TermCommand,
},
}

#[derive(Subcommand)]
enum TermCommand {
/// Print shell initialization script
#[command(
about = "Print shell initialization script",
long_about = "Prints shell configuration to set up terminal-integrated sessions.\n\
Each terminal gets a persistent goose session that automatically resumes.\n\n\
Setup:\n \
echo 'eval \"$(goose term init zsh)\"' >> ~/.zshrc\n \
source ~/.zshrc"
)]
Init {
/// Shell type (bash, zsh, fish, powershell)
#[arg(value_enum)]
shell: Shell,
},

/// Log a shell command (called by shell hook)
#[command(about = "Log a shell command to the session", hide = true)]
Log {
/// The command that was executed
command: String,
},

/// Run a prompt in the terminal session
#[command(
about = "Run a prompt in the terminal session",
long_about = "Run a prompt in the terminal-integrated session.\n\n\
Examples:\n \
goose term run \"list files in this directory\"\n \
goose term run \"create a python script that prints hello world\""
)]
Run {
/// The prompt to send to goose
#[arg(value_parser = non_empty_string)]
prompt: String,
},
}

#[derive(clap::ValueEnum, Clone, Debug)]
Expand All @@ -838,6 +902,14 @@ enum CliProviderVariant {
Ollama,
}

#[derive(clap::ValueEnum, Clone, Debug)]
enum Shell {
Bash,
Zsh,
Fish,
Powershell,
}

#[derive(Debug)]
pub struct InputConfig {
pub contents: Option<String>,
Expand Down Expand Up @@ -874,6 +946,7 @@ pub async fn cli() -> anyhow::Result<()> {
Some(Command::Bench { .. }) => "bench",
Some(Command::Recipe { .. }) => "recipe",
Some(Command::Web { .. }) => "web",
Some(Command::Term { .. }) => "term",
None => "default_session",
};

Expand Down Expand Up @@ -1387,6 +1460,26 @@ pub async fn cli() -> anyhow::Result<()> {
crate::commands::web::handle_web(port, host, open, auth_token).await?;
return Ok(());
}
Some(Command::Term { command }) => {
match command {
TermCommand::Init { shell } => {
let shell_str = match shell {
Shell::Bash => "bash",
Shell::Zsh => "zsh",
Shell::Fish => "fish",
Shell::Powershell => "powershell",
};
handle_term_init(shell_str)?;
}
TermCommand::Log { command } => {
handle_term_log(command).await?;
}
TermCommand::Run { prompt } => {
handle_term_run(prompt).await?;
}
}
return Ok(());
}
None => {
return if !Config::global().exists() {
handle_configure().await?;
Expand Down
1 change: 1 addition & 0 deletions crates/goose-cli/src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,5 +6,6 @@ pub mod project;
pub mod recipe;
pub mod schedule;
pub mod session;
pub mod term;
pub mod update;
pub mod web;
192 changes: 192 additions & 0 deletions crates/goose-cli/src/commands/term.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
use anyhow::{anyhow, Result};
use goose::session::session_manager::SessionType;
use goose::session::SessionManager;
use uuid::Uuid;

use crate::session::{build_session, SessionBuilderConfig};

const TERMINAL_SESSION_PREFIX: &str = "term:";

/// Handle `goose term init <shell>` - print shell initialization script
pub fn handle_term_init(shell: &str) -> Result<()> {
let terminal_id = Uuid::new_v4().to_string();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

actually you could just create the session here and then use the session id of that session as the terminal_id


// Get the path to the current goose binary
let goose_bin = std::env::current_exe()
.map(|p| p.to_string_lossy().into_owned())
.unwrap_or_else(|_| "goose".to_string());

let script = match shell.to_lowercase().as_str() {
"bash" => {
format!(
r#"export GOOSE_TERMINAL_ID="{terminal_id}"
alias gt='{goose_bin} term run'

# Log commands to goose (runs silently in background)
goose_preexec() {{
[[ "$1" =~ ^goose\ term ]] && return
[[ "$1" =~ ^gt\ ]] && return

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Regex pattern ^gt\ (line 28) requires gt followed by space but won't match standalone gt command. Should be ^gt(\s|$) or ^gt to handle both cases.

Copilot uses AI. Check for mistakes.
("{goose_bin}" term log "$1" &) 2>/dev/null

Copilot AI Nov 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goose binary path contains spaces, this command will fail. Quote the path: ("'${goose_bin}'" term log "$1" &) or use escaped quotes within the path variable itself.

Copilot uses AI. Check for mistakes.
}}

# Install preexec hook for bash
if [[ -z "$goose_preexec_installed" ]]; then
goose_preexec_installed=1
trap 'goose_preexec "$BASH_COMMAND"' DEBUG
fi"#
)
}
"zsh" => {
format!(
r#"export GOOSE_TERMINAL_ID="{terminal_id}"
alias gt='{goose_bin} term run'

# Log commands to goose (runs silently in background)
goose_preexec() {{
[[ "$1" =~ ^goose\ term ]] && return
[[ "$1" =~ ^gt\ ]] && return

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same issue as bash: regex pattern ^gt\ won't match standalone gt command. Should be ^gt(\s|$) or ^gt.

Copilot uses AI. Check for mistakes.
("{goose_bin}" term log "$1" &) 2>/dev/null

Copilot AI Nov 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goose binary path contains spaces, this command will fail. Quote the path: ("'${goose_bin}'" term log "$1" &)

Copilot uses AI. Check for mistakes.
}}

# Install preexec hook for zsh
autoload -Uz add-zsh-hook
add-zsh-hook preexec goose_preexec"#
)
}
"fish" => {
format!(
r#"set -gx GOOSE_TERMINAL_ID "{terminal_id}"
alias gt='{goose_bin} term run'

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fish shell aliases don't support arguments like bash/zsh. Use alias gt '{goose_bin} term run' (function-style without =) or create a proper function instead.

Suggested change
alias gt='{goose_bin} term run'
function gt; {goose_bin} term run $argv; end

Copilot uses AI. Check for mistakes.

# Log commands to goose
function goose_preexec --on-event fish_preexec
string match -q -r '^goose term' -- $argv[1]; and return
string match -q -r '^gt ' -- $argv[1]; and return

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pattern ^gt requires space after gt but won't match standalone gt. Should be '^gt(\s|$)' or '^gt'.

Suggested change
string match -q -r '^gt ' -- $argv[1]; and return
string match -q -r '^gt(\\s|$)' -- $argv[1]; and return

Copilot uses AI. Check for mistakes.
{goose_bin} term log $argv[1] 2>/dev/null &

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing quotes around $argv[1] in Fish shell. Commands with spaces or special characters will fail. Use {goose_bin} term log \"$argv[1]\" 2>/dev/null &

Suggested change
{goose_bin} term log $argv[1] 2>/dev/null &
{goose_bin} term log "$argv[1]" 2>/dev/null &

Copilot uses AI. Check for mistakes.
end"#
)
}
"powershell" | "pwsh" => {
format!(
r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}"
Set-Alias -Name gt -Value {{ {goose_bin} term run $args }}

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PowerShell Set-Alias -Value doesn't accept scriptblocks with parameters. Use a function instead: function gt {{ & {goose_bin} term run @args }}

Suggested change
Set-Alias -Name gt -Value {{ {goose_bin} term run $args }}
function gt { {goose_bin} term run $args }

Copilot uses AI. Check for mistakes.

# Log commands to goose
Set-PSReadLineKeyHandler -Chord Enter -ScriptBlock {{
$line = $null
[Microsoft.PowerShell.PSConsoleReadLine]::GetBufferState([ref]$line, [ref]$null)
if ($line -notmatch '^goose term' -and $line -notmatch '^gt ') {{

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pattern ^gt won't match standalone gt command. Should be ^gt(\s|$) or ^gt\b.

Copilot uses AI. Check for mistakes.
Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null

Copilot AI Nov 23, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the goose binary path contains spaces, this command will fail. Quote the path in PowerShell: & '{goose_bin}' term log $using:line

Suggested change
Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null
Start-Job -ScriptBlock {{ '{goose_bin}' term log $using:line }} | Out-Null

Copilot uses AI. Check for mistakes.
}}
[Microsoft.PowerShell.PSConsoleReadLine]::AcceptLine()
}}"#
)
}
_ => {
return Err(anyhow!(
"Unsupported shell: {}. Supported shells: bash, zsh, fish, powershell",
shell
));
}
};

println!("{}", script);
Ok(())
}

/// Handle `goose term log <command>` - log a shell command to the database
pub async fn handle_term_log(command: String) -> Result<()> {
let terminal_id = std::env::var("GOOSE_TERMINAL_ID")
.map_err(|_| anyhow!("GOOSE_TERMINAL_ID not set. Run 'goose term init <shell>' first."))?;

let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id);
let working_dir = std::env::current_dir()?;

// Create session if it doesn't exist (so we can log commands before first run)
if SessionManager::get_session(&session_name, false)
.await
.is_err()
{
let session = SessionManager::create_session_with_id(
session_name.clone(),
working_dir.clone(),
session_name.clone(),
SessionType::User,
)
.await?;

SessionManager::update_session(&session.id)
.user_provided_name(session_name.clone())
.apply()
.await?;

Copilot AI Nov 22, 2025

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Duplicated session creation logic appears in both handle_term_log (lines 110-121) and handle_term_run (lines 151-163). Extract to a helper function like ensure_terminal_session to reduce duplication.

Copilot uses AI. Check for mistakes.
}

SessionManager::add_shell_command(&session_name, &command, &working_dir).await?;

Ok(())
}

/// Handle `goose term run <prompt>` - run a prompt in the terminal session
pub async fn handle_term_run(prompt: String) -> Result<()> {
let terminal_id = std::env::var("GOOSE_TERMINAL_ID").map_err(|_| {
anyhow!(
"GOOSE_TERMINAL_ID not set.\n\n\
Add to your shell config (~/.zshrc or ~/.bashrc):\n \
eval \"$(goose term init zsh)\"\n\n\
Then restart your terminal or run: source ~/.zshrc"
)
})?;

let session_name = format!("{}{}", TERMINAL_SESSION_PREFIX, terminal_id);

let session_id = match SessionManager::get_session(&session_name, false).await {
Ok(_) => {
SessionManager::update_session(&session_name)
.working_dir(std::env::current_dir()?)
.apply()
.await?;
session_name.clone()
}
Err(_) => {
let session = SessionManager::create_session_with_id(
session_name.clone(),
std::env::current_dir()?,
session_name.clone(),
SessionType::User,
)
.await?;

// Mark with user-provided name so session persists across restarts
SessionManager::update_session(&session.id)
.user_provided_name(session_name)
.apply()
.await?;

session.id
}
};

let commands = SessionManager::get_shell_commands_since_last_message(&session_id).await?;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not entirely sure I follow what we are fetching here. doesn't each session add at least one message to the conversation so isn't this then automatically empty?

in general though, adding shell commands to the session manager seems rather specific for this application. couldn't we just list the messages from session and exclude the <shell_history></shell_history> from that to get the same result? or if that doesn't work, maybe add a message content type?

let prompt_with_context = if commands.is_empty() {
prompt
} else {
format!(
"<shell_history>\n{}\n</shell_history>\n\n{}",
commands.join("\n"),
prompt
)
};

let config = SessionBuilderConfig {
session_id: Some(session_id),
resume: true,
interactive: false,
quiet: true,
..Default::default()
};

let mut session = build_session(config).await;
session.headless(prompt_with_context).await?;

Ok(())
}
Loading
Loading