Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
17 changes: 15 additions & 2 deletions .claude/skills/vibeshell/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ VibeShell automatically starts its local headless daemon when an SSH, SFTP, or s
## Before operating

1. Verify the CLI is available with `vibeshell version`.
2. Inspect configured targets with `vibeshell servers`.
3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line.
2. Inspect configured targets with `vibeshell servers`. Add or remove them with `vibeshell servers add` and `vibeshell servers delete` — the desktop UI is not required.
3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. Passwords for `servers add` come from `SSH_PASSWORD` or `VIBESHELL_PASSWORD`; keys from `--identity`.
4. Reuse an existing session unless the user explicitly needs an independent parallel shell.

Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.local/bin/vibeshell`, then `/Applications/VibeShell.app/Contents/MacOS/vibeshell` on macOS. Use the resolved absolute path for the rest of the workflow when necessary. If none exists, tell the user which lookup failed. Do not silently replace VibeShell with `ssh`, `scp`, or another client because that bypasses the saved VibeShell configuration and session model.

## Add or delete saved servers

```bash
vibeshell servers add root@prod.example.com --name prod-web
SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web
vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding
vibeshell servers add alice@web-1 --type teleport --proxy teleport.example.com:443
vibeshell servers delete prod-web
```

Display name defaults to the host. Secrets must not appear on the command line. Teleport nodes use `tsh` from PATH after `tsh login --proxy=...`.

## Import existing SSH configurations

Preview everything VibeShell can discover:
Expand All @@ -39,6 +51,7 @@ vibeshell import openssh
vibeshell import openssh --path ~/.ssh/config
vibeshell import tabby --path ~/.config/tabby/config.yaml
vibeshell import putty --path ~/putty-sessions.reg
vibeshell import teleport
```

Use `--json` when structured output is more useful. Never import or expose plaintext passwords from third-party profiles. VibeShell may reference an existing private-key path and reads that local key only when establishing a connection.
Expand Down
17 changes: 15 additions & 2 deletions .codex/skills/vibeshell/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,24 @@ VibeShell automatically starts its local headless daemon when an SSH, SFTP, or s
## Before operating

1. Verify the CLI is available with `vibeshell version`.
2. Inspect configured targets with `vibeshell servers`.
3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line.
2. Inspect configured targets with `vibeshell servers`. Add or remove them with `vibeshell servers add` and `vibeshell servers delete` — the desktop UI is not required.
3. Prefer saved server names over raw hosts. Credentials remain local to VibeShell and must never be printed, copied into prompts, or passed on the command line. Passwords for `servers add` come from `SSH_PASSWORD` or `VIBESHELL_PASSWORD`; keys from `--identity`.
4. Reuse an existing session unless the user explicitly needs an independent parallel shell.

Resolve the native executable in this order: `vibeshell` from `PATH`, `$HOME/.local/bin/vibeshell`, then `/Applications/VibeShell.app/Contents/MacOS/vibeshell` on macOS. Use the resolved absolute path for the rest of the workflow when necessary. If none exists, tell the user which lookup failed. Do not silently replace VibeShell with `ssh`, `scp`, or another client because that bypasses the saved VibeShell configuration and session model.

## Add or delete saved servers

```bash
vibeshell servers add root@prod.example.com --name prod-web
SSH_PASSWORD=... vibeshell servers add root@prod.example.com --name prod-web
vibeshell servers add ubuntu@10.0.0.8:2222 --identity ~/.ssh/id_ed25519 --jump bastion --agent-forwarding
vibeshell servers add alice@web-1 --type teleport --proxy teleport.example.com:443
vibeshell servers delete prod-web
```

Display name defaults to the host. Secrets must not appear on the command line. Teleport nodes use `tsh` from PATH after `tsh login --proxy=...`.

## Import existing SSH configurations

Preview everything VibeShell can discover:
Expand All @@ -39,6 +51,7 @@ vibeshell import openssh
vibeshell import openssh --path ~/.ssh/config
vibeshell import tabby --path ~/.config/tabby/config.yaml
vibeshell import putty --path ~/putty-sessions.reg
vibeshell import teleport
```

Use `--json` when structured output is more useful. Never import or expose plaintext passwords from third-party profiles. VibeShell may reference an existing private-key path and reads that local key only when establishing a connection.
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ Import the discovered OpenSSH, PuTTY, and Tabby profiles:

```bash
vibeshell import auto
vibeshell servers add root@prod.example.com --name prod-web
vibeshell servers
```

Expand Down Expand Up @@ -224,8 +225,8 @@ The standalone `vibeshell` binary is a real Rust client and daemon, not a JavaSc
| Area | Commands |
| --- | --- |
| Version and diagnostics | `vibeshell version`, `vibeshell daemon start`, `vibeshell daemon status` |
| Inventory | `vibeshell servers` |
| Import | `vibeshell import auto|openssh|putty|tabby [--path ...] [--dry-run] [--json]` |
| Inventory | `vibeshell servers`, `vibeshell servers add user@host`, `vibeshell servers delete <name>` |
| Import | `vibeshell import auto|openssh|putty|tabby|teleport [--path ...] [--dry-run] [--json]` |
| Connect | `vibeshell ssh <server> [--new] [--wait]` |
| Remote command | `vibeshell ssh <server> -- <command>`, `--command-file`, or `--command-stdin` |
| Sessions | `vibeshell sessions`, `vibeshell attach`, `vibeshell ssh-session`, `vibeshell exec`, `vibeshell send-key`, `vibeshell kill` |
Expand Down
3 changes: 3 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,11 +26,14 @@ Both installers verify the native binary and trigger its built-in, idempotent Sk
vibeshell version
vibeshell import auto --dry-run
vibeshell import auto
vibeshell servers add root@prod.example.com --name prod-web
vibeshell servers
vibeshell ssh <server>
vibeshell sftp <server>
```

Add a server without the GUI using `user@host[:port]`. Passwords are read from `SSH_PASSWORD` or `VIBESHELL_PASSWORD` (never argv). Use `--identity` for a private key. Teleport: `vibeshell servers add user@node --type teleport --proxy teleport.example.com:443`. Delete with `vibeshell servers delete <name>`.

Commands that need an SSH/SFTP session automatically start the native local daemon. The daemon stores its IPC endpoint and state under the current user's VibeShell data directory and can be inspected directly:

```bash
Expand Down
205 changes: 197 additions & 8 deletions cli/src/commands/server.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,28 @@
//! Server management commands for the CLI.
//!
//! These commands allow listing configured servers by communicating
//! with the VibeShell GUI over IPC.

use anyhow::{bail, Result};
use std::path::{Path, PathBuf};

use anyhow::{bail, Context, Result};
use vibeshell_core::commands::server::AddServerSpec;
use vibeshell_core::ipc::IpcMessage;

use crate::ipc_support;
use crate::ssh_target;

pub struct AddServerArgs {
pub target: String,
pub name: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub identity: Option<PathBuf>,
pub jump: Option<String>,
pub agent_forwarding: bool,
pub post_login: Option<String>,
pub group: Option<String>,
pub tags: Vec<String>,
pub connection_kind: String,
pub teleport_proxy: Option<String>,
}

/// List all configured servers known to VibeShell.
pub fn list() -> Result<()> {
Expand All @@ -21,10 +37,21 @@ pub fn list() -> Result<()> {

println!("Configured servers:");
for server in servers {
println!(
" {} {}@{}:{} auth={}",
server.name, server.username, server.host, server.port, server.auth_type
);
let kind = server.connection_kind.as_deref().unwrap_or("ssh");
if kind == "teleport" {
println!(
" {} {}@{} teleport proxy={}",
server.name,
server.username,
server.host,
server.teleport_proxy.as_deref().unwrap_or("-")
);
} else {
println!(
" {} {}@{}:{} auth={}",
server.name, server.username, server.host, server.port, server.auth_type
);
}
}
Ok(())
}
Expand All @@ -36,3 +63,165 @@ pub fn list() -> Result<()> {
}
}
}

/// Add a server from `user@host[:port]` shorthand. Secrets come from env vars,
/// never from argv: `SSH_PASSWORD` or `VIBESHELL_PASSWORD`, and
/// `VIBESHELL_KEY_PASSPHRASE` when `--identity` points at an encrypted key.
pub fn add(args: AddServerArgs) -> Result<()> {
let target = ssh_target::parse_ssh_target(&args.target)?;
let username = args
.user
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.or(target.username)
.ok_or_else(|| anyhow::anyhow!("Username is required (use user@host or pass --user)"))?;
let host = target.host;
let port = args.port.or(target.port).unwrap_or(22);
let name = args
.name
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.unwrap_or_else(|| host.clone());

let identity_path = args.identity.as_deref();
let is_teleport = args.connection_kind.eq_ignore_ascii_case("teleport")
|| args.connection_kind.eq_ignore_ascii_case("tsh");
if is_teleport {
let proxy_ok = args
.teleport_proxy
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.is_some();
if !proxy_ok {
bail!("Teleport servers require --proxy (for example teleport.example.com:443)");
}
}

let (auth_type, credential, passphrase, key_path, saved_credentials) = if is_teleport {
("password", None, None, None, true)
} else {
resolve_credentials(identity_path)?
};

let spec = AddServerSpec {
name: name.clone(),
host: host.clone(),
port,
username: username.clone(),
auth_type: auth_type.to_string(),
group_id: None,
group_name: args.group,
tags: args.tags,
jump_host_id: None,
jump_host: args.jump,
post_login_command: args.post_login,
agent_forwarding: args.agent_forwarding,
connection_kind: Some(args.connection_kind),
teleport_proxy: args.teleport_proxy,
credential,
passphrase,
key_path,
};

match ipc_support::send(&IpcMessage::AddServer { spec })? {
IpcMessage::ServerAdded { server } => {
println!(
"Added server '{}' ({}@{}:{})",
server.name, server.username, server.host, server.port
);
if !saved_credentials {
if identity_path.is_some() {
eprintln!(
"Warning: --identity was set but the key file could not be stored. This should not happen."
);
} else {
eprintln!(
"Credentials were not saved. Set SSH_PASSWORD or VIBESHELL_PASSWORD to store a password, or pass --identity KEYFILE."
);
}
}
Ok(())
}
IpcMessage::Error { message } => {
bail!("Error adding server: {}", message);
}
_ => bail!("Unexpected response from background service"),
}
}

pub fn delete(name: &str) -> Result<()> {
let name = name.trim();
if name.is_empty() {
bail!("Server name is required");
}

match ipc_support::send(&IpcMessage::DeleteServer {
name: name.to_string(),
})? {
IpcMessage::Ok => {
println!("Deleted server '{name}'");
Ok(())
}
IpcMessage::Error { message } => {
bail!("Error deleting server: {}", message);
}
_ => bail!("Unexpected response from background service"),
}
}

fn resolve_credentials(
identity: Option<&Path>,
) -> Result<(
&'static str,
Option<String>,
Option<String>,
Option<String>,
bool,
)> {
if let Some(path) = identity {
let key = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read identity file {}", path.display()))?;
if key.trim().is_empty() {
bail!("Identity file {} is empty", path.display());
}
let passphrase =
env_nonempty("VIBESHELL_KEY_PASSPHRASE").or_else(|| env_nonempty("SSH_KEY_PASSPHRASE"));
Ok((
"key_with_passphrase",
Some(key),
passphrase,
Some(path.display().to_string()),
true,
))
} else if let Some(password) =
env_nonempty("SSH_PASSWORD").or_else(|| env_nonempty("VIBESHELL_PASSWORD"))
{
Ok(("password", Some(password), None, None, true))
} else {
Ok(("password", None, None, None, false))
}
}

fn env_nonempty(name: &str) -> Option<String> {
std::env::var(name)
.ok()
.map(|value| value.trim().to_string())
.filter(|value| !value.is_empty())
}

#[cfg(test)]
mod tests {
use super::env_nonempty;

#[test]
fn env_nonempty_treats_blank_as_unset() {
std::env::set_var("VIBESHELL_TEST_EMPTY_SECRET", " ");
assert!(env_nonempty("VIBESHELL_TEST_EMPTY_SECRET").is_none());
std::env::remove_var("VIBESHELL_TEST_EMPTY_SECRET");
}
}
Loading
Loading