-
Notifications
You must be signed in to change notification settings - Fork 0
chore: Task 3: Persona/skill projection for Claude Code #607
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,18 @@ | ||
| [package] | ||
| name = "agentflare-apps" | ||
| version = "0.1.0" | ||
| edition = "2024" | ||
| publish = false | ||
|
|
||
| [dependencies] | ||
| serde = { version = "1", features = ["derive"] } | ||
| serde_json = "1" | ||
| toml = "0.8" | ||
| gateway-registry = { package = "agentflare-gateway-registry", path = "../gateway-registry" } | ||
| agentflare-workspace-hack = { version = "0.1", path = "../../agentflare-workspace-hack" } | ||
|
|
||
| [dev-dependencies] | ||
| tempfile = "3" | ||
|
|
||
| [lints.rust] | ||
| unsafe_code = "warn" |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| pub mod manifest; | ||
| pub mod project; | ||
|
|
||
| pub use manifest::{AppManifest, ToolsManifest, load_app_manifest, load_tools_manifest}; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,89 @@ | ||
| use serde::Deserialize; | ||
| use std::collections::HashMap; | ||
| use std::path::{Path, PathBuf}; | ||
|
|
||
| #[derive(Debug, Clone, Deserialize)] | ||
| struct RawAppManifest { | ||
| name: String, | ||
| version: String, | ||
| workflow: String, | ||
| #[serde(default)] | ||
| sandbox_profile: Option<String>, | ||
| } | ||
|
|
||
| #[derive(Debug, Clone)] | ||
| pub struct AppManifest { | ||
| pub name: String, | ||
| pub version: String, | ||
| pub workflow: PathBuf, | ||
| pub sandbox_profile: Option<String>, | ||
| } | ||
|
|
||
| pub fn load_app_manifest(app_dir: &Path) -> Result<AppManifest, String> { | ||
| let path = app_dir.join("app.toml"); | ||
| let text = std::fs::read_to_string(&path) | ||
| .map_err(|e| format!("could not read {}: {e}", path.display()))?; | ||
| let raw: RawAppManifest = | ||
| toml::from_str(&text).map_err(|e| format!("{}: invalid app.toml: {e}", path.display()))?; | ||
| Ok(AppManifest { | ||
| name: raw.name, | ||
| version: raw.version, | ||
| workflow: app_dir.join(raw.workflow), | ||
| sandbox_profile: raw.sandbox_profile, | ||
| }) | ||
| } | ||
|
|
||
| #[derive(Debug, Deserialize)] | ||
| pub struct ToolsManifest { | ||
| #[serde(default)] | ||
| pub servers: HashMap<String, gateway_registry::ServerConfig>, | ||
| } | ||
|
|
||
| pub fn load_tools_manifest(app_dir: &Path) -> Result<Option<ToolsManifest>, String> { | ||
| let path = app_dir.join("tools.toml"); | ||
| if !path.is_file() { | ||
| return Ok(None); | ||
| } | ||
| let text = std::fs::read_to_string(&path) | ||
| .map_err(|e| format!("could not read {}: {e}", path.display()))?; | ||
| let parsed: ToolsManifest = toml::from_str(&text) | ||
| .map_err(|e| format!("{}: invalid tools.toml: {e}", path.display()))?; | ||
| Ok(Some(parsed)) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
| use std::io::Write; | ||
|
|
||
| #[test] | ||
| fn loads_a_minimal_app_toml() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let mut f = std::fs::File::create(dir.path().join("app.toml")).unwrap(); | ||
| writeln!( | ||
| f, | ||
| r#" | ||
| name = "auto-company" | ||
| version = "0.1.0" | ||
| workflow = "workflow.json" | ||
| "# | ||
| ) | ||
| .unwrap(); | ||
|
|
||
| let manifest = load_app_manifest(dir.path()).unwrap(); | ||
| assert_eq!(manifest.name, "auto-company"); | ||
| assert_eq!(manifest.version, "0.1.0"); | ||
| assert_eq!(manifest.workflow, dir.path().join("workflow.json")); | ||
| assert_eq!(manifest.sandbox_profile, None); | ||
| } | ||
|
|
||
| #[test] | ||
| fn missing_app_toml_is_a_clear_error() { | ||
| let dir = tempfile::tempdir().unwrap(); | ||
| let err = load_app_manifest(dir.path()).unwrap_err(); | ||
| assert!( | ||
| err.contains("app.toml"), | ||
| "error should name the missing file: {err}" | ||
| ); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,148 @@ | ||
| use crate::manifest::ToolsManifest; | ||
| use gateway_registry::ServerConfig; | ||
| use std::path::Path; | ||
|
|
||
| pub fn project_for_claude_code( | ||
| app_dir: &Path, | ||
| scratch_dir: &Path, | ||
| tools: Option<&ToolsManifest>, | ||
| ) -> Result<(), String> { | ||
| project_dir( | ||
| &app_dir.join("personas"), | ||
| &scratch_dir.join(".claude/agents"), | ||
| "md", | ||
| )?; | ||
| project_skills(&app_dir.join("skills"), &scratch_dir.join(".claude/skills"))?; | ||
|
|
||
| let settings_dir = scratch_dir.join(".claude"); | ||
| std::fs::create_dir_all(&settings_dir).map_err(|e| e.to_string())?; | ||
| std::fs::write( | ||
| settings_dir.join("settings.json"), | ||
| serde_json::to_vec_pretty(&serde_json::json!({ "enableAllProjectMcpServers": true })) | ||
| .map_err(|e| e.to_string())?, | ||
| ) | ||
| .map_err(|e| e.to_string())?; | ||
|
|
||
| if let Some(tools) = tools { | ||
| write_mcp_json(scratch_dir, tools)?; | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn project_dir(src: &Path, dst: &Path, ext: &str) -> Result<(), String> { | ||
| if !src.is_dir() { | ||
| return Ok(()); | ||
| } | ||
| std::fs::create_dir_all(dst).map_err(|e| e.to_string())?; | ||
| for entry in std::fs::read_dir(src).map_err(|e| e.to_string())? { | ||
| let entry = entry.map_err(|e| e.to_string())?; | ||
| if entry.path().extension().is_some_and(|e| e == ext) { | ||
| let target = dst.join(entry.file_name()); | ||
| std::fs::copy(entry.path(), target).map_err(|e| e.to_string())?; | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn project_skills(src: &Path, dst: &Path) -> Result<(), String> { | ||
| if !src.is_dir() { | ||
| return Ok(()); | ||
| } | ||
| for entry in std::fs::read_dir(src).map_err(|e| e.to_string())? { | ||
| let entry = entry.map_err(|e| e.to_string())?; | ||
| let path = entry.path(); | ||
| if path.extension().is_some_and(|e| e == "md") { | ||
| let stem = path.file_stem().unwrap().to_string_lossy().into_owned(); | ||
| let skill_dir = dst.join(&stem); | ||
| std::fs::create_dir_all(&skill_dir).map_err(|e| e.to_string())?; | ||
| std::fs::copy(&path, skill_dir.join("SKILL.md")).map_err(|e| e.to_string())?; | ||
| } | ||
| } | ||
| Ok(()) | ||
| } | ||
|
|
||
| fn write_mcp_json(scratch_dir: &Path, tools: &ToolsManifest) -> Result<(), String> { | ||
| let mut servers = serde_json::Map::new(); | ||
| for (name, cfg) in &tools.servers { | ||
| let entry = match cfg { | ||
| ServerConfig::McpStdio { command, args, .. } => serde_json::json!({ | ||
| "command": command, | ||
| "args": args, | ||
| }), | ||
| ServerConfig::McpHttp { url, .. } => serde_json::json!({ "url": url }), | ||
|
Comment on lines
+68
to
+72
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win 🔎 Supported by static analysis🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
# Exception: lean-ctx is unavailable in this review environment.
# Inspect the gateway configuration fields without executing repository code.
ast-grep outline crates/gateway-registry/src --items all --match 'ServerConfig|McpHttpBackend'
rg -n -C 8 'McpHttp|auth_ref|auth_env|auth_header' crates/gateway-registry/src
# Verify the installed Claude Code behavior for an URL-only project entry.
command -v claude
claude --version
tmp_dir="$(mktemp -d)"
trap 'rm -rf "$tmp_dir"' EXIT
cat >"$tmp_dir/.mcp.json" <<'JSON'
{"mcpServers":{"http-server":{"url":"https://example.invalid/mcp"}}}
JSON
(
cd "$tmp_dir"
claude mcp list
)
# Expected: Claude Code reports that the server has a URL but no type.Repository: getappz/agentflare Length of output: 35553 🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- project.rs outline ---'
ast-grep outline crates/agentflare-apps/src/project.rs --items all
printf '%s\n' '--- serializer implementation and nearby tests ---'
sed -n '1,180p' crates/agentflare-apps/src/project.rs
printf '%s\n' '--- write_mcp_json references ---'
rg -n -C 6 'write_mcp_json|mcpServers|McpHttp' crates/agentflare-appsRepository: getappz/agentflare Length of output: 9563 🌐 Web query:
💡 Result: In Claude Code, you can configure MCP servers using a.mcp.json file (for project-specific scope) or via ~/.claude.json (for user-level configuration) [1]. Configuration Details for HTTP Servers: - Type: Use "http" as the type [2]. You may also use "streamable-http" as an alias for "http", which is consistent with the Model Context Protocol (MCP) specification [2][3]. - Headers: HTTP headers are defined within the headers object of the server configuration [2]. - URL: The endpoint URL is specified via the url field [2]. Environment Variable Expansion: Claude Code supports environment variable expansion directly within.mcp.json and other JSON configuration files [2]. This allows you to securely manage sensitive values (like API keys) and machine-specific paths without hardcoding them into shared files [2][4]. Supported Syntax: - ${VAR}: Expands to the value of the environment variable VAR [2]. - ${VAR:-default}: Expands to the value of VAR if it is set; otherwise, it uses the specified default value [2]. Locations for Expansion: Environment variables can be expanded in the following fields [2]: - url (for HTTP servers) - headers (for authentication) - command (for server executable paths) - args (for command-line arguments) - env (for environment variables passed to the server) If a referenced variable is missing and no default is provided, Claude Code will issue a warning in the claude mcp list output and use the unexpanded string as-is [2]. It is recommended to use the ${VAR:-default} fallback syntax or ensure the variable is set in the environment [2]. Citations:
🏁 Script executed: #!/usr/bin/env bash
set -euo pipefail
printf '%s\n' '--- ToolsManifest definition ---'
rg -n -C 12 'struct ToolsManifest|type ToolsManifest|servers:.*ServerConfig' crates
printf '%s\n' '--- project_for_claude_code callers ---'
rg -n -C 10 'project_for_claude_code|ToolsManifest' crates/agentflare-apps crates
printf '%s\n' '--- manifest-related file map ---'
fd -i 'manifest|project' crates/agentflare-apps/src crates/agentflare-apps/tests 2>/dev/null || trueRepository: getappz/agentflare Length of output: 22496 Emit a complete Claude Code HTTP MCP entry.
🤖 Prompt for AI Agents |
||
| }; | ||
| servers.insert(name.clone(), entry); | ||
| } | ||
| let doc = serde_json::json!({ "mcpServers": serde_json::Value::Object(servers) }); | ||
| std::fs::write( | ||
| scratch_dir.join(".mcp.json"), | ||
| serde_json::to_vec_pretty(&doc).map_err(|e| e.to_string())?, | ||
| ) | ||
| .map_err(|e| e.to_string()) | ||
| } | ||
|
|
||
| #[cfg(test)] | ||
| mod tests { | ||
| use super::*; | ||
|
|
||
| #[test] | ||
| fn projects_personas_and_skills_into_claude_layout() { | ||
| let app_dir = tempfile::tempdir().unwrap(); | ||
| std::fs::create_dir_all(app_dir.path().join("personas")).unwrap(); | ||
| std::fs::write( | ||
| app_dir.path().join("personas/ceo.md"), | ||
| "# CEO\nYou lead the company.", | ||
| ) | ||
| .unwrap(); | ||
| std::fs::create_dir_all(app_dir.path().join("skills")).unwrap(); | ||
| std::fs::write(app_dir.path().join("skills/pricing.md"), "# Pricing skill").unwrap(); | ||
|
|
||
| let scratch = tempfile::tempdir().unwrap(); | ||
| project_for_claude_code(app_dir.path(), scratch.path(), None).unwrap(); | ||
|
|
||
| let persona = | ||
| std::fs::read_to_string(scratch.path().join(".claude/agents/ceo.md")).unwrap(); | ||
| assert_eq!(persona, "# CEO\nYou lead the company."); | ||
|
|
||
| let skill = std::fs::read_to_string(scratch.path().join(".claude/skills/pricing/SKILL.md")) | ||
| .unwrap(); | ||
| assert_eq!(skill, "# Pricing skill"); | ||
|
|
||
| let settings: serde_json::Value = serde_json::from_str( | ||
| &std::fs::read_to_string(scratch.path().join(".claude/settings.json")).unwrap(), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| settings["enableAllProjectMcpServers"], | ||
| serde_json::json!(true) | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn writes_mcp_json_when_tools_manifest_present() { | ||
| let app_dir = tempfile::tempdir().unwrap(); | ||
| let scratch = tempfile::tempdir().unwrap(); | ||
| let mut servers = std::collections::HashMap::new(); | ||
| servers.insert( | ||
| "pricing-api".to_string(), | ||
| gateway_registry::ServerConfig::McpStdio { | ||
| command: "pricing-mcp".to_string(), | ||
| args: vec![], | ||
| auth_ref: None, | ||
| auth_env: None, | ||
| }, | ||
| ); | ||
| let tools = ToolsManifest { servers }; | ||
|
|
||
| project_for_claude_code(app_dir.path(), scratch.path(), Some(&tools)).unwrap(); | ||
|
|
||
| let mcp_json: serde_json::Value = serde_json::from_str( | ||
| &std::fs::read_to_string(scratch.path().join(".mcp.json")).unwrap(), | ||
| ) | ||
| .unwrap(); | ||
| assert_eq!( | ||
| mcp_json["mcpServers"]["pricing-api"]["command"], | ||
| "pricing-mcp" | ||
| ); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🔎 Supported by static analysis
🏁 Script executed:
Repository: getappz/agentflare
Length of output: 2798
🌐 Web query:
Rust std::path::Path::is_file returns false metadata error documentation💡 Result:
In Rust, the std::path::Path::is_file method is explicitly designed as a convenience function that coerces all errors to false [1][2]. If the underlying filesystem metadata cannot be accessed—due to reasons such as permission issues, broken symbolic links, or other I/O errors—the method returns false rather than reporting the error [1][2][3]. According to the official Rust documentation, if you need to distinguish between a path that is not a file and a path whose status cannot be determined due to an error, you should not use is_file [1][2]. Instead, you should call std::fs::metadata and handle the resulting Result enum directly [1][2]. If the result is Ok, you can then call the is_file method on the returned Metadata object [1][2]. If your goal is specifically to check for the existence of a file or directory while handling potential access errors, the std::path::Path::try_exists method is the recommended alternative [4][5]. Unlike exists or is_file, try_exists returns a Result<bool, io::Error>, allowing you to explicitly handle cases where existence cannot be confirmed [6][5].
Citations:
try_exists()method tostd::path::Pathrust-lang/rust#81822Return an error when
tools.tomlmetadata cannot be accessed.Path::is_file()coerces metadata errors tofalse.load_tools_manifesttherefore returnsOk(None)and silently skips tool projection when metadata access fails. Handlestd::fs::metadatadirectly, returnOk(None)only forErrorKind::NotFound, and return an error for other failures.🤖 Prompt for AI Agents