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
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ Multiple agents with the same role get unique IDs automatically (`worker`, `work
| Command | Description |
|---------|-------------|
| `squad init [--refresh-roles]` | Initialize workspace, create `.squad/`, add `.squad/` to `.gitignore`, and append squad guidance to `CLAUDE.md`, `AGENTS.md`, and `GEMINI.md` if missing. `--refresh-roles` rewrites only builtin `manager`/`worker`/`inspector` files under `.squad/roles/`. |
| `squad join <id> [--role <role>] [--client <claude\|gemini\|codex\|opencode>] [--protocol-version <n>]` | Join as agent (auto-suffixes if ID is taken; omitted capability metadata stays `NULL`) |
| `squad join <id> [--role <role>] [--client <claude\|gemini\|codex\|opencode\|agy>] [--protocol-version <n>]` | Join as agent (auto-suffixes if ID is taken; omitted capability metadata stays `NULL`) |
| `squad leave <id>` | Archive agent and preserve unread work |
| `squad agents [--all] [--json]` | List online agents (`--json` emits one JSON object per line including raw/effective capability fields and protocol-derived support booleans) |
| `squad send [--task-id <id>] [--reply-to <message-id>] <from> <to> <message>` | Send a note (`@all` to broadcast, or `squad send [flags] --file <path-or-> <from> <to>` to read from file/stdin) |
Expand All @@ -152,6 +152,7 @@ Install the `/squad` slash command for your AI tools:
```bash
squad setup # auto-detect and install for all found tools
squad setup claude # install only for Claude Code
squad setup agy # install only for Antigravity
squad setup --list # show supported platforms
```

Expand All @@ -163,6 +164,7 @@ Supported platforms:
| Gemini CLI | `gemini` | `~/.gemini/commands/squad.toml` |
| Codex CLI | `codex` | `~/.codex/skills/squad/SKILL.md` |
| OpenCode | `opencode` | `~/.config/opencode/commands/squad.md` |
| Antigravity | `agy` | `~/.gemini/config/skills/squad/SKILL.md` |

Once installed, use `/squad <role>` (or `$squad <role>` in Codex) in any project where `squad init` has been run. Generated slash templates automatically join with their platform client type and the current supported protocol version.

Expand Down
4 changes: 3 additions & 1 deletion README.zh-CN.md
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,7 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run
| 命令 | 说明 |
|------|------|
| `squad init [--refresh-roles]` | 初始化工作区,创建 `.squad/`,将 `.squad/` 加入 `.gitignore`,并在缺失时向 `CLAUDE.md`、`AGENTS.md`、`GEMINI.md` 追加 squad 说明。`--refresh-roles` 只会重写 `.squad/roles/` 下内置的 `manager`/`worker`/`inspector` 文件。 |
| `squad join <id> [--role <role>] [--client <claude\|gemini\|codex\|opencode>] [--protocol-version <n>]` | 以 Agent 身份加入(ID 冲突时自动添加后缀;省略能力元数据时数据库存 `NULL`) |
| `squad join <id> [--role <role>] [--client <claude\|gemini\|codex\|opencode\|agy>] [--protocol-version <n>]` | 以 Agent 身份加入(ID 冲突时自动添加后缀;省略能力元数据时数据库存 `NULL`) |
| `squad leave <id>` | 归档 Agent,并保留未读工作 |
| `squad agents [--all] [--json]` | 列出在线 Agent(`--json` 每行输出一个 JSON 对象,包含原始/生效能力字段和基于协议版本推导的支持布尔值) |
| `squad send [--task-id <id>] [--reply-to <message-id>] <from> <to> <message>` | 发送普通消息(`@all` 广播给所有人,或用 `squad send [flags] --file <path-or-> <from> <to>` 从文件/标准输入读取内容) |
Expand All @@ -152,6 +152,7 @@ scripts/squad-tmux-launch.sh /path/to/project --dry-run
```bash
squad setup # 自动检测已安装的工具并安装
squad setup claude # 只安装到 Claude Code
squad setup agy # 只安装到 Antigravity
squad setup --list # 查看支持的平台
```

Expand All @@ -161,6 +162,7 @@ squad setup --list # 查看支持的平台
| Gemini CLI | `gemini` | `~/.gemini/commands/squad.toml` |
| Codex CLI | `codex` | `~/.codex/skills/squad/SKILL.md` |
| OpenCode | `opencode` | `~/.config/opencode/commands/squad.md` |
| Antigravity | `agy` | `~/.gemini/config/skills/squad/SKILL.md` |

安装后,在任何执行过 `squad init` 的项目中使用 `/squad <角色>`(Codex 中使用 `$squad <角色>`)即可。生成的 slash 模板会自动带上所属平台的 `client` 值和当前支持的协议版本。

Expand Down
20 changes: 12 additions & 8 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ fn main() -> Result<()> {
"join" => {
let id = args.next().unwrap_or_default();
if id.is_empty() {
bail!("Usage: squad join <id> [--role <role>] [--client <claude|gemini|codex|opencode>] [--protocol-version <n>]");
bail!("Usage: squad join <id> [--role <role>] [--client <claude|gemini|codex|opencode|agy>] [--protocol-version <n>]");
}
let options = parse_join_args(&id, args.collect())?;
cmd_join(&id, &options)
Expand Down Expand Up @@ -155,9 +155,10 @@ fn parse_join_args(id: &str, args: Vec<String>) -> Result<JoinOptions> {
bail!("--client requires a value");
}
match value.as_str() {
"claude" | "gemini" | "codex" | "opencode" => {
"claude" | "gemini" | "codex" | "opencode" | "agy" => {
options.client_type = Some(value.clone())
}
"antigravity" => options.client_type = Some("agy".to_string()),
_ => bail!("invalid --client value: {value}"),
}
i += 2;
Expand Down Expand Up @@ -1180,10 +1181,13 @@ fn cmd_doctor() -> Result<()> {
let store = open_store(&workspace)?;

// 1. Template diagnostics
let home = PathBuf::from(std::env::var("HOME").context("HOME not set")?);
let home = std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.context("HOME or USERPROFILE not set")?;
let installed_platforms: Vec<&squad::setup::Platform> = squad::setup::PLATFORMS
.iter()
.filter(|p| squad::setup::is_installed(p.binary))
.filter(|p| squad::setup::is_platform_installed(p))
.collect();
let template_diags =
squad::setup::diagnose_templates_for_platforms(&installed_platforms, &home)?;
Expand Down Expand Up @@ -1229,7 +1233,7 @@ fn cmd_setup(target: Option<&str>) -> Result<()> {
Some("--list") => {
println!("Supported platforms:");
for p in squad::setup::PLATFORMS {
let status = if squad::setup::is_installed(p.binary) {
let status = if squad::setup::is_platform_installed(p) {
"installed"
} else {
"not found"
Expand All @@ -1241,7 +1245,7 @@ fn cmd_setup(target: Option<&str>) -> Result<()> {
Some(name) => {
let platform = squad::setup::PLATFORMS
.iter()
.find(|p| p.name == name)
.find(|p| p.name == name || (p.name == "agy" && name == "antigravity"))
.with_context(|| format!("unknown platform: {name}. Run 'squad setup --list'"))?;
let path = squad::setup::install_for_platform(platform)?;
println!("Installed squad for {} → {}", name, path.display());
Expand All @@ -1252,7 +1256,7 @@ fn cmd_setup(target: Option<&str>) -> Result<()> {
let results = squad::setup::run_setup();
if results.is_empty() {
println!("No supported AI tools found in PATH.");
println!("Supported: claude, gemini, codex, opencode");
println!("Supported: claude, gemini, codex, opencode, agy");
return Ok(());
}
for (name, path, result) in &results {
Expand All @@ -1276,7 +1280,7 @@ const HELP_TEXT: &str = r#"squad — Multi-AI-agent terminal collaboration

COMMANDS
squad init [--refresh-roles] Initialize workspace (`--refresh-roles` rewrites builtin roles only)
squad join <id> [--role <role>] [--client <claude|gemini|codex|opencode>] [--protocol-version <n>]
squad join <id> [--role <role>] [--client <claude|gemini|codex|opencode|agy>] [--protocol-version <n>]
Join as agent (role defaults to id; omitted metadata stays NULL)
squad leave <id> Archive agent
squad agents [--all] [--json] List online agents (`--all` includes archived agents; `--json` emits one JSON object per line with raw/effective capability fields)
Expand Down
89 changes: 86 additions & 3 deletions src/setup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -36,8 +36,80 @@ pub const PLATFORMS: &[Platform] = &[
command_path: ".config/opencode/commands/squad.md",
content: SQUAD_MD_CONTENT,
},
Platform {
name: "agy",
binary: "agy",
command_path: ".gemini/config/skills/squad/SKILL.md",
content: SQUAD_AGY_CONTENT,
},
];

/// Antigravity Skills format (uses $ARGUMENTS, placed in ~/.gemini/config/skills/squad/SKILL.md)
pub const SQUAD_AGY_CONTENT: &str = r#"---
name: squad
description: "Join squad multi-agent collaboration. Usage: /squad <role> [custom-id]"
---

You are joining a squad multi-agent collaboration team.

Your join arguments: $ARGUMENTS

**IMPORTANT:** Do NOT run `squad $ARGUMENTS` or treat the arguments as a CLI subcommand. Instead, follow the setup steps below.

## Phase 1: Setup (do this once)

1. Parse your join arguments above.

**If arguments are empty or missing:**
Run `squad roles` to list available roles, then ask the user which role they want to join as. Do NOT proceed until the user picks a role.

**If arguments look like a role name** (1-2 words, e.g. "cto", "worker worker-2"):
- First word is your role — this can be ANY string: "cto", "ceo", "manager", "reviewer", etc. It does NOT need to appear in `squad roles` (that list only shows predefined templates).
- Optional second word is a custom agent ID
- If no custom ID provided, use the role name as your ID
- Examples: "manager" → id=manager, role=manager | "worker worker-2" → id=worker-2, role=worker | "cto" → id=cto, role=cto

**If arguments look like natural language** (e.g. "加入团队,作为管理员", "join as tech lead and review PRs"):
- Extract the intended role from the text. Pick a short English role name (e.g. "manager", "reviewer", "cto").
- Use that as your role and ID.
- If no role can be inferred, ask the user to clarify.

2. Run `squad init` (safe to run — won't overwrite existing workspace).

3. **Clean up stale agents from previous sessions:**
Run `squad agents` and check the output.
- If ALL agents show "stale" (no active agents), tell the user stale squad state was detected and ask the user whether they want to reset squad state with `squad clean` followed by `squad init`. Do NOT clean automatically.
- If some agents are active (a team is already running), skip cleanup and proceed.

4. Run `squad join <id> --role <role> --client __SQUAD_CLIENT__ --protocol-version __SQUAD_PROTOCOL_VERSION__` to register yourself.
- Read the output line that says "Joined as ..." — that confirms your actual agent ID.
- If the ID was taken, squad auto-assigns a suffixed ID (e.g. worker-2). Use that ID for all commands.
- If role instructions are printed (=== Role Instructions ===), follow them.
- If no predefined template exists, interpret the role using your own knowledge.

5. Run `squad agents` to see who else is on the team.

6. **If any squad command returns "Session replaced":** another terminal took your ID. Re-join with a different ID (e.g. `squad join worker-2 --role worker --client __SQUAD_CLIENT__ --protocol-version __SQUAD_PROTOCOL_VERSION__`).

## Phase 2: Enter Receive Mode (MANDATORY)

**Immediately after setup, run `squad receive <your-id> --wait` to start listening for messages.** Do NOT wait for the user to tell you — enter receive mode now.

After receiving a message:
1. Execute the task or respond as appropriate for your role.
2. Report results using `squad send` or `squad task` commands.
3. Run `squad receive <your-id> --wait` again to wait for the next message.

If receive times out with no messages, run it again immediately.

Other useful commands:
- `squad send <your-id> <to> "<message>"` — send a message (use @all to broadcast)
- `squad task create <your-id> <to> --title "<title>"` — create a structured task
- `squad agents` — see who is online
- `squad pending` — check all unread messages
- `squad history` — view message history
"#;

/// Codex Skills format (uses $ARGUMENTS, placed in ~/.agents/skills/squad/SKILL.md)
pub const SQUAD_CODEX_CONTENT: &str = r#"---
name: squad
Expand Down Expand Up @@ -308,7 +380,7 @@ pub fn check_and_update_commands() -> Vec<(String, PathBuf)> {
let version = current_version();
let mut updated = Vec::new();
for platform in PLATFORMS {
if !is_installed(platform.binary) {
if !is_platform_installed(platform) {
continue;
}
let path = match command_path(platform) {
Expand Down Expand Up @@ -442,11 +514,22 @@ fn is_executable(path: &Path) -> bool {
path.is_file()
}

/// Check if a platform's binary (or supported alias) is installed.
pub fn is_platform_installed(platform: &Platform) -> bool {
if is_installed(platform.binary) {
return true;
}
if platform.name == "agy" && is_installed("antigravity") {
return true;
}
false
}

/// Detect which platforms are installed.
pub fn detect_platforms() -> Vec<&'static Platform> {
PLATFORMS
.iter()
.filter(|p| is_installed(p.binary))
.filter(|p| is_platform_installed(p))
.collect()
}

Expand Down Expand Up @@ -480,7 +563,7 @@ pub fn install_for_platform(platform: &Platform) -> Result<PathBuf> {
pub fn run_setup() -> Vec<(String, PathBuf, Result<()>)> {
let mut results = Vec::new();
for platform in PLATFORMS {
if !is_installed(platform.binary) {
if !is_platform_installed(platform) {
continue;
}
match install_for_platform(platform) {
Expand Down
45 changes: 45 additions & 0 deletions tests/cli_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,48 @@ fn test_agents_human_output_shows_capability_metadata_when_present() {
.stdout(predicate::str::contains("protocol: 2"));
}

#[test]
fn test_join_with_agy_and_antigravity_client() {
let tmp = TempDir::new().unwrap();
squad(tmp.path()).arg("init").assert().success();

squad(tmp.path())
.args([
"join",
"agy-agent",
"--role",
"worker",
"--client",
"agy",
"--protocol-version",
"2",
])
.assert()
.success();

squad(tmp.path())
.args([
"join",
"antigravity-agent",
"--role",
"manager",
"--client",
"antigravity",
"--protocol-version",
"2",
])
.assert()
.success();

squad(tmp.path())
.arg("agents")
.assert()
.success()
.stdout(predicate::str::contains("agy-agent (role: worker) — active"))
.stdout(predicate::str::contains("client: agy"))
.stdout(predicate::str::contains("antigravity-agent (role: manager) — active"));
}

#[test]
fn test_send_and_receive() {
let tmp = TempDir::new().unwrap();
Expand Down Expand Up @@ -1434,13 +1476,16 @@ fn test_setup_templates_pass_client_and_protocol_metadata_on_join() {
let codex = command_content(PLATFORMS.iter().find(|p| p.name == "codex").unwrap());
let opencode = command_content(PLATFORMS.iter().find(|p| p.name == "opencode").unwrap());
let gemini = command_content(PLATFORMS.iter().find(|p| p.name == "gemini").unwrap());
let agy = command_content(PLATFORMS.iter().find(|p| p.name == "agy").unwrap());

assert!(claude.contains("--client claude"));
assert!(claude.contains("--protocol-version 2"));
assert!(codex.contains("--client codex"));
assert!(opencode.contains("--client opencode"));
assert!(gemini.contains("--client gemini"));
assert!(gemini.contains("--protocol-version 2"));
assert!(agy.contains("--client agy"));
assert!(agy.contains("--protocol-version 2"));
}

#[test]
Expand Down
42 changes: 39 additions & 3 deletions tests/setup_test.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
use squad::setup::{
command_path, current_version, diagnose_templates_for_platforms, install_command,
install_for_platform, is_installed, PLATFORMS, SQUAD_CODEX_CONTENT, SQUAD_MD_CONTENT,
SQUAD_TOML_CONTENT,
install_for_platform, is_installed, PLATFORMS, SQUAD_AGY_CONTENT, SQUAD_CODEX_CONTENT,
SQUAD_MD_CONTENT, SQUAD_TOML_CONTENT,
};
use std::sync::{Mutex, OnceLock};
use tempfile::TempDir;
Expand Down Expand Up @@ -52,12 +52,13 @@ fn lock_env() -> std::sync::MutexGuard<'static, ()> {

#[test]
fn test_platforms_defined() {
assert!(PLATFORMS.len() >= 4);
assert!(PLATFORMS.len() >= 5);
let names: Vec<&str> = PLATFORMS.iter().map(|p| p.name).collect();
assert!(names.contains(&"claude"));
assert!(names.contains(&"gemini"));
assert!(names.contains(&"codex"));
assert!(names.contains(&"opencode"));
assert!(names.contains(&"agy"));
}

#[test]
Expand Down Expand Up @@ -114,6 +115,41 @@ fn test_installed_codex_skill_frontmatter_is_valid_yaml() {
assert_eq!(parsed["squad-version"], current_version());
}

#[test]
fn test_agy_skill_frontmatter_is_valid_yaml() {
let frontmatter = SQUAD_AGY_CONTENT
.strip_prefix("---\n")
.and_then(|content| content.split_once("\n---"))
.map(|(frontmatter, _)| frontmatter)
.unwrap();

serde_yaml::from_str::<serde_yaml::Value>(frontmatter).unwrap();
}

#[test]
fn test_installed_agy_skill_frontmatter_is_valid_yaml() {
let _lock = lock_env();
let _env = EnvGuard::new();
let tmp = TempDir::new().unwrap();
std::env::set_var("HOME", tmp.path());

let agy = PLATFORMS
.iter()
.find(|platform| platform.name == "agy")
.unwrap();
let path = install_for_platform(agy).unwrap();
let content = std::fs::read_to_string(path).unwrap();
let frontmatter = content
.strip_prefix("---\n")
.and_then(|content| content.split_once("\n---"))
.map(|(frontmatter, _)| frontmatter)
.unwrap();

let parsed = serde_yaml::from_str::<serde_yaml::Value>(frontmatter).unwrap();
assert_eq!(parsed["name"], "squad");
assert_eq!(parsed["squad-version"], current_version());
}

#[test]
fn test_command_path_falls_back_to_userprofile_when_home_is_missing() {
let _lock = lock_env();
Expand Down