mirror of
https://github.com/block/buzz.git
synced 2026-11-02 07:22:55 +00:00
Launch sprout-agent from the desktop GUI (#529)
This commit is contained in:
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
@@ -346,6 +346,8 @@ jobs:
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-agent-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
- name: Build Tauri app
|
||||
run: cd desktop && pnpm tauri build
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -52,7 +52,7 @@ jobs:
|
||||
|
||||
- name: Build sidecars
|
||||
run: |
|
||||
cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr
|
||||
cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr
|
||||
./scripts/bundle-sidecars.sh
|
||||
|
||||
- name: Build unsigned Tauri app
|
||||
|
||||
@@ -460,7 +460,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
||||
match normalize_agent_command_identity(command).as_str() {
|
||||
"goose" => Some(vec!["acp".to_string()]),
|
||||
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
|
||||
| "claudecode" => Some(Vec::new()),
|
||||
| "claudecode" | "sprout-agent" => Some(Vec::new()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -1228,6 +1228,18 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_sprout_agent_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("sprout-agent", Vec::new()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("sprout-agent", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_agent_command_identity_variants() {
|
||||
assert_eq!(normalize_agent_command_identity("goose"), "goose");
|
||||
|
||||
@@ -50,7 +50,7 @@ const overrides = new Map([
|
||||
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
|
||||
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
|
||||
["src-tauri/src/nostr_convert.rs", 870], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + 20 unit tests
|
||||
["src-tauri/src/managed_agents/runtime.rs", 740], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection
|
||||
["src-tauri/src/managed_agents/runtime.rs", 780], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection + sprout-agent mcp_hooks wiring + tests
|
||||
["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests
|
||||
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
|
||||
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
|
||||
|
||||
@@ -5,17 +5,23 @@ use tauri::AppHandle;
|
||||
|
||||
use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo};
|
||||
|
||||
struct KnownAcpProvider {
|
||||
id: &'static str,
|
||||
label: &'static str,
|
||||
commands: &'static [&'static str],
|
||||
aliases: &'static [&'static str],
|
||||
avatar_url: &'static str,
|
||||
pub(crate) struct KnownAcpProvider {
|
||||
pub id: &'static str,
|
||||
pub label: &'static str,
|
||||
pub commands: &'static [&'static str],
|
||||
pub aliases: &'static [&'static str],
|
||||
pub avatar_url: &'static str,
|
||||
/// MCP server binary to use instead of the default `sprout-mcp-server`.
|
||||
pub mcp_command: Option<&'static str>,
|
||||
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
|
||||
pub mcp_hooks: bool,
|
||||
}
|
||||
|
||||
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
|
||||
const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default";
|
||||
const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default";
|
||||
const SPROUT_AGENT_AVATAR_URL: &str =
|
||||
"https://raw.githubusercontent.com/block/sprout/main/docs/assets/sprout-icon.png";
|
||||
|
||||
fn common_binary_paths() -> &'static [PathBuf] {
|
||||
use std::sync::OnceLock;
|
||||
@@ -46,6 +52,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
commands: &["goose"],
|
||||
aliases: &[],
|
||||
avatar_url: GOOSE_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "claude",
|
||||
@@ -53,6 +61,8 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
commands: &["claude-agent-acp", "claude-code-acp"],
|
||||
aliases: &["claude-code", "claudecode"],
|
||||
avatar_url: CLAUDE_CODE_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "codex",
|
||||
@@ -60,6 +70,17 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
|
||||
commands: &["codex-acp"],
|
||||
aliases: &[],
|
||||
avatar_url: CODEX_AVATAR_URL,
|
||||
mcp_command: None,
|
||||
mcp_hooks: false,
|
||||
},
|
||||
KnownAcpProvider {
|
||||
id: "sprout-agent",
|
||||
label: "Sprout Agent",
|
||||
commands: &["sprout-agent"],
|
||||
aliases: &[],
|
||||
avatar_url: SPROUT_AGENT_AVATAR_URL,
|
||||
mcp_command: Some("sprout-dev-mcp"),
|
||||
mcp_hooks: true,
|
||||
},
|
||||
];
|
||||
|
||||
@@ -110,7 +131,7 @@ fn normalize_command_identity(command: &str) -> String {
|
||||
lower
|
||||
}
|
||||
|
||||
fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> {
|
||||
pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvider> {
|
||||
let normalized = normalize_command_identity(command);
|
||||
|
||||
KNOWN_ACP_PROVIDERS.iter().find(|provider| {
|
||||
@@ -127,7 +148,7 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
|
||||
match normalize_command_identity(command).as_str() {
|
||||
"goose" => Some(vec!["acp".to_string()]),
|
||||
"codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code"
|
||||
| "claudecode" => Some(Vec::new()),
|
||||
| "claudecode" | "sprout-agent" => Some(Vec::new()),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -342,6 +363,7 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
|
||||
command: command.to_string(),
|
||||
binary_path: binary_path.display().to_string(),
|
||||
default_args: normalize_agent_args(command, Vec::new()),
|
||||
mcp_command: provider.mcp_command.map(str::to_string),
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
@@ -356,7 +378,7 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
|
||||
mod tests {
|
||||
use super::{
|
||||
find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
|
||||
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL,
|
||||
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL,
|
||||
};
|
||||
|
||||
#[test]
|
||||
@@ -407,6 +429,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_sprout_agent_avatar() {
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("sprout-agent"),
|
||||
Some(SPROUT_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
assert_eq!(
|
||||
managed_agent_avatar_url("/usr/local/bin/sprout-agent"),
|
||||
Some(SPROUT_AGENT_AVATAR_URL.to_string())
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalizes_sprout_agent_args_to_empty() {
|
||||
assert_eq!(
|
||||
normalize_agent_args("sprout-agent", Vec::new()),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
assert_eq!(
|
||||
normalize_agent_args("sprout-agent", vec!["acp".into()]),
|
||||
Vec::<String>::new()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn login_shell_lookup_treats_command_as_data() {
|
||||
let marker =
|
||||
|
||||
@@ -4,9 +4,9 @@ use tauri::AppHandle;
|
||||
|
||||
use crate::{
|
||||
managed_agents::{
|
||||
append_log_marker, login_shell_path, managed_agent_log_path, missing_command_message,
|
||||
normalize_agent_args, open_log_file, resolve_command, ManagedAgentProcess,
|
||||
ManagedAgentRecord, ManagedAgentSummary,
|
||||
append_log_marker, known_acp_provider, login_shell_path, managed_agent_log_path,
|
||||
missing_command_message, normalize_agent_args, open_log_file, resolve_command,
|
||||
ManagedAgentProcess, ManagedAgentRecord, ManagedAgentSummary,
|
||||
},
|
||||
util::now_iso,
|
||||
};
|
||||
@@ -19,6 +19,8 @@ use crate::{
|
||||
pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[
|
||||
"sprout-acp",
|
||||
"sprout_acp",
|
||||
"sprout-agent",
|
||||
"sprout_agent",
|
||||
"claude-agent-acp",
|
||||
"claude_agent_acp",
|
||||
"claude-code-acp",
|
||||
@@ -28,6 +30,11 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[
|
||||
"goose",
|
||||
"sprout-mcp",
|
||||
"sprout_mcp",
|
||||
// sprout-dev-mcp's multicall personalities (rg, tree, sprout,
|
||||
// git-credential-nostr, git-sign-nostr) are short-lived per-tool-call
|
||||
// invocations — not listed here.
|
||||
"sprout-dev-mcp",
|
||||
"sprout_dev_mcp",
|
||||
];
|
||||
|
||||
/// Check if a process name matches any of our known agent binaries.
|
||||
@@ -484,6 +491,11 @@ pub fn spawn_agent_child(
|
||||
command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command);
|
||||
command.env("SPROUT_ACP_AGENT_ARGS", agent_args.join(","));
|
||||
command.env("SPROUT_ACP_MCP_COMMAND", &resolved_mcp_command);
|
||||
// Enable MCP hook tools (_Stop, _PostCompact) for agents that need them.
|
||||
// Uses "*" because build_mcp_servers() hard-codes the server name to "sprout-mcp".
|
||||
if known_acp_provider(&record.agent_command).is_some_and(|p| p.mcp_hooks) {
|
||||
command.env("MCP_HOOK_SERVERS", "*");
|
||||
}
|
||||
if let Some(idle) = record.idle_timeout_seconds {
|
||||
command.env("SPROUT_ACP_IDLE_TIMEOUT", idle.to_string());
|
||||
command.env("SPROUT_ACP_TURN_TIMEOUT", idle.to_string());
|
||||
@@ -729,3 +741,32 @@ pub fn stop_managed_agent_process(
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use crate::managed_agents::known_acp_provider;
|
||||
|
||||
#[test]
|
||||
fn sprout_agent_has_mcp_hooks() {
|
||||
let p = known_acp_provider("sprout-agent").expect("should resolve");
|
||||
assert!(p.mcp_hooks);
|
||||
assert_eq!(p.mcp_command, Some("sprout-dev-mcp"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn sprout_agent_resolved_via_path() {
|
||||
assert!(known_acp_provider("/usr/local/bin/sprout-agent").is_some_and(|p| p.mcp_hooks));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn goose_has_no_mcp_hooks() {
|
||||
let p = known_acp_provider("goose").expect("should resolve");
|
||||
assert!(!p.mcp_hooks);
|
||||
assert_eq!(p.mcp_command, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_command_returns_none() {
|
||||
assert!(known_acp_provider("custom-agent").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -237,6 +237,8 @@ pub struct AcpProviderInfo {
|
||||
pub command: String,
|
||||
pub binary_path: String,
|
||||
pub default_args: Vec<String>,
|
||||
/// MCP server binary override. `None` means use the default (`sprout-mcp-server`).
|
||||
pub mcp_command: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
|
||||
@@ -49,6 +49,8 @@
|
||||
"externalBin": [
|
||||
"binaries/sprout-acp",
|
||||
"binaries/sprout-mcp-server",
|
||||
"binaries/sprout-agent",
|
||||
"binaries/sprout-dev-mcp",
|
||||
"binaries/git-credential-nostr"
|
||||
],
|
||||
"icon": [
|
||||
|
||||
@@ -17,7 +17,7 @@ import type {
|
||||
|
||||
type ChannelAgentProvider = Pick<
|
||||
AcpProvider,
|
||||
"id" | "label" | "command" | "defaultArgs"
|
||||
"id" | "label" | "command" | "defaultArgs" | "mcpCommand"
|
||||
>;
|
||||
|
||||
export type AttachManagedAgentToChannelInput = {
|
||||
@@ -248,7 +248,7 @@ export async function ensureChannelAgentPresetInChannel(
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: input.provider.command,
|
||||
agentArgs: input.provider.defaultArgs,
|
||||
mcpCommand: "sprout-mcp-server",
|
||||
mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server",
|
||||
spawnAfterCreate: false,
|
||||
});
|
||||
const attached = await attachManagedAgentToChannel(channelId, {
|
||||
@@ -299,7 +299,7 @@ export async function createChannelManagedAgent(
|
||||
acpCommand: "sprout-acp",
|
||||
agentCommand: input.provider.command,
|
||||
agentArgs: input.provider.defaultArgs,
|
||||
mcpCommand: "sprout-mcp-server",
|
||||
mcpCommand: input.provider.mcpCommand ?? "sprout-mcp-server",
|
||||
personaId: input.personaId ?? undefined,
|
||||
systemPrompt: input.systemPrompt?.trim() || undefined,
|
||||
avatarUrl: resolvedAvatarUrl,
|
||||
|
||||
@@ -435,6 +435,7 @@ export function useEnsureGooseInChannelMutation(channelId: string | null) {
|
||||
label: "Goose",
|
||||
command: "goose",
|
||||
defaultArgs: ["acp"],
|
||||
mcpCommand: null,
|
||||
},
|
||||
role: "bot",
|
||||
});
|
||||
|
||||
@@ -132,6 +132,7 @@ export function AddTeamToChannelDialog({
|
||||
label: providerToUse.label,
|
||||
command: providerToUse.command,
|
||||
defaultArgs: providerToUse.defaultArgs,
|
||||
mcpCommand: providerToUse.mcpCommand,
|
||||
},
|
||||
name: persona.displayName,
|
||||
systemPrompt: persona.systemPrompt,
|
||||
|
||||
@@ -109,6 +109,7 @@ export function CreateAgentDialog({
|
||||
setSelectedProviderId(remembered.id);
|
||||
setAgentCommand(remembered.command);
|
||||
setAgentArgs(remembered.defaultArgs.join(","));
|
||||
setMcpCommand(remembered.mcpCommand ?? "sprout-mcp-server");
|
||||
} else {
|
||||
const matchingProvider =
|
||||
providers.find((provider) => provider.command === agentCommand) ?? null;
|
||||
@@ -241,6 +242,7 @@ export function CreateAgentDialog({
|
||||
setLastProvider(nextProviderId);
|
||||
setAgentCommand(provider.command);
|
||||
setAgentArgs(provider.defaultArgs.join(","));
|
||||
setMcpCommand(provider.mcpCommand ?? "sprout-mcp-server");
|
||||
}
|
||||
|
||||
function handleRunOnChange(value: string) {
|
||||
|
||||
@@ -246,6 +246,7 @@ type RawAcpProvider = {
|
||||
command: string;
|
||||
binary_path: string;
|
||||
default_args: string[];
|
||||
mcp_command: string | null;
|
||||
};
|
||||
|
||||
type RawCommandAvailability = {
|
||||
@@ -842,6 +843,7 @@ function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider {
|
||||
command: provider.command,
|
||||
binaryPath: provider.binary_path,
|
||||
defaultArgs: provider.default_args,
|
||||
mcpCommand: provider.mcp_command,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -348,6 +348,8 @@ export type AcpProvider = {
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
/** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */
|
||||
mcpCommand: string | null;
|
||||
};
|
||||
|
||||
export type CommandAvailability = {
|
||||
|
||||
@@ -17,6 +17,7 @@ type MockAcpProvider = {
|
||||
command: string;
|
||||
binaryPath: string;
|
||||
defaultArgs: string[];
|
||||
mcpCommand?: string | null;
|
||||
};
|
||||
|
||||
type MockCommandAvailability = {
|
||||
@@ -319,6 +320,7 @@ type RawAcpProvider = {
|
||||
command: string;
|
||||
binary_path: string;
|
||||
default_args: string[];
|
||||
mcp_command: string | null;
|
||||
};
|
||||
|
||||
type RawCommandAvailability = {
|
||||
@@ -3445,6 +3447,7 @@ async function handleDiscoverAcpProviders(
|
||||
command: provider.command,
|
||||
binary_path: provider.binaryPath,
|
||||
default_args: [...provider.defaultArgs],
|
||||
mcp_command: provider.mcpCommand ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -3455,6 +3458,7 @@ async function handleDiscoverAcpProviders(
|
||||
command: "goose",
|
||||
binary_path: "/usr/local/bin/goose",
|
||||
default_args: ["acp"],
|
||||
mcp_command: null,
|
||||
},
|
||||
{
|
||||
id: "codex",
|
||||
@@ -3462,6 +3466,7 @@ async function handleDiscoverAcpProviders(
|
||||
command: "codex-acp",
|
||||
binary_path: "/usr/local/bin/codex-acp",
|
||||
default_args: [],
|
||||
mcp_command: null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
4
justfile
4
justfile
@@ -92,7 +92,7 @@ _ensure-sidecar-stubs:
|
||||
set -euo pipefail
|
||||
TARGET=$(rustc -vV | sed -n 's|host: ||p')
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
for bin in sprout-acp sprout-mcp-server git-credential-nostr; do
|
||||
for bin in sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr; do
|
||||
touch "desktop/src-tauri/binaries/${bin}-${TARGET}"
|
||||
done
|
||||
|
||||
@@ -108,6 +108,8 @@ desktop-release-build target="aarch64-apple-darwin":
|
||||
mkdir -p desktop/src-tauri/binaries
|
||||
touch "desktop/src-tauri/binaries/sprout-acp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-agent-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/sprout-dev-mcp-$TARGET"
|
||||
touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET"
|
||||
pnpm install
|
||||
cd {{desktop_dir}} && pnpm tauri build --target {{target}}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
SIDECARS=(sprout-acp sprout-mcp-server git-credential-nostr)
|
||||
SIDECARS=(sprout-acp sprout-mcp-server sprout-agent sprout-dev-mcp git-credential-nostr)
|
||||
TARGET=${1:-$(rustc -vV | sed -n 's|host: ||p')}
|
||||
BINARIES_DIR="desktop/src-tauri/binaries"
|
||||
|
||||
@@ -11,7 +11,7 @@ for bin in "${SIDECARS[@]}"; do
|
||||
done
|
||||
if [[ ${#missing[@]} -gt 0 ]]; then
|
||||
echo "Error: missing release binaries: ${missing[*]}" >&2
|
||||
echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr' first." >&2
|
||||
echo "Run 'cargo build --release -p sprout-acp -p sprout-mcp -p sprout-agent -p sprout-dev-mcp -p git-credential-nostr' first." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
Reference in New Issue
Block a user