@@ -267,6 +267,22 @@ pub struct CliArgs {
267267 #[ arg( long, env = "BUZZ_ACP_MCP_COMMAND" , default_value = "" ) ]
268268 pub mcp_command : String ,
269269
270+ /// Persona pack directory. Requires `--persona`: the named persona's MCP
271+ /// servers are added to every ACP session and its skills are materialized
272+ /// into the working directory.
273+ #[ arg( long, env = "BUZZ_ACP_PERSONA_PACK" , requires = "persona" ) ]
274+ pub persona_pack : Option < PathBuf > ,
275+
276+ /// Persona name within `--persona-pack`.
277+ #[ arg( long, env = "BUZZ_ACP_PERSONA_NAME" , requires = "persona_pack" ) ]
278+ pub persona : Option < String > ,
279+
280+ /// Working directory for this harness and its agent subprocess. Give each
281+ /// agent its own directory to keep skills and runtime config unshared.
282+ /// Created if missing. Defaults to the process working directory.
283+ #[ arg( long, env = "BUZZ_ACP_WORKDIR" ) ]
284+ pub workdir : Option < PathBuf > ,
285+
270286 /// Idle timeout: max seconds of silence before killing a turn.
271287 /// Resets on any agent stdout activity.
272288 #[ arg( long, env = "BUZZ_ACP_IDLE_TIMEOUT" ) ]
@@ -598,6 +614,18 @@ pub struct Config {
598614 /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL, BUZZ_AGENT_MODEL).
599615 /// Populated from persona pack resolution. Empty when no pack is configured.
600616 pub persona_env_vars : Vec < ( String , String ) > ,
617+ /// MCP servers declared by the configured persona (pack `.mcp.json` merged
618+ /// with the persona's own `mcp_servers:`). Added to every ACP session
619+ /// alongside the Buzz-managed server. Empty when no pack is configured.
620+ pub persona_mcp_servers : Vec < buzz_persona:: resolve:: ResolvedMcpServer > ,
621+ /// Absolute source directories of the skills scoped to the configured
622+ /// persona — the ones it claims plus the pack's unclaimed (shared) ones.
623+ /// Materialized into the working directory at startup. Empty when no pack
624+ /// is configured.
625+ pub persona_skill_dirs : Vec < PathBuf > ,
626+ /// Working directory to switch into at startup. `None` keeps the process
627+ /// working directory as inherited.
628+ pub workdir : Option < PathBuf > ,
601629 /// Whether `codex_network_env()` successfully injected a `CODEX_CONFIG` entry into
602630 /// `persona_env_vars`. When true, `AcpClient::spawn` merges all `CODEX_CONFIG` entries
603631 /// and forces `sandbox_workspace_write.network_access = true` via `build_codex_config_env`.
@@ -796,6 +824,57 @@ fn default_agent_args(command: &str) -> Option<Vec<String>> {
796824 }
797825}
798826
827+ /// Resolve `--persona-pack` / `--persona` into the persona's MCP servers and
828+ /// the source directories of the skills scoped to it.
829+ ///
830+ /// Skill scoping is [`buzz_persona::pack::resolve_skills`]: a skill claimed by
831+ /// any persona goes only to that persona, an unclaimed one goes to all of them.
832+ ///
833+ /// Every failure is terminal. A pack that does not load, a persona that is not
834+ /// in it, or a skill directory that does not exist would otherwise start an
835+ /// agent with quietly fewer capabilities than its persona declares.
836+ fn resolve_persona_pack (
837+ pack_dir : & std:: path:: Path ,
838+ persona : & str ,
839+ ) -> Result < ( Vec < buzz_persona:: resolve:: ResolvedMcpServer > , Vec < PathBuf > ) , ConfigError > {
840+ let pack_err = |e : buzz_persona:: pack:: PackError | {
841+ ConfigError :: ConfigFile ( format ! ( "persona pack {}: {e}" , pack_dir. display( ) ) )
842+ } ;
843+
844+ let loaded = buzz_persona:: pack:: load_pack ( pack_dir) . map_err ( pack_err) ?;
845+ // Scoping reads the loaded personas; resolution reads the whole pack.
846+ let mut skills_by_persona = buzz_persona:: pack:: resolve_skills ( pack_dir, & loaded. personas ) ;
847+ let resolved = buzz_persona:: resolve:: resolve_loaded_pack ( & loaded) . map_err ( pack_err) ?;
848+
849+ let available: Vec < & str > = resolved. personas . iter ( ) . map ( |p| p. name . as_str ( ) ) . collect ( ) ;
850+ let selected = resolved
851+ . personas
852+ . iter ( )
853+ . find ( |p| p. name == persona)
854+ . ok_or_else ( || {
855+ ConfigError :: ConfigFile ( format ! (
856+ "persona '{persona}' not found in pack {} (available: {})" ,
857+ pack_dir. display( ) ,
858+ available. join( ", " )
859+ ) )
860+ } ) ?;
861+
862+ let skills_root = pack_dir. join ( "skills" ) ;
863+ let mut skill_dirs = Vec :: new ( ) ;
864+ for name in skills_by_persona. remove ( persona) . unwrap_or_default ( ) {
865+ let dir = skills_root. join ( & name) ;
866+ if !dir. is_dir ( ) {
867+ return Err ( ConfigError :: ConfigFile ( format ! (
868+ "persona '{persona}' declares skill '{name}' but {} is not a directory" ,
869+ dir. display( )
870+ ) ) ) ;
871+ }
872+ skill_dirs. push ( dir) ;
873+ }
874+
875+ Ok ( ( selected. mcp_servers . clone ( ) , skill_dirs) )
876+ }
877+
799878/// Per-runtime environment defaults applied when Buzz owns the agent process.
800879///
801880/// Mirrors [`default_agent_args`]: keyed on the normalized command identity,
@@ -1150,6 +1229,15 @@ impl Config {
11501229
11511230 validate_multiple_event_handling ( args. multiple_event_handling , args. dedup ) ?;
11521231
1232+ // Persona pack: MCP servers and skill sources for the named persona.
1233+ // `--persona-pack` and `--persona` require each other (clap), so both
1234+ // are present or neither is.
1235+ let ( persona_mcp_servers, persona_skill_dirs) =
1236+ match ( args. persona_pack . as_deref ( ) , args. persona . as_deref ( ) ) {
1237+ ( Some ( pack_dir) , Some ( persona) ) => resolve_persona_pack ( pack_dir, persona) ?,
1238+ _ => ( Vec :: new ( ) , Vec :: new ( ) ) ,
1239+ } ;
1240+
11531241 let config = Config {
11541242 keys,
11551243 relay_url : args. relay_url ,
@@ -1195,6 +1283,9 @@ impl Config {
11951283 respond_to_allowlist,
11961284 allowed_respond_to,
11971285 persona_env_vars,
1286+ persona_mcp_servers,
1287+ persona_skill_dirs,
1288+ workdir : args. workdir ,
11981289 has_generated_codex_config,
11991290 relay_observer : args. relay_observer ,
12001291 exit_after_inactivity_secs : args. exit_after_inactivity ,
@@ -1571,6 +1662,9 @@ mod tests {
15711662 respond_to_allowlist : HashSet :: new ( ) ,
15721663 allowed_respond_to : Vec :: new ( ) ,
15731664 persona_env_vars : vec ! [ ] ,
1665+ persona_mcp_servers : vec ! [ ] ,
1666+ persona_skill_dirs : vec ! [ ] ,
1667+ workdir : None ,
15741668 has_generated_codex_config : false ,
15751669 relay_observer : false ,
15761670 exit_after_inactivity_secs : 0 ,
@@ -3190,3 +3284,161 @@ channels = "ALL"
31903284 ) ;
31913285 }
31923286}
3287+
3288+ #[ cfg( test) ]
3289+ mod persona_pack_tests {
3290+ use super :: * ;
3291+ use std:: path:: Path ;
3292+
3293+ /// Write a two-persona pack: `alpha` claims the `search` skill and declares
3294+ /// its own MCP server; `review` is unclaimed. `shared` belongs to nobody,
3295+ /// so it is scoped to both.
3296+ fn write_pack ( root : & Path ) {
3297+ std:: fs:: create_dir_all ( root. join ( ".plugin" ) ) . unwrap ( ) ;
3298+ std:: fs:: write (
3299+ root. join ( ".plugin/plugin.json" ) ,
3300+ r#"{
3301+ "id": "test-pack",
3302+ "name": "Test Pack",
3303+ "version": "1.0.0",
3304+ "personas": ["personas/alpha.persona.md", "personas/beta.persona.md"]
3305+ }"# ,
3306+ )
3307+ . unwrap ( ) ;
3308+
3309+ std:: fs:: create_dir_all ( root. join ( "personas" ) ) . unwrap ( ) ;
3310+ std:: fs:: write (
3311+ root. join ( "personas/alpha.persona.md" ) ,
3312+ r#"---
3313+ name: alpha
3314+ display_name: Alpha
3315+ description: Alpha persona.
3316+ skills:
3317+ - skills/search
3318+ mcp_servers:
3319+ - name: semgrep
3320+ command: semgrep-mcp
3321+ args: ['--stdio']
3322+ env:
3323+ TOKEN: abc123
3324+ ---
3325+ You are Alpha.
3326+ "# ,
3327+ )
3328+ . unwrap ( ) ;
3329+ std:: fs:: write (
3330+ root. join ( "personas/beta.persona.md" ) ,
3331+ "---\n name: beta\n display_name: Beta\n description: Beta persona.\n ---\n You are Beta.\n " ,
3332+ )
3333+ . unwrap ( ) ;
3334+
3335+ std:: fs:: write (
3336+ root. join ( ".mcp.json" ) ,
3337+ r#"{"mcpServers": {"fetch": {"command": "fetch-mcp"}}}"# ,
3338+ )
3339+ . unwrap ( ) ;
3340+
3341+ for skill in [ "search" , "shared" ] {
3342+ let dir = root. join ( "skills" ) . join ( skill) ;
3343+ std:: fs:: create_dir_all ( & dir) . unwrap ( ) ;
3344+ std:: fs:: write ( dir. join ( "SKILL.md" ) , format ! ( "---\n name: {skill}\n ---\n " ) ) . unwrap ( ) ;
3345+ }
3346+ }
3347+
3348+ #[ test]
3349+ fn resolves_persona_mcp_servers_merged_with_pack_shared ( ) {
3350+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
3351+ write_pack ( tmp. path ( ) ) ;
3352+
3353+ let ( servers, _) = resolve_persona_pack ( tmp. path ( ) , "alpha" ) . unwrap ( ) ;
3354+
3355+ let mut names: Vec < & str > = servers. iter ( ) . map ( |s| s. name . as_str ( ) ) . collect ( ) ;
3356+ names. sort_unstable ( ) ;
3357+ assert_eq ! ( names, vec![ "fetch" , "semgrep" ] ) ;
3358+ let semgrep = servers. iter ( ) . find ( |s| s. name == "semgrep" ) . unwrap ( ) ;
3359+ assert_eq ! ( semgrep. command, "semgrep-mcp" ) ;
3360+ assert_eq ! ( semgrep. args, vec![ "--stdio" . to_string( ) ] ) ;
3361+ assert_eq ! (
3362+ semgrep. env,
3363+ vec![ ( "TOKEN" . to_string( ) , "abc123" . to_string( ) ) ]
3364+ ) ;
3365+ }
3366+
3367+ #[ test]
3368+ fn claimed_skill_is_scoped_to_its_persona_only ( ) {
3369+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
3370+ write_pack ( tmp. path ( ) ) ;
3371+
3372+ let ( _, alpha_skills) = resolve_persona_pack ( tmp. path ( ) , "alpha" ) . unwrap ( ) ;
3373+ let ( _, beta_skills) = resolve_persona_pack ( tmp. path ( ) , "beta" ) . unwrap ( ) ;
3374+
3375+ let names = |dirs : & [ PathBuf ] | -> Vec < String > {
3376+ let mut n: Vec < String > = dirs
3377+ . iter ( )
3378+ . map ( |d| d. file_name ( ) . unwrap ( ) . to_string_lossy ( ) . into_owned ( ) )
3379+ . collect ( ) ;
3380+ n. sort ( ) ;
3381+ n
3382+ } ;
3383+ assert_eq ! ( names( & alpha_skills) , vec![ "search" , "shared" ] ) ;
3384+ assert_eq ! (
3385+ names( & beta_skills) ,
3386+ vec![ "shared" ] ,
3387+ "a skill claimed by alpha must not reach beta"
3388+ ) ;
3389+ }
3390+
3391+ #[ test]
3392+ fn unknown_persona_is_a_config_error_listing_the_available_names ( ) {
3393+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
3394+ write_pack ( tmp. path ( ) ) ;
3395+
3396+ let err = resolve_persona_pack ( tmp. path ( ) , "gamma" ) . unwrap_err ( ) ;
3397+ let msg = err. to_string ( ) ;
3398+ assert ! ( msg. contains( "gamma" ) , "{msg}" ) ;
3399+ assert ! (
3400+ msg. contains( "alpha" ) ,
3401+ "available names must be listed: {msg}"
3402+ ) ;
3403+ }
3404+
3405+ #[ test]
3406+ fn declared_but_missing_skill_directory_is_a_config_error ( ) {
3407+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
3408+ write_pack ( tmp. path ( ) ) ;
3409+ std:: fs:: remove_dir_all ( tmp. path ( ) . join ( "skills/search" ) ) . unwrap ( ) ;
3410+
3411+ let err = resolve_persona_pack ( tmp. path ( ) , "alpha" ) . unwrap_err ( ) ;
3412+ assert ! (
3413+ err. to_string( ) . contains( "search" ) ,
3414+ "a persona must not start with a silently missing skill: {err}"
3415+ ) ;
3416+ }
3417+
3418+ #[ test]
3419+ fn pack_dir_without_a_manifest_is_a_config_error ( ) {
3420+ let tmp = tempfile:: tempdir ( ) . unwrap ( ) ;
3421+ let err = resolve_persona_pack ( tmp. path ( ) , "alpha" ) . unwrap_err ( ) ;
3422+ assert ! ( err. to_string( ) . contains( "persona pack" ) , "{err}" ) ;
3423+ }
3424+
3425+ #[ test]
3426+ fn persona_pack_and_persona_name_require_each_other ( ) {
3427+ use clap:: Parser ;
3428+ let base = [
3429+ "buzz-acp" ,
3430+ "--private-key" ,
3431+ "nsec1vl029mgpspedva04g90vltkh6fvh240zqtv9k0t9af8935ke9laqsnlfe5" ,
3432+ ] ;
3433+
3434+ assert ! (
3435+ CliArgs :: try_parse_from( base. iter( ) . copied( ) . chain( [ "--persona-pack" , "/tmp/pack" ] ) )
3436+ . is_err( ) ,
3437+ "--persona-pack alone must be rejected"
3438+ ) ;
3439+ assert ! (
3440+ CliArgs :: try_parse_from( base. iter( ) . copied( ) . chain( [ "--persona" , "alpha" ] ) ) . is_err( ) ,
3441+ "--persona alone must be rejected"
3442+ ) ;
3443+ }
3444+ }
0 commit comments