diff --git a/app/src-tauri/src/agmsg.rs b/app/src-tauri/src/agmsg.rs index bf9f3ff76..a05eec2ba 100644 --- a/app/src-tauri/src/agmsg.rs +++ b/app/src-tauri/src/agmsg.rs @@ -227,6 +227,21 @@ pub struct Member { pub types: Vec, /// First registration's project dir (used as the cwd when spawning a pane). pub project: String, + /// Every (type, project) registration this member holds, in api.sh's + /// order — `types`/`project` above stay as the flattened summary the + /// frontend already relies on; this is the detail agmsg_set_project needs + /// to target a single registration instead of every type at once. + pub registrations: Vec, +} + +/// One (type, project) pair from a member's registration list — see +/// `Member::registrations`. Field is `r#type` because the wire name from +/// api.sh is the reserved word `type`; serializes back to the frontend as +/// `type` too, matching `ApiMember`/agmsg's own JSON. +#[derive(Clone, Serialize, Deserialize)] +pub struct Registration { + pub r#type: String, + pub project: String, } /// A spawnable agent type, read from its type.conf manifest. @@ -362,13 +377,18 @@ fn parse_jsonl Deserialize<'de>>(raw: &str) -> Vec { /// Wire shape of `api.sh get teams members` — see scripts/api.sh. /// `project` is nullable there (a member with zero registrations); `Member` /// itself keeps a plain `String` for the frontend, so this is mapped rather -/// than deriving Deserialize directly on `Member`. +/// than deriving Deserialize directly on `Member`. `registrations` defaults +/// to empty so an older installed core's api.sh (predating core 1.1.13, which +/// added the field) still deserializes — existing `types`/`project` behavior +/// is unaffected either way. #[derive(Deserialize)] struct ApiMember { name: String, #[serde(default)] types: Vec, project: Option, + #[serde(default)] + registrations: Vec, } /// Wire shape of `api.sh get teams messages` — matches the @@ -548,7 +568,12 @@ pub fn agmsg_members(team: String) -> Result, String> { let mut types = m.types; types.sort(); types.dedup(); - Member { name: m.name, types, project: m.project.unwrap_or_default() } + Member { + name: m.name, + types, + project: m.project.unwrap_or_default(), + registrations: m.registrations, + } }) .collect(); members.sort_by(|a, b| a.name.cmp(&b.name)); @@ -656,6 +681,37 @@ pub fn agmsg_join( run_script("join.sh", &[&team, &name, &agent_type, &project]).map(|_| ()) } +/// Change a member's registered project dir via set-project.sh — all of the +/// member's registrations, or only one type's when `agent_type` is given. +#[tauri::command] +pub fn agmsg_set_project( + team: String, + name: String, + project: String, + agent_type: Option, +) -> Result<(), String> { + // Same MSYS->native handling as agmsg_join (#315): a project path read + // back from an existing registration arrives in MSYS form, but + // create_dir_all needs the native form or Windows builds the phantom + // C:\c\Users\... tree instead of the real one. + #[cfg(target_os = "windows")] + let project = msys_to_native(&project); + std::fs::create_dir_all(&project).map_err(|e| e.to_string())?; + let project = bash_path(std::path::Path::new(&project)); + match &agent_type { + Some(ty) => run_script("set-project.sh", &[&team, &name, &project, "--type", ty]), + None => run_script("set-project.sh", &[&team, &name, &project]), + } + .map(|_| ()) +} + +/// Whether the installed core has set-project.sh (ships in core >= 1.1.13) — +/// feature-gates the app's "change project dir" menu items on older installs. +#[tauri::command] +pub fn agmsg_supports_set_project() -> bool { + agmsg_base().join("scripts").join("set-project.sh").is_file() +} + /// Rename a member in a team (updates team config + rewrites message history). #[tauri::command] pub fn agmsg_rename(team: String, old_name: String, new_name: String) -> Result<(), String> { @@ -1004,4 +1060,76 @@ mod tests { let got = std::fs::read_to_string(dir.path().join("arg4.txt")).unwrap(); assert_eq!(got, msys_proj, "join.sh $4 should be the MSYS form"); } + + /// agmsg_set_project copies agmsg_join's MSYS->native handling verbatim + /// (see there) — the same #315 guarantee applies: create_dir_all must + /// build the NATIVE dir, not the phantom C:\c\Users\... tree Windows + /// would derive from an unconverted MSYS project path. The fake + /// set-project.sh result is ignored so a bash hiccup can't mask the + /// create_dir_all check. + #[test] + #[serial] + #[cfg(target_os = "windows")] + fn agmsg_set_project_creates_the_native_dir_not_the_phantom() { + let _base = fake_base(&[("set-project.sh", "exit 0")]); + let tmp = tempfile::tempdir().unwrap(); + let native_proj = tmp.path().join("agmsg-agents").join("alice"); + // MSYS form, as a Windows registration stores it. + let msys_proj = to_bash_slashes(&native_proj.to_string_lossy()); + let _ = super::agmsg_set_project("t".into(), "alice".into(), msys_proj, None); + assert!( + native_proj.is_dir(), + "agmsg_set_project must create the native dir, not a phantom C:\\c\\Users\\... tree", + ); + } + + /// End to end through a fake set-project.sh: the native dir is created, + /// the project ($3) arrives in MSYS form (storage/identity keys stay MSYS + /// while the filesystem side is native, same as join.sh's $4), and + /// `--type ` is appended only when agent_type is Some. + #[test] + #[serial] + #[cfg(target_os = "windows")] + fn agmsg_set_project_passes_msys_form_and_type_flag() { + let dir = tempfile::tempdir().unwrap(); + // Forward-slash base so both Rust (agmsg_base) and Git Bash ($AGMSG_APP_BASE + // expansion / redirect) accept it — a native backslash path gets mangled by + // MSYS argv/redirect handling. + let base = dir.path().to_string_lossy().replace('\\', "/"); + let sdir = dir.path().join("scripts"); + std::fs::create_dir_all(&sdir).unwrap(); + std::fs::write( + sdir.join("set-project.sh"), + "#!/usr/bin/env bash\nprintf '%s\\n' \"$@\" > \"$AGMSG_APP_BASE/args.txt\"\n", + ) + .unwrap(); + let _env = EnvGuard::set("AGMSG_APP_BASE", &base); + + let native_proj = dir.path().join("agmsg-agents").join("bob"); + let msys_proj = to_bash_slashes(&native_proj.to_string_lossy()); + + super::agmsg_set_project("t".into(), "bob".into(), msys_proj.clone(), None) + .expect("set-project should succeed"); + assert!(native_proj.is_dir(), "native project dir should be created"); + let got = std::fs::read_to_string(dir.path().join("args.txt")).unwrap(); + assert_eq!( + got.lines().collect::>(), + ["t", "bob", msys_proj.as_str()], + "project arg ($3) should be the MSYS form; --type absent when agent_type is None", + ); + + super::agmsg_set_project( + "t".into(), + "bob".into(), + msys_proj.clone(), + Some("claude-code".into()), + ) + .expect("set-project with --type should succeed"); + let got = std::fs::read_to_string(dir.path().join("args.txt")).unwrap(); + assert_eq!( + got.lines().collect::>(), + ["t", "bob", msys_proj.as_str(), "--type", "claude-code"], + "--type should be appended when agent_type is Some", + ); + } } diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index 23e8b2620..7e5bbf9a2 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -644,6 +644,8 @@ pub fn run() { agmsg::agmsg_messages, agmsg::agmsg_send, agmsg::agmsg_join, + agmsg::agmsg_set_project, + agmsg::agmsg_supports_set_project, agmsg::agmsg_rename, agmsg::agmsg_leave, agmsg::agmsg_delivery_mode, diff --git a/app/src/App.tsx b/app/src/App.tsx index 68fc2dbee..223816bb2 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -24,6 +24,7 @@ import { MAX_TERMINAL_FONT_SIZE, MIN_TERMINAL_FONT_SIZE, NewTeamModal, + ProjectDirModal, RenameModal, SettingsModal, } from "./modals"; @@ -53,7 +54,15 @@ import { PulseDot } from "./pulseSync"; import { resolveActiveTab } from "./tabMemory"; import "./App.css"; -export type Member = { name: string; types: string[]; project: string }; +export type Member = { + name: string; + types: string[]; + project: string; + // Per-type registrations — empty on an older core whose api.sh predates + // this field. Distinct from `project` (the FIRST registration's project, + // which may belong to a different type than the one being edited). + registrations: { type: string; project: string }[]; +}; type Message = { id: number; team: string; @@ -224,6 +233,7 @@ type Modal = | { kind: "appuser"; auto: boolean } | { kind: "rename"; current: string } | { kind: "leave"; name: string } + | { kind: "projectDir"; team: string; name: string; current: string; agentType?: string } | { kind: "settings" } | { kind: "closeWindow"; windowId: string } | { kind: "closePane"; paneId: string } @@ -351,6 +361,10 @@ export default function App() { const [newMenu, setNewMenu] = useState(false); const [cmdName, setCmdName] = useState("agmsg"); const [spawnTypes, setSpawnTypes] = useState([]); + // Whether the installed core has set-project.sh (core 1.1.13+) — feature- + // gates the "Change project directory…" context-menu items below, same + // pattern as coreOutdated already gates the banner. + const [supportsSetProject, setSupportsSetProject] = useState(false); const [sidebarWidth, setSidebarWidth] = useState(200); const [chatHeight, setChatHeight] = useState(160); // Terminal font size, adjustable from the Settings modal and persisted @@ -425,6 +439,8 @@ export default function App() { const [memberMenu, setMemberMenu] = useState<{ member: Member; x: number; y: number } | null>( null, ); + // Right-click context menu over a team row in the sidebar: { team, x, y }. + const [teamMenu, setTeamMenu] = useState<{ team: string; x: number; y: number } | null>(null); // Right-click context menu over a pane's header: { paneId, windowId, x, y }. const [paneMenu, setPaneMenu] = useState<{ paneId: string; @@ -451,6 +467,7 @@ export default function App() { const closeAllMenus = useCallback(() => { setNewMenu(false); setMemberMenu(null); + setTeamMenu(null); setPaneMenu(null); setWindowMenu(null); setRoomMenu(null); @@ -620,7 +637,15 @@ export default function App() { const appUserMember = members.find((m) => m.types.includes(APP_USER_TYPE)); const appUser = appUserMember?.name ?? ""; // The team's project dir (the app-user's) — new agents default into the same place. - const teamProject = appUserMember?.project ?? ""; + // Prefers the app-user's own registration (accurate when the same name also + // has other-type registrations); falls back to `project` for an older core + // whose api.sh doesn't return `registrations` yet. Display-only default, so + // that fallback is fine here — unlike the team-menu edit flow below, which + // must never guess. + const teamProject = + appUserMember?.registrations.find((r) => r.type === APP_USER_TYPE)?.project ?? + appUserMember?.project ?? + ""; // Everyone else is a spawnable/messageable agent. const others = members.filter((m) => !m.types.includes(APP_USER_TYPE)); // The app user's own send/receive thread. @@ -775,6 +800,13 @@ export default function App() { invoke("agmsg_spawnable_types").then(setSpawnTypes).catch(() => {}); }, []); + // Feature-gate for the project-dir edit context-menu items — false (hidden) + // until proven otherwise, so an older core without set-project.sh never + // shows an action that would fail. + useEffect(() => { + invoke("agmsg_supports_set_project").then(setSupportsSetProject).catch(() => {}); + }, []); + // First load: teams. If there are none, the first-run flow opens New Team. // If agmsg isn't installed at all, install the bundled copy first (no // network — see agmsg_install) and retry once; only a genuine failure @@ -1636,6 +1668,20 @@ export default function App() { [team, loadMembers], ); + // The modal carries its own team (a right-clicked team row need not be the + // active one), so that — not the outer `team` — is what's passed to the + // backend. Only the currently active team's roster is ever rendered, so + // reloading always targets `team` regardless of which team the modal edited + // (a no-op refresh when they differ, since that team's list didn't change). + const onSetProject = useCallback( + async (modalTeam: string, name: string, project: string, agentType?: string) => { + await invoke("agmsg_set_project", { team: modalTeam, name, project, agentType }); + await loadMembers(team); + setModal(null); + }, + [team, loadMembers], + ); + const onLeave = useCallback( async (name: string) => { const pane = panesRef.current.find((p) => p.label === name); @@ -2021,6 +2067,18 @@ export default function App() { className={teamName === team ? "team-status-row active" : "team-status-row"} title={`${teamName}: ${status} (open panes)`} onClick={() => setTeam(teamName)} + onContextMenu={(e) => { + // Older core with no set-project.sh: this menu has + // exactly one item and it's always the project-dir + // edit, so there's nothing to show at all — leave the + // native context menu/no-op alone instead of opening + // an empty (or permanently-disabled) agmsg menu. + if (!supportsSetProject) return; + e.preventDefault(); + e.stopPropagation(); + closeAllMenus(); + setTeamMenu({ team: teamName, x: e.clientX, y: e.clientY }); + }} > @@ -2571,6 +2629,21 @@ export default function App() { {modal?.kind === "rename" && ( setModal(null)} /> )} + {modal?.kind === "projectDir" && ( + onSetProject(modal.team, modal.name, project, modal.agentType)} + onClose={() => setModal(null)} + browseDir={browseDir} + /> + )} {modal?.kind === "leave" && ( {t("ctxMenu.member.rename")} + {supportsSetProject && ( + + )} + + ); + })()} + {paneMenu && (() => { const sourceWindow = windows.find((w) => w.id === paneMenu.windowId); diff --git a/app/src/i18n/locales/de.json b/app/src/i18n/locales/de.json index 0729955f4..26e274c9a 100644 --- a/app/src/i18n/locales/de.json +++ b/app/src/i18n/locales/de.json @@ -138,14 +138,24 @@ "title": "{{name}} schließen?", "body": "Dadurch wird {{name}} gestoppt. Dies kann nicht rückgängig gemacht werden.", "confirmLabel": "Panel schließen" + }, + "projectDir": { + "titleTeam": "Projektverzeichnis — {{team}}", + "titleAgent": "Projektverzeichnis — {{name}}", + "note": "Wird erst beim nächsten Start des Agenten wirksam. Bereits laufende Bereiche behalten ihr aktuelles Verzeichnis.", + "save": "Speichern" } }, "ctxMenu": { + "team": { + "setProject": "Projektverzeichnis ändern…" + }, "member": { "spawnTo": "Starten in ▸", "spawnNewTab": "Neuer Tab", "existingTabsDivider": "— vorhandene Tabs —", "rename": "Umbenennen…", + "setProject": "Projektverzeichnis ändern…", "leave": "Verlassen" }, "pane": { diff --git a/app/src/i18n/locales/en.json b/app/src/i18n/locales/en.json index d24b8502f..11d2422cd 100644 --- a/app/src/i18n/locales/en.json +++ b/app/src/i18n/locales/en.json @@ -148,14 +148,24 @@ "title": "Close {{name}}?", "body": "This stops {{name}}. This can't be undone.", "confirmLabel": "Close pane" + }, + "projectDir": { + "titleTeam": "Project directory — {{team}}", + "titleAgent": "Project directory — {{name}}", + "note": "Takes effect the next time the agent is spawned. Already-running panes keep their current directory.", + "save": "Save" } }, "ctxMenu": { + "team": { + "setProject": "Change project directory…" + }, "member": { "spawnTo": "Spawn to ▸", "spawnNewTab": "New tab", "existingTabsDivider": "— existing tabs —", "rename": "Rename…", + "setProject": "Change project directory…", "leave": "Leave" }, "pane": { diff --git a/app/src/i18n/locales/es.json b/app/src/i18n/locales/es.json index ee6100720..3aeab4b3e 100644 --- a/app/src/i18n/locales/es.json +++ b/app/src/i18n/locales/es.json @@ -138,14 +138,24 @@ "title": "¿Cerrar {{name}}?", "body": "Esto detendrá {{name}}. No se puede deshacer.", "confirmLabel": "Cerrar panel" + }, + "projectDir": { + "titleTeam": "Directorio del proyecto — {{team}}", + "titleAgent": "Directorio del proyecto — {{name}}", + "note": "Los cambios surten efecto la próxima vez que se inicie el agente. Los paneles ya en ejecución conservan su directorio actual.", + "save": "Guardar" } }, "ctxMenu": { + "team": { + "setProject": "Cambiar directorio del proyecto…" + }, "member": { "spawnTo": "Iniciar en ▸", "spawnNewTab": "Nueva pestaña", "existingTabsDivider": "— pestañas existentes —", "rename": "Renombrar…", + "setProject": "Cambiar directorio del proyecto…", "leave": "Quitar" }, "pane": { diff --git a/app/src/i18n/locales/fr.json b/app/src/i18n/locales/fr.json index 09504325f..8c79e77c5 100644 --- a/app/src/i18n/locales/fr.json +++ b/app/src/i18n/locales/fr.json @@ -138,14 +138,24 @@ "title": "Fermer {{name}} ?", "body": "Cela arrêtera {{name}}. Cette action est irréversible.", "confirmLabel": "Fermer le panneau" + }, + "projectDir": { + "titleTeam": "Répertoire du projet — {{team}}", + "titleAgent": "Répertoire du projet — {{name}}", + "note": "Prend effet au prochain démarrage de l'agent. Les panneaux déjà en cours d'exécution conservent leur répertoire actuel.", + "save": "Enregistrer" } }, "ctxMenu": { + "team": { + "setProject": "Changer le répertoire du projet…" + }, "member": { "spawnTo": "Démarrer vers ▸", "spawnNewTab": "Nouvel onglet", "existingTabsDivider": "— onglets existants —", "rename": "Renommer…", + "setProject": "Changer le répertoire du projet…", "leave": "Quitter" }, "pane": { diff --git a/app/src/i18n/locales/ja.json b/app/src/i18n/locales/ja.json index b4d8d6721..a266478a1 100644 --- a/app/src/i18n/locales/ja.json +++ b/app/src/i18n/locales/ja.json @@ -138,14 +138,24 @@ "title": "{{name}} を閉じますか?", "body": "{{name}} が停止します。元に戻せません。", "confirmLabel": "ペインを閉じる" + }, + "projectDir": { + "titleTeam": "プロジェクトディレクトリ — {{team}}", + "titleAgent": "プロジェクトディレクトリ — {{name}}", + "note": "変更は次回起動時から有効です。実行中のペインは現在のディレクトリのまま動き続けます。", + "save": "保存" } }, "ctxMenu": { + "team": { + "setProject": "プロジェクトディレクトリを変更…" + }, "member": { "spawnTo": "起動先 ▸", "spawnNewTab": "新しいタブ", "existingTabsDivider": "— 既存のタブ —", "rename": "名前を変更…", + "setProject": "プロジェクトディレクトリを変更…", "leave": "削除" }, "pane": { diff --git a/app/src/i18n/locales/ko.json b/app/src/i18n/locales/ko.json index 05b2724b1..499695192 100644 --- a/app/src/i18n/locales/ko.json +++ b/app/src/i18n/locales/ko.json @@ -138,14 +138,24 @@ "title": "{{name}}을(를) 닫으시겠습니까?", "body": "{{name}}이(가) 중지됩니다. 되돌릴 수 없습니다.", "confirmLabel": "패널 닫기" + }, + "projectDir": { + "titleTeam": "프로젝트 디렉터리 — {{team}}", + "titleAgent": "프로젝트 디렉터리 — {{name}}", + "note": "변경 사항은 에이전트가 다음에 시작될 때부터 적용됩니다. 이미 실행 중인 패널은 현재 디렉터리를 그대로 유지합니다.", + "save": "저장" } }, "ctxMenu": { + "team": { + "setProject": "프로젝트 디렉터리 변경…" + }, "member": { "spawnTo": "실행 위치 ▸", "spawnNewTab": "새 탭", "existingTabsDivider": "— 기존 탭 —", "rename": "이름 변경…", + "setProject": "프로젝트 디렉터리 변경…", "leave": "나가기" }, "pane": { diff --git a/app/src/i18n/locales/pt-BR.json b/app/src/i18n/locales/pt-BR.json index 55f86add2..b1a41a18d 100644 --- a/app/src/i18n/locales/pt-BR.json +++ b/app/src/i18n/locales/pt-BR.json @@ -138,14 +138,24 @@ "title": "Fechar {{name}}?", "body": "Isso vai parar {{name}}. Isso não pode ser desfeito.", "confirmLabel": "Fechar painel" + }, + "projectDir": { + "titleTeam": "Diretório do projeto — {{team}}", + "titleAgent": "Diretório do projeto — {{name}}", + "note": "Terá efeito na próxima vez que o agente for iniciado. Painéis já em execução mantêm o diretório atual.", + "save": "Salvar" } }, "ctxMenu": { + "team": { + "setProject": "Alterar diretório do projeto…" + }, "member": { "spawnTo": "Iniciar em ▸", "spawnNewTab": "Nova aba", "existingTabsDivider": "— abas existentes —", "rename": "Renomear…", + "setProject": "Alterar diretório do projeto…", "leave": "Sair" }, "pane": { diff --git a/app/src/i18n/locales/zh-CN.json b/app/src/i18n/locales/zh-CN.json index a007f04de..9bfb47926 100644 --- a/app/src/i18n/locales/zh-CN.json +++ b/app/src/i18n/locales/zh-CN.json @@ -138,14 +138,24 @@ "title": "关闭 {{name}}?", "body": "这将停止 {{name}}。此操作无法撤销。", "confirmLabel": "关闭面板" + }, + "projectDir": { + "titleTeam": "项目目录 — {{team}}", + "titleAgent": "项目目录 — {{name}}", + "note": "更改将在该智能体下次启动时生效。已在运行的面板会保留当前目录。", + "save": "保存" } }, "ctxMenu": { + "team": { + "setProject": "更改项目目录…" + }, "member": { "spawnTo": "启动到 ▸", "spawnNewTab": "新标签页", "existingTabsDivider": "— 现有标签页 —", "rename": "重命名…", + "setProject": "更改项目目录…", "leave": "离开" }, "pane": { diff --git a/app/src/i18n/locales/zh-TW.json b/app/src/i18n/locales/zh-TW.json index c2b3d7964..1fdb02c27 100644 --- a/app/src/i18n/locales/zh-TW.json +++ b/app/src/i18n/locales/zh-TW.json @@ -138,14 +138,24 @@ "title": "關閉 {{name}}?", "body": "這將停止 {{name}}。此操作無法復原。", "confirmLabel": "關閉面板" + }, + "projectDir": { + "titleTeam": "專案目錄 — {{team}}", + "titleAgent": "專案目錄 — {{name}}", + "note": "變更將於該代理下次啟動時生效。已在執行中的面板會維持目前的目錄。", + "save": "儲存" } }, "ctxMenu": { + "team": { + "setProject": "變更專案目錄…" + }, "member": { "spawnTo": "啟動至 ▸", "spawnNewTab": "新分頁", "existingTabsDivider": "— 現有分頁 —", "rename": "重新命名…", + "setProject": "變更專案目錄…", "leave": "離開" }, "pane": { diff --git a/app/src/modals.tsx b/app/src/modals.tsx index 367c0ff7e..ab409defe 100644 --- a/app/src/modals.tsx +++ b/app/src/modals.tsx @@ -316,6 +316,67 @@ export function AgentModal(props: { ); } +export function ProjectDirModal(props: { + title: string; + current: string; + onSave: (project: string) => Promise; + onClose: () => void; + browseDir: BrowseDir; +}) { + const { t } = useTranslation(); + // Plain controlled input, not useDefaultProject — this field is editing an + // EXISTING registration's project, so it must never re-derive a default + // from a name the way the add-member modals do. + const [project, setProject] = useState(props.current); + const [err, setErr] = useState(""); + const trimmed = project.trim(); + const ready = trimmed !== "" && trimmed !== props.current; + const submit = async () => { + if (!ready) return; + try { + await props.onSave(trimmed); + } catch (e) { + setErr(String(e)); + } + }; + return ( + +

{t("modal.projectDir.note")}

+
{ + e.preventDefault(); + submit(); + }} + > + + {err &&
{err}
} +
+ + +
+
+
+ ); +} + export function RenameModal(props: { current: string; onRename: (current: string, next: string) => Promise; diff --git a/docs/building-on-agmsg.md b/docs/building-on-agmsg.md index f70581bc7..e47a835fb 100644 --- a/docs/building-on-agmsg.md +++ b/docs/building-on-agmsg.md @@ -62,6 +62,7 @@ flows use: | Join a team / register an agent | `scripts/join.sh ` | | Rename an agent | `scripts/rename.sh ` | | Remove an agent from a team | `scripts/leave.sh ` | +| Change a registered agent's project dir | `scripts/set-project.sh [--type ]` | | Check delivery mode | `scripts/delivery.sh status [ ]` | | Set delivery mode | `scripts/delivery.sh set ` | diff --git a/docs/design.ja.md b/docs/design.ja.md index c712cfb67..c255e2731 100644 --- a/docs/design.ja.md +++ b/docs/design.ja.md @@ -115,6 +115,7 @@ Agent responds → Stop hook fires → check-inbox.sh runs | `history.sh` | メッセージ履歴を表示(新しい順に取得し、古い順に表示) | | `join.sh` | エージェントをチームに追加(必要ならチームを作成) | | `leave.sh` | エージェントをチームから削除(チームが空になれば削除) | +| `set-project.sh` | 登録済みエージェントのプロジェクトディレクトリを変更(全登録、または `--type` で1つだけ) | | `team.sh` | チームメンバーを一覧表示 | | `whoami.sh` | プロジェクトパスとタイプでエージェントを識別 | | `rename.sh` | 設定とメッセージ履歴内でエージェント名を変更 | diff --git a/docs/design.md b/docs/design.md index d8a8adc23..351d02d98 100644 --- a/docs/design.md +++ b/docs/design.md @@ -135,6 +135,7 @@ must track the same resolved project); direct shell invocations and | `history.sh` | Show message history (newest first, displayed oldest first) | | `join.sh` | Add agent to team (create team if needed) | | `leave.sh` | Remove agent from team (delete team if empty) | +| `set-project.sh` | Repoint a registered agent's project dir (all registrations, or one `--type`) | | `team.sh` | List team members | | `whoami.sh` | Identify agent by project path and type | | `rename.sh` | Rename agent in config and message history | diff --git a/scripts/api.sh b/scripts/api.sh index 388049a63..e31a3fa10 100755 --- a/scripts/api.sh +++ b/scripts/api.sh @@ -87,6 +87,13 @@ get_members() { SELECT json_extract(r.value, '\$.project') FROM json_each(json_extract(a.value, '\$.registrations')) AS r LIMIT 1 + ), + 'registrations', ( + SELECT json_group_array(json_object( + 'type', json_extract(r.value, '\$.type'), + 'project', json_extract(r.value, '\$.project') + )) + FROM json_each(json_extract(a.value, '\$.registrations')) AS r ) ) FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) AS a diff --git a/scripts/set-project.sh b/scripts/set-project.sh new file mode 100644 index 000000000..e6a12d159 --- /dev/null +++ b/scripts/set-project.sh @@ -0,0 +1,190 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Usage: set-project.sh [--type ] +# +# Repoints an existing agent's registered project dir. join.sh only ever ADDS a +# registration (a re-join with a different project leaves the old one in place), +# so this is the only way to move an already-registered agent to a different +# directory without leave.sh dropping the member entirely. +# +# Without --type every registration the agent has in this team moves. With +# --type only that type's registrations move, leaving the agent's other types +# where they are. +# +# The path is taken as given (only spelling-normalized): callers here are +# deliberate — a human picking a directory in the app, or an agent told to move +# a role — so this does NOT run agmsg_resolve_project, same as spawn.sh's +# explicit --project. Nor does it create the directory; join.sh doesn't either, +# and the app's own agmsg_set_project does that before calling us. + +TEAM="${1:?Usage: set-project.sh [--type ]}" +AGENT_ID="${2:?Missing agent_id}" +NEW_PROJECT="${3:?Missing new_project}" +shift 3 + +AGENT_TYPE="" +while [ $# -gt 0 ]; do + case "$1" in + --type) + AGENT_TYPE="${2:?Missing value for --type}" + shift 2 + ;; + *) + echo "Unknown option: $1" >&2 + exit 1 + ;; + esac +done + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +TEAMS_DIR="$SCRIPT_DIR/../teams" + +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/validate.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/resolve-project.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/storage.sh" +# shellcheck disable=SC1091 +source "$SCRIPT_DIR/lib/registry-lock.sh" + +agmsg_validate_team_name "$TEAM" || exit 1 +agmsg_validate_agent_name "$AGENT_ID" || exit 1 + +if [ -n "$AGENT_TYPE" ]; then + # shellcheck disable=SC1091 + source "$SCRIPT_DIR/lib/type-registry.sh" + if ! agmsg_is_known_type "$AGENT_TYPE"; then + echo "Unknown agent type: '$AGENT_TYPE' (supported: $(agmsg_known_types | sort -u | paste -sd, - | sed 's/,/, /g'))" >&2 + exit 1 + fi +fi + +NEW_PROJECT="$(agmsg_normalize_project_path "$NEW_PROJECT")" + +TEAM_DIR="$TEAMS_DIR/$TEAM" +TEAM_CONFIG="$TEAM_DIR/config.json" +if [ ! -f "$TEAM_CONFIG" ]; then + echo "Team not found: $TEAM" >&2 + exit 1 +fi + +# Serialize the read-modify-write against concurrent join/leave/rename on this +# team (#141), exactly as those scripts do. +agmsg_lock_acquire "$TEAM_DIR" || exit 1 + +# Every value below is escaped as a SQL string literal and spliced in, never +# bound via `.param set` — the sqlite3 shell's dot-command tokenizer doesn't +# honour SQL '' escaping, so a name or path containing a single quote would +# silently corrupt every later query (#87 cluster; same note in join.sh). +_agmsg_sqlesc() { printf %s "$1" | sed "s/'/''/g"; } +AGENT_ID_SQL=$(_agmsg_sqlesc "$AGENT_ID") +AGENT_TYPE_SQL=$(_agmsg_sqlesc "$AGENT_TYPE") +NEW_PROJECT_SQL=$(_agmsg_sqlesc "$NEW_PROJECT") +CONFIG_SQL=$(agmsg_sql_readfile_path "$TEAM_CONFIG") + +# Which registrations this run moves. Without --type, all of them. +if [ -n "$AGENT_TYPE" ]; then + MATCH_EXPR="json_extract(value, '\$.type') = '$AGENT_TYPE_SQL'" +else + MATCH_EXPR="1" +fi + +EXISTING=$(agmsg_sqlite_mem " + WITH cfg AS (SELECT CAST(readfile('$CONFIG_SQL') AS TEXT) AS json) + SELECT value + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) + WHERE key = '$AGENT_ID_SQL'; +") +if [ -z "$EXISTING" ] || [ "$EXISTING" = "null" ]; then + agmsg_lock_release + echo "Agent $AGENT_ID not in team $TEAM" >&2 + exit 1 +fi + +# Legacy single-registration records ({type, project} at the top level) still +# exist in the wild; normalize to the array shape first so the rewrite below +# has one form to handle (same CASE as join.sh/reset.sh). +EXISTING_ESCAPED=$(_agmsg_sqlesc "$EXISTING") +NORMALIZED=$(agmsg_sqlite_mem " + WITH agent(a) AS (SELECT '$EXISTING_ESCAPED') + SELECT CASE + WHEN json_type(json_extract(a, '\$.registrations')) = 'array' THEN a + ELSE json_object( + 'registrations', + json_array(json_object( + 'type', json_extract(a, '\$.type'), + 'project', json_extract(a, '\$.project') + )) + ) + END + FROM agent; +") +NORMALIZED_ESCAPED=$(_agmsg_sqlesc "$NORMALIZED") + +MATCH_COUNT=$(agmsg_sqlite_mem " + SELECT count(*) + FROM json_each(json_extract('$NORMALIZED_ESCAPED', '\$.registrations')) + WHERE $MATCH_EXPR; +") +if [ "${MATCH_COUNT:-0}" -eq 0 ]; then + agmsg_lock_release + if [ -n "$AGENT_TYPE" ]; then + echo "No '$AGENT_TYPE' registration for $AGENT_ID in team $TEAM" >&2 + else + echo "No registrations for $AGENT_ID in team $TEAM" >&2 + fi + exit 1 +fi + +# Rewrite the matching registrations' project, then collapse duplicates: moving +# a claude-code registration onto a path where the same agent already has one +# would otherwise leave two identical entries. GROUP BY on the rebuilt object +# text dedupes; MIN(key) keeps the surviving entries in their original order. +UPDATED_REGS=$(agmsg_sqlite_mem " + SELECT COALESCE(( + SELECT json_group_array(json(reg)) + FROM ( + SELECT json_object( + 'type', json_extract(value, '\$.type'), + 'project', CASE WHEN $MATCH_EXPR THEN '$NEW_PROJECT_SQL' + ELSE json_extract(value, '\$.project') END + ) AS reg, + MIN(key) AS ord + FROM json_each(json_extract('$NORMALIZED_ESCAPED', '\$.registrations')) + GROUP BY reg + ORDER BY ord + ) + ), json('[]')); +") +UPDATED_REGS_ESCAPED=$(_agmsg_sqlesc "$UPDATED_REGS") +AGENT_OBJ=$(agmsg_sqlite_mem " + SELECT json_set('$NORMALIZED_ESCAPED', '\$.registrations', json('$UPDATED_REGS_ESCAPED')); +") +AGENT_OBJ_ESCAPED=$(_agmsg_sqlesc "$AGENT_OBJ") + +# json_set on the '$.agents.' path (parity with leave.sh/reset.sh), NOT +# join.sh's json_patch-onto-agents form: json_patch does an RFC7396 MERGE, so +# on a legacy top-level {type,project} record it would leave those two keys +# sitting alongside the freshly-added `registrations` array instead of the +# normalization above actually taking effect. json_set replaces the agent's +# value outright. The name is concatenated into the path string rather than +# used as a JSON object key, but that's safe: agmsg_validate_agent_name +# already rejects '.', '/', '\', '"', '[', ']' (#87 cluster), so it can't be +# reinterpreted as a different path segment. +UPDATED=$(agmsg_sqlite_mem " + WITH cfg AS (SELECT CAST(readfile('$CONFIG_SQL') AS TEXT) AS json) + SELECT json_set(cfg.json, '\$.agents.' || '$AGENT_ID_SQL', json('$AGENT_OBJ_ESCAPED')) + FROM cfg; +") +if [ -z "$UPDATED" ]; then + agmsg_lock_release + echo "Failed to update $TEAM_CONFIG" >&2 + exit 1 +fi +agmsg_write_atomic "$TEAM_CONFIG" "$UPDATED" +agmsg_lock_release + +echo "Set project for $AGENT_ID in $TEAM: $NEW_PROJECT" diff --git a/tests/test_api.bats b/tests/test_api.bats index 593bbe22f..27a0d65fb 100644 --- a/tests/test_api.bats +++ b/tests/test_api.bats @@ -69,6 +69,24 @@ json_valid_line() { [ "$(json_field "$alice_line" project)" = "/tmp/project-a" ] } +@test "api: get teams members includes a per-member registrations array" { + bash "$SCRIPTS/join.sh" testteam alice codex /tmp/project-a2 + run bash "$SCRIPTS/api.sh" get teams testteam members + [ "$status" -eq 0 ] + local alice_line + alice_line="$(echo "$output" | grep '"alice"')" + [ "$(sqlite_mem "SELECT json_array_length('$(printf %s "$alice_line" | sed "s/'/''/g")', '\$.registrations');")" -eq 2 ] + local first_type first_project second_type second_project + first_type="$(json_field "$alice_line" 'registrations[0].type')" + first_project="$(json_field "$alice_line" 'registrations[0].project')" + second_type="$(json_field "$alice_line" 'registrations[1].type')" + second_project="$(json_field "$alice_line" 'registrations[1].project')" + [ "$first_type" = "claude-code" ] + [ "$first_project" = "/tmp/project-a" ] + [ "$second_type" = "codex" ] + [ "$second_project" = "/tmp/project-a2" ] +} + @test "api: get teams members is empty for a nonexistent team" { run bash "$SCRIPTS/api.sh" get teams ghost-team members [ "$status" -eq 0 ] diff --git a/tests/test_team.bats b/tests/test_team.bats index 11d89d230..f2efaf990 100644 --- a/tests/test_team.bats +++ b/tests/test_team.bats @@ -137,6 +137,202 @@ EOF [ ! -e "$TEST_SKILL_DIR/teams/myteam/.config.lock" ] } +# --- set-project.sh --- + +# Prints 's registered project paths in , one per line, in +# registration order. Looks the agent up by `key = ''` in json_each +# over $.agents (same shape set-project.sh's own EXISTING query uses) rather +# than concatenating the name into a '$.agents.' JSON path, so a quote +# in the agent name can't misroute this helper either. +projects_of() { + local team="$1" agent="$2" cfg agent_sql + cfg="$TEST_SKILL_DIR/teams/$team/config.json" + agent_sql="$(printf '%s' "$agent" | sed "s/'/''/g")" + sqlite_mem " + WITH cfg AS (SELECT CAST(readfile('$(rf "$cfg")') AS TEXT) AS json), + agent AS ( + SELECT value AS a + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) + WHERE key = '$agent_sql' + ) + SELECT json_extract(r.value, '\$.project') + FROM agent, json_each(json_extract(agent.a, '\$.registrations')) AS r + ORDER BY r.key; + " +} + +# Same lookup, filtered to a single registration type — for assertions that a +# --type-scoped set-project left a specific type's project alone (or moved +# it), independent of the agent's other registrations. +project_of_type() { + local team="$1" agent="$2" type="$3" cfg agent_sql type_sql + cfg="$TEST_SKILL_DIR/teams/$team/config.json" + agent_sql="$(printf '%s' "$agent" | sed "s/'/''/g")" + type_sql="$(printf '%s' "$type" | sed "s/'/''/g")" + sqlite_mem " + WITH cfg AS (SELECT CAST(readfile('$(rf "$cfg")') AS TEXT) AS json), + agent AS ( + SELECT value AS a + FROM cfg, json_each(json_extract(cfg.json, '\$.agents')) + WHERE key = '$agent_sql' + ) + SELECT json_extract(r.value, '\$.project') + FROM agent, json_each(json_extract(agent.a, '\$.registrations')) AS r + WHERE json_extract(r.value, '\$.type') = '$type_sql'; + " +} + +@test "set-project: repoints an existing registration" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-old + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-new + [ "$status" -eq 0 ] + [[ "$output" =~ "Set project for alice in myteam: /tmp/proj-new" ]] + local projs; projs="$(projects_of myteam alice)" + [[ "$projs" =~ "/tmp/proj-new" ]] + [[ ! "$projs" =~ "/tmp/proj-old" ]] +} + +@test "set-project: --type omitted moves every registration" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + bash "$SCRIPTS/join.sh" myteam alice codex /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b + [ "$status" -eq 0 ] + local projs; projs="$(projects_of myteam alice)" + [ "$(echo "$projs" | wc -l | tr -d ' ')" -eq 2 ] + [[ ! "$projs" =~ "/tmp/proj-a" ]] + [ "$(echo "$projs" | grep -c "/tmp/proj-b")" -eq 2 ] +} + +@test "set-project: --type moves only that type's registrations, others untouched" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + bash "$SCRIPTS/join.sh" myteam alice codex /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b --type codex + [ "$status" -eq 0 ] + [ "$(project_of_type myteam alice codex)" = "/tmp/proj-b" ] + [ "$(project_of_type myteam alice claude-code)" = "/tmp/proj-a" ] +} + +@test "set-project: --type agmsg-app moves only the app-user registration when a name is shared with another type (Codex review scenario)" { + bash "$SCRIPTS/join.sh" myteam shared agmsg-app /tmp/proj-app + bash "$SCRIPTS/join.sh" myteam shared claude-code /tmp/proj-agent + run bash "$SCRIPTS/set-project.sh" myteam shared /tmp/proj-app-new --type agmsg-app + [ "$status" -eq 0 ] + [ "$(project_of_type myteam shared agmsg-app)" = "/tmp/proj-app-new" ] + [ "$(project_of_type myteam shared claude-code)" = "/tmp/proj-agent" ] +} + +@test "set-project: collapses a registration that lands on an existing identical one" { + bash "$SCRIPTS/join.sh" myteam alice codex /tmp/proj-a + bash "$SCRIPTS/join.sh" myteam alice codex /tmp/proj-b + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-a --type codex + [ "$status" -eq 0 ] + local projs; projs="$(projects_of myteam alice)" + [ "$(echo "$projs" | wc -l | tr -d ' ')" -eq 1 ] + [ "$projs" = "/tmp/proj-a" ] +} + +@test "set-project: leaves other members untouched" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + bash "$SCRIPTS/join.sh" myteam bob claude-code /tmp/proj-b + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-new + [ "$status" -eq 0 ] + [ "$(projects_of myteam bob)" = "/tmp/proj-b" ] +} + +@test "set-project: fails on unknown team" { + run bash "$SCRIPTS/set-project.sh" ghostteam alice /tmp/proj + [ "$status" -ne 0 ] + [[ "$output" =~ "Team not found" ]] +} + +@test "set-project: fails when agent is not in team" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam ghost /tmp/proj-b + [ "$status" -ne 0 ] + [[ "$output" =~ "not in team" ]] +} + +@test "set-project: fails when --type matches no registration, config unchanged" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + local cfg="$TEST_SKILL_DIR/teams/myteam/config.json" before + before="$(cat "$cfg")" + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b --type codex + [ "$status" -ne 0 ] + [[ "$output" =~ "No 'codex' registration" ]] + [ "$(cat "$cfg")" = "$before" ] +} + +@test "set-project: rejects unknown --type" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b --type bogus + [ "$status" -ne 0 ] + [[ "$output" =~ "Unknown agent type" ]] +} + +@test "set-project: an agent name containing a single quote doesn't break the underlying SQL statement (#87-class)" { + local agent="al'ice" + bash "$SCRIPTS/join.sh" myteam "$agent" claude-code /tmp/proj-old + run bash "$SCRIPTS/set-project.sh" myteam "$agent" /tmp/proj-new + [ "$status" -eq 0 ] + [[ ! "$output" =~ "syntax error" ]] + [[ ! "$output" =~ ".parameter" ]] + [ "$(projects_of myteam "$agent")" = "/tmp/proj-new" ] +} + +@test "set-project: a project path containing a single quote survives the round trip" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-old + local project="$TEST_SKILL_DIR/pro'j" + run bash "$SCRIPTS/set-project.sh" myteam alice "$project" + [ "$status" -eq 0 ] + [[ "$output" =~ "$project" ]] + [ "$(projects_of myteam alice)" = "$project" ] +} + +@test "set-project: rejects an agent name containing path-hazard characters" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam "al.ice" /tmp/proj-b + [ "$status" -ne 0 ] + [[ "$output" =~ "must not contain" ]] +} + +@test "set-project: releases its lock on success (no .config.lock left behind)" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b + [ ! -e "$TEST_SKILL_DIR/teams/myteam/.config.lock" ] +} + +@test "set-project: releases its lock when the agent isn't in the team" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam ghost /tmp/proj-b + [ "$status" -ne 0 ] + [ ! -e "$TEST_SKILL_DIR/teams/myteam/.config.lock" ] +} + +@test "set-project: releases its lock when --type matches no registration" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-a + run bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-b --type codex + [ "$status" -ne 0 ] + [ ! -e "$TEST_SKILL_DIR/teams/myteam/.config.lock" ] +} + +@test "set-project: whoami.sh resolves the agent at the new project and no longer at the old one" { + bash "$SCRIPTS/join.sh" myteam alice claude-code /tmp/proj-old + bash "$SCRIPTS/set-project.sh" myteam alice /tmp/proj-new + clear_autodetect_env + mock_no_agent_ps + run bash "$SCRIPTS/whoami.sh" /tmp/proj-new claude-code + [ "$status" -eq 0 ] + [[ "$output" =~ "agent=alice" ]] + # Not an exact match at the old path any more. alice is still a registered + # claude-code agent (just elsewhere now), so this legitimately falls into + # whoami's "suggest=true agents=alice ..." shape rather than + # "not_joined=true" -- the contract this asserts is just that /tmp/proj-old + # no longer resolves as an exact "agent=alice" identity. + run bash "$SCRIPTS/whoami.sh" /tmp/proj-old claude-code + [ "$status" -eq 0 ] + [[ ! "$output" =~ "agent=alice" ]] +} + # --- leave.sh --- @test "leave: removes agent from team" {