mirror of
https://github.com/block/goose.git
synced 2026-07-17 12:56:20 +02:00
[feat] add term command
Term command is a new way to use goose without the builtin REPL - instead the session is tied right into your terminal
This commit is contained in:
@@ -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,
|
||||
@@ -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 {
|
||||
@@ -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)]
|
||||
@@ -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>,
|
||||
@@ -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",
|
||||
};
|
||||
|
||||
@@ -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?;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
// 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
|
||||
("{goose_bin}" term log "$1" &) 2>/dev/null
|
||||
}}
|
||||
|
||||
# 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
|
||||
("{goose_bin}" term log "$1" &) 2>/dev/null
|
||||
}}
|
||||
|
||||
# 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'
|
||||
|
||||
# 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
|
||||
{goose_bin} term log $argv[1] 2>/dev/null &
|
||||
end"#
|
||||
)
|
||||
}
|
||||
"powershell" | "pwsh" => {
|
||||
format!(
|
||||
r#"$env:GOOSE_TERMINAL_ID = "{terminal_id}"
|
||||
Set-Alias -Name gt -Value {{ {goose_bin} term run $args }}
|
||||
|
||||
# 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 ') {{
|
||||
Start-Job -ScriptBlock {{ {goose_bin} term log $using:line }} | Out-Null
|
||||
}}
|
||||
[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?;
|
||||
}
|
||||
|
||||
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?;
|
||||
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(())
|
||||
}
|
||||
@@ -19,7 +19,7 @@ use tokio::sync::OnceCell;
|
||||
use tracing::{info, warn};
|
||||
use utoipa::ToSchema;
|
||||
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 6;
|
||||
const CURRENT_SCHEMA_VERSION: i32 = 7;
|
||||
pub const SESSIONS_FOLDER: &str = "sessions";
|
||||
pub const DB_NAME: &str = "sessions.db";
|
||||
|
||||
@@ -261,6 +261,18 @@ impl SessionManager {
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_id(
|
||||
id: String,
|
||||
working_dir: PathBuf,
|
||||
name: String,
|
||||
session_type: SessionType,
|
||||
) -> Result<Session> {
|
||||
Self::instance()
|
||||
.await?
|
||||
.create_session_with_id(id, working_dir, name, session_type)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_session(id: &str, include_messages: bool) -> Result<Session> {
|
||||
Self::instance()
|
||||
.await?
|
||||
@@ -361,6 +373,24 @@ impl SessionManager {
|
||||
.search_chat_history(query, limit, after_date, before_date, exclude_session_id)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn add_shell_command(
|
||||
session_id: &str,
|
||||
command: &str,
|
||||
working_dir: &Path,
|
||||
) -> Result<()> {
|
||||
Self::instance()
|
||||
.await?
|
||||
.add_shell_command(session_id, command, working_dir)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn get_shell_commands_since_last_message(session_id: &str) -> Result<Vec<String>> {
|
||||
Self::instance()
|
||||
.await?
|
||||
.get_shell_commands_since_last_message(session_id)
|
||||
.await
|
||||
}
|
||||
}
|
||||
|
||||
pub struct SessionStorage {
|
||||
@@ -598,6 +628,29 @@ impl SessionStorage {
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE shell_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
command TEXT NOT NULL,
|
||||
working_dir TEXT NOT NULL,
|
||||
created_timestamp INTEGER NOT NULL
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)")
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
sqlx::query(
|
||||
"CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)",
|
||||
)
|
||||
.execute(&pool)
|
||||
.await?;
|
||||
|
||||
Ok(Self { pool })
|
||||
}
|
||||
|
||||
@@ -836,6 +889,31 @@ impl SessionStorage {
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
7 => {
|
||||
sqlx::query(
|
||||
r#"
|
||||
CREATE TABLE shell_commands (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES sessions(id),
|
||||
command TEXT NOT NULL,
|
||||
working_dir TEXT NOT NULL,
|
||||
created_timestamp INTEGER NOT NULL
|
||||
)
|
||||
"#,
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query(
|
||||
"CREATE INDEX idx_shell_commands_session ON shell_commands(session_id)",
|
||||
)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
sqlx::query("CREATE INDEX idx_shell_commands_timestamp ON shell_commands(session_id, created_timestamp)")
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
}
|
||||
_ => {
|
||||
anyhow::bail!("Unknown migration version: {}", version);
|
||||
}
|
||||
@@ -883,6 +961,48 @@ impl SessionStorage {
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn create_session_with_id(
|
||||
&self,
|
||||
id: String,
|
||||
working_dir: PathBuf,
|
||||
name: String,
|
||||
session_type: SessionType,
|
||||
) -> Result<Session> {
|
||||
let mut tx = self.pool.begin().await?;
|
||||
|
||||
// Use INSERT OR IGNORE to handle race conditions where multiple processes
|
||||
// might try to create the same session simultaneously
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT OR IGNORE INTO sessions (id, name, user_set_name, session_type, working_dir, extension_data)
|
||||
VALUES (?, ?, FALSE, ?, ?, '{}')
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.bind(&name)
|
||||
.bind(session_type.to_string())
|
||||
.bind(working_dir.to_string_lossy().as_ref())
|
||||
.execute(&mut *tx)
|
||||
.await?;
|
||||
|
||||
let session = sqlx::query_as::<_, Session>(
|
||||
r#"
|
||||
SELECT id, working_dir, name, description, user_set_name, session_type, created_at, updated_at, extension_data,
|
||||
total_tokens, input_tokens, output_tokens,
|
||||
accumulated_total_tokens, accumulated_input_tokens, accumulated_output_tokens,
|
||||
schedule_id, recipe_json, user_recipe_values_json,
|
||||
provider_name, model_config_json
|
||||
FROM sessions WHERE id = ?
|
||||
"#,
|
||||
)
|
||||
.bind(&id)
|
||||
.fetch_one(&mut *tx)
|
||||
.await?;
|
||||
|
||||
tx.commit().await?;
|
||||
Ok(session)
|
||||
}
|
||||
|
||||
async fn get_session(&self, id: &str, include_messages: bool) -> Result<Session> {
|
||||
let mut session = sqlx::query_as::<_, Session>(
|
||||
r#"
|
||||
@@ -1286,6 +1406,55 @@ impl SessionStorage {
|
||||
.execute()
|
||||
.await
|
||||
}
|
||||
|
||||
async fn add_shell_command(
|
||||
&self,
|
||||
session_id: &str,
|
||||
command: &str,
|
||||
working_dir: &Path,
|
||||
) -> Result<()> {
|
||||
// Use seconds to match messages table timestamp format
|
||||
let timestamp = chrono::Utc::now().timestamp();
|
||||
|
||||
sqlx::query(
|
||||
r#"
|
||||
INSERT INTO shell_commands (session_id, command, working_dir, created_timestamp)
|
||||
VALUES (?, ?, ?, ?)
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(command)
|
||||
.bind(working_dir.to_string_lossy().as_ref())
|
||||
.bind(timestamp)
|
||||
.execute(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn get_shell_commands_since_last_message(&self, session_id: &str) -> Result<Vec<String>> {
|
||||
let last_message_timestamp = sqlx::query_scalar::<_, Option<i64>>(
|
||||
"SELECT MAX(created_timestamp) FROM messages WHERE session_id = ?",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_one(&self.pool)
|
||||
.await?
|
||||
.unwrap_or(0);
|
||||
|
||||
let commands = sqlx::query_scalar::<_, String>(
|
||||
r#"
|
||||
SELECT command FROM shell_commands
|
||||
WHERE session_id = ? AND created_timestamp > ?
|
||||
ORDER BY created_timestamp ASC
|
||||
"#,
|
||||
)
|
||||
.bind(session_id)
|
||||
.bind(last_message_timestamp)
|
||||
.fetch_all(&self.pool)
|
||||
.await?;
|
||||
|
||||
Ok(commands)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1492,4 +1661,102 @@ mod tests {
|
||||
assert!(imported.user_set_name);
|
||||
assert_eq!(imported.working_dir, PathBuf::from("/tmp/test"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_create_session_with_id_race_condition() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test_race.db");
|
||||
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
|
||||
|
||||
let session_id = "test-race-session";
|
||||
let mut handles = vec![];
|
||||
|
||||
// Spawn multiple tasks trying to create the same session simultaneously
|
||||
for _ in 0..10 {
|
||||
let storage = Arc::clone(&storage);
|
||||
let id = session_id.to_string();
|
||||
handles.push(tokio::spawn(async move {
|
||||
storage
|
||||
.create_session_with_id(
|
||||
id.clone(),
|
||||
PathBuf::from("/tmp/test"),
|
||||
id,
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
}));
|
||||
}
|
||||
|
||||
// All should succeed without UNIQUE constraint errors
|
||||
for handle in handles {
|
||||
let result = handle.await.unwrap();
|
||||
assert!(
|
||||
result.is_ok(),
|
||||
"create_session_with_id failed: {:?}",
|
||||
result
|
||||
);
|
||||
}
|
||||
|
||||
// Should only have one session with this ID
|
||||
let session = storage.get_session(session_id, false).await.unwrap();
|
||||
assert_eq!(session.id, session_id);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_shell_commands_since_last_message() {
|
||||
let temp_dir = TempDir::new().unwrap();
|
||||
let db_path = temp_dir.path().join("test_shell.db");
|
||||
let storage = Arc::new(SessionStorage::create(&db_path).await.unwrap());
|
||||
|
||||
let session = storage
|
||||
.create_session(
|
||||
PathBuf::from("/tmp/test"),
|
||||
"test".to_string(),
|
||||
SessionType::User,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Add some shell commands
|
||||
storage
|
||||
.add_shell_command(&session.id, "ls -la", &PathBuf::from("/tmp"))
|
||||
.await
|
||||
.unwrap();
|
||||
storage
|
||||
.add_shell_command(&session.id, "cd foo", &PathBuf::from("/tmp"))
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Should get both commands (no messages yet)
|
||||
let commands = storage
|
||||
.get_shell_commands_since_last_message(&session.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(commands.len(), 2);
|
||||
assert_eq!(commands[0], "ls -la");
|
||||
assert_eq!(commands[1], "cd foo");
|
||||
|
||||
// Add a message with timestamp in the future to ensure it's after shell commands
|
||||
let future_timestamp = chrono::Utc::now().timestamp() + 100;
|
||||
storage
|
||||
.add_message(
|
||||
&session.id,
|
||||
&Message {
|
||||
id: None,
|
||||
role: Role::User,
|
||||
created: future_timestamp,
|
||||
content: vec![MessageContent::text("test")],
|
||||
metadata: Default::default(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
// Commands before the message should not be returned
|
||||
let commands = storage
|
||||
.get_shell_commands_since_last_message(&session.id)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(commands.len(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
# Terminal Integration
|
||||
|
||||
The `goose term` commands let you talk to goose directly from your shell prompt. Instead of switching to a separate REPL session, you stay in your terminal and call goose when you need it.
|
||||
|
||||
```bash
|
||||
gt "what does this error mean?"
|
||||
```
|
||||
|
||||
Goose responds, you read the answer, and you're back at your prompt. The conversation lives alongside your work, not in a separate window you have to manage.
|
||||
|
||||
## Command History Awareness
|
||||
|
||||
The real power comes from shell integration. Once set up, goose tracks the commands you run, so when you ask a question, it already knows what you've been doing.
|
||||
|
||||
No more copy-pasting error messages or explaining "I ran these commands...". Just work normally, then ask goose for help.
|
||||
|
||||
## Setup
|
||||
|
||||
Add one line to your shell config:
|
||||
|
||||
**zsh** (`~/.zshrc`)
|
||||
```bash
|
||||
eval "$(goose term init zsh)"
|
||||
```
|
||||
|
||||
**bash** (`~/.bashrc`)
|
||||
```bash
|
||||
eval "$(goose term init bash)"
|
||||
```
|
||||
|
||||
**fish** (`~/.config/fish/config.fish`)
|
||||
```fish
|
||||
goose term init fish | source
|
||||
```
|
||||
|
||||
**PowerShell** (`$PROFILE`)
|
||||
```powershell
|
||||
Invoke-Expression (goose term init powershell)
|
||||
```
|
||||
|
||||
Then restart your terminal or source the config.
|
||||
|
||||
## Usage
|
||||
|
||||
Once set up, your terminal gets a session ID. All commands you run are logged to that session.
|
||||
|
||||
To talk to goose about what you've been doing:
|
||||
|
||||
```bash
|
||||
gt "why did that fail?"
|
||||
```
|
||||
|
||||
`gt` is just an alias for `goose term run`. It opens goose with your command history already loaded.
|
||||
|
||||
## What Gets Logged
|
||||
|
||||
Every command you type gets stored with its timestamp and working directory. Goose sees commands you ran since your last message to it.
|
||||
|
||||
Commands starting with `goose term` or `gt` are not logged (to avoid noise).
|
||||
|
||||
## Performance
|
||||
|
||||
- **Shell startup**: adds ~10ms
|
||||
- **Per command**: ~10ms, runs in background (non-blocking)
|
||||
|
||||
You won't notice any delay. The logging happens asynchronously after your command starts executing.
|
||||
|
||||
## How It Works
|
||||
|
||||
`goose term init` outputs shell code that:
|
||||
1. Sets a `GOOSE_TERMINAL_ID` environment variable
|
||||
2. Creates the `gt` alias
|
||||
3. Installs a preexec hook that calls `goose term log` for each command
|
||||
|
||||
The hook runs `goose term log <command> &` in the background, which writes to a local SQLite database. When you run `gt`, goose queries that database for commands since your last message.
|
||||
Reference in New Issue
Block a user