use std::fs;
use std::path::{Component, Path, PathBuf};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::error::{Error, Result};
pub const AGENT_PACKAGE_MANIFEST: &str = ".supercode/package.toml";
pub const AGENT_PACKAGE_SCHEMA_VERSION: u32 = 1;
pub const CONTRIBUTION_SCHEMA: &str = "supercode/contribution-v1";
pub const RESOURCE_SCHEMA: &str = "supercode/package-resource-v1";
const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
const MAX_CONTRIBUTION_BYTES: u64 = 256 * 1024;
const MAX_CONTRIBUTIONS: usize = 128;
const MAX_RESOURCE_BYTES: u64 = 512 * 1024;
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPackage {
pub schema_version: u32,
pub id: String,
pub name: String,
pub version: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
pub workspace: PathBuf,
pub package_root: PathBuf,
pub manifest_path: PathBuf,
pub agent: AgentFolder,
pub capabilities: AgentPackageCapabilities,
pub storage: AgentPackageStorage,
pub contributions: Vec<AgentContribution>,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentFolder {
pub root: PathBuf,
pub instructions: Vec<PathBuf>,
#[serde(skip_serializing_if = "Option::is_none")]
pub skills_root: Option<PathBuf>,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPackageCapabilities {
pub required: Vec<String>,
pub optional: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPackageStorage {
pub relative_path: PathBuf,
pub path: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentContribution {
pub schema: String,
pub id: String,
pub kind: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub title: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub placement: Option<Value>,
#[serde(default)]
pub data: Value,
#[serde(default, skip_deserializing)]
pub source: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct AgentPackageResource {
pub schema: String,
pub contribution_id: String,
pub format: String,
pub media_type: String,
pub data: Value,
}
#[derive(Debug, Deserialize)]
struct RawManifest {
schema_version: u32,
id: String,
name: Option<String>,
#[serde(default)]
version: String,
description: Option<String>,
agent: RawAgentFolder,
#[serde(default)]
capabilities: RawCapabilities,
storage: RawStorage,
#[serde(default)]
contributions: RawContributions,
}
#[derive(Debug, Deserialize)]
struct RawAgentFolder {
#[serde(default = "default_agent_root")]
root: PathBuf,
#[serde(default = "default_instructions")]
instructions: Vec<PathBuf>,
#[serde(default = "default_skills_root")]
skills: PathBuf,
}
#[derive(Debug, Default, Deserialize)]
struct RawCapabilities {
#[serde(default)]
required: Vec<String>,
#[serde(default)]
optional: Vec<String>,
}
#[derive(Debug, Deserialize)]
struct RawStorage {
path: PathBuf,
}
#[derive(Debug, Deserialize)]
struct RawContributions {
#[serde(default = "default_contributions_root")]
root: PathBuf,
}
impl Default for RawContributions {
fn default() -> Self {
Self {
root: default_contributions_root(),
}
}
}
fn default_agent_root() -> PathBuf {
PathBuf::from("agent")
}
fn default_instructions() -> Vec<PathBuf> {
vec![PathBuf::from("AGENTS.md")]
}
fn default_skills_root() -> PathBuf {
PathBuf::from("skills")
}
fn default_contributions_root() -> PathBuf {
PathBuf::from("contributions")
}
fn package_error(message: impl Into<String>) -> Error {
Error::tool("agent_package", message)
}
fn validate_id(value: &str, field: &str) -> Result<()> {
let valid = !value.is_empty()
&& value.len() <= 128
&& value
.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
if valid {
Ok(())
} else {
Err(package_error(format!(
"{field} must contain only ASCII letters, digits, `.`, `-`, or `_`"
)))
}
}
fn validate_capabilities(values: Vec<String>, field: &str) -> Result<Vec<String>> {
let mut out = Vec::with_capacity(values.len());
for value in values {
validate_id(&value, field)?;
if !out.contains(&value) {
out.push(value);
}
}
Ok(out)
}
fn safe_relative(path: &Path, field: &str) -> Result<()> {
if path.as_os_str().is_empty()
|| path.is_absolute()
|| path
.components()
.any(|component| !matches!(component, Component::Normal(_)))
{
return Err(package_error(format!(
"{field} must be a non-empty relative path without `.` or `..`: {}",
path.display()
)));
}
Ok(())
}
fn read_bounded(path: &Path, max_bytes: u64, label: &str) -> Result<String> {
let metadata = fs::metadata(path)
.map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))?;
if !metadata.is_file() {
return Err(package_error(format!(
"{label} is not a regular file: {}",
path.display()
)));
}
if metadata.len() > max_bytes {
return Err(package_error(format!(
"{label} exceeds the {max_bytes}-byte limit: {}",
path.display()
)));
}
fs::read_to_string(path)
.map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))
}
fn contained_existing(root: &Path, relative: &Path, field: &str) -> Result<PathBuf> {
safe_relative(relative, field)?;
let candidate = root.join(relative);
let canonical = fs::canonicalize(&candidate).map_err(|error| {
package_error(format!(
"resolving {field} {}: {error}",
candidate.display()
))
})?;
if !canonical.starts_with(root) {
return Err(package_error(format!(
"{field} resolves outside the package root: {}",
candidate.display()
)));
}
Ok(canonical)
}
pub fn load_workspace_agent_package(workspace: &Path) -> Result<Option<AgentPackage>> {
let workspace = fs::canonicalize(workspace).map_err(|error| {
package_error(format!(
"resolving workspace {}: {error}",
workspace.display()
))
})?;
let manifest_path = workspace.join(AGENT_PACKAGE_MANIFEST);
if !manifest_path.exists() {
return Ok(None);
}
let manifest_path = fs::canonicalize(&manifest_path).map_err(|error| {
package_error(format!("resolving {}: {error}", manifest_path.display()))
})?;
let package_root = manifest_path
.parent()
.expect("the manifest always has a .supercode parent")
.to_path_buf();
if !package_root.starts_with(&workspace) {
return Err(package_error(
"agent package manifest resolves outside workspace",
));
}
let text = read_bounded(&manifest_path, MAX_MANIFEST_BYTES, "agent package manifest")?;
let raw: RawManifest = toml::from_str(&text)
.map_err(|error| package_error(format!("parsing {}: {error}", manifest_path.display())))?;
if raw.schema_version != AGENT_PACKAGE_SCHEMA_VERSION {
return Err(package_error(format!(
"unsupported agent package schema_version {}; expected {}",
raw.schema_version, AGENT_PACKAGE_SCHEMA_VERSION
)));
}
validate_id(&raw.id, "package id")?;
let name = raw.name.unwrap_or_else(|| raw.id.clone());
if name.trim().is_empty() {
return Err(package_error("package name cannot be empty"));
}
let agent_root = contained_existing(&package_root, &raw.agent.root, "agent.root")?;
if !agent_root.is_dir() {
return Err(package_error(format!(
"agent.root is not a directory: {}",
agent_root.display()
)));
}
let mut instructions = Vec::with_capacity(raw.agent.instructions.len());
for relative in raw.agent.instructions {
let path = contained_existing(&agent_root, &relative, "agent.instructions")?;
if !path.is_file() {
return Err(package_error(format!(
"agent instruction is not a file: {}",
path.display()
)));
}
instructions.push(path);
}
let skills_candidate = agent_root.join(&raw.agent.skills);
safe_relative(&raw.agent.skills, "agent.skills")?;
let skills_root = if skills_candidate.exists() {
let path = contained_existing(&agent_root, &raw.agent.skills, "agent.skills")?;
if !path.is_dir() {
return Err(package_error(format!(
"agent.skills is not a directory: {}",
path.display()
)));
}
Some(path)
} else {
None
};
safe_relative(&raw.storage.path, "storage.path")?;
let storage_path = workspace.join(&raw.storage.path);
if storage_path.exists() {
let canonical_storage = fs::canonicalize(&storage_path).map_err(|error| {
package_error(format!(
"resolving storage.path {}: {error}",
storage_path.display()
))
})?;
if !canonical_storage.starts_with(&workspace) {
return Err(package_error(format!(
"storage.path resolves outside the workspace: {}",
storage_path.display()
)));
}
if !canonical_storage.is_dir() {
return Err(package_error(format!(
"storage.path is not a directory: {}",
storage_path.display()
)));
}
}
let storage = AgentPackageStorage {
path: storage_path,
relative_path: raw.storage.path,
};
let contributions_root = package_root.join(&raw.contributions.root);
safe_relative(&raw.contributions.root, "contributions.root")?;
let contributions = if contributions_root.exists() {
let contributions_root =
contained_existing(&package_root, &raw.contributions.root, "contributions.root")?;
if !contributions_root.is_dir() {
return Err(package_error(format!(
"contributions.root is not a directory: {}",
contributions_root.display()
)));
}
load_contributions(&contributions_root, &raw.id)?
} else {
Vec::new()
};
Ok(Some(AgentPackage {
schema_version: raw.schema_version,
id: raw.id,
name,
version: if raw.version.trim().is_empty() {
"0.0.0".to_string()
} else {
raw.version
},
description: raw.description.filter(|value| !value.trim().is_empty()),
workspace,
package_root,
manifest_path,
agent: AgentFolder {
root: agent_root,
instructions,
skills_root,
},
capabilities: AgentPackageCapabilities {
required: validate_capabilities(raw.capabilities.required, "required capability")?,
optional: validate_capabilities(raw.capabilities.optional, "optional capability")?,
},
storage,
contributions,
}))
}
fn load_contributions(root: &Path, package_id: &str) -> Result<Vec<AgentContribution>> {
let mut files = fs::read_dir(root)
.map_err(|error| package_error(format!("reading {}: {error}", root.display())))?
.filter_map(std::result::Result::ok)
.map(|entry| entry.path())
.filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
.collect::<Vec<_>>();
files.sort();
if files.len() > MAX_CONTRIBUTIONS {
return Err(package_error(format!(
"contributions.root contains {} JSON files; limit is {MAX_CONTRIBUTIONS}",
files.len()
)));
}
let mut contributions = Vec::with_capacity(files.len());
let mut ids = std::collections::HashSet::new();
for path in files {
let canonical = fs::canonicalize(&path).map_err(|error| {
package_error(format!(
"resolving contribution {}: {error}",
path.display()
))
})?;
if !canonical.starts_with(root) {
return Err(package_error(format!(
"contribution resolves outside contributions.root: {}",
path.display()
)));
}
let text = read_bounded(&canonical, MAX_CONTRIBUTION_BYTES, "contribution")?;
let mut contribution: AgentContribution = serde_json::from_str(&text).map_err(|error| {
package_error(format!(
"parsing contribution {}: {error}",
canonical.display()
))
})?;
if contribution.schema != CONTRIBUTION_SCHEMA {
return Err(package_error(format!(
"contribution {} has unsupported schema `{}`",
canonical.display(),
contribution.schema
)));
}
validate_id(&contribution.id, "contribution id")?;
if contribution.id != package_id && !contribution.id.starts_with(&format!("{package_id}."))
{
return Err(package_error(format!(
"contribution id `{}` must be `{package_id}` or start with `{package_id}.`",
contribution.id
)));
}
validate_id(&contribution.kind, "contribution kind")?;
contribution_resource_relative(&contribution)?;
if !ids.insert(contribution.id.clone()) {
return Err(package_error(format!(
"duplicate contribution id `{}`",
contribution.id
)));
}
contribution.source = canonical;
contributions.push(contribution);
}
Ok(contributions)
}
fn contribution_resource_relative(contribution: &AgentContribution) -> Result<Option<PathBuf>> {
let Some(resource) = contribution.data.get("resource") else {
return Ok(None);
};
let resource = resource.as_str().ok_or_else(|| {
package_error(format!(
"contribution `{}` data.resource must be a string",
contribution.id
))
})?;
let relative = PathBuf::from(resource);
safe_relative(
&relative,
&format!("contribution `{}` data.resource", contribution.id),
)?;
Ok(Some(relative))
}
pub fn declared_resource_contribution_ids(package: &AgentPackage) -> Vec<&str> {
package
.contributions
.iter()
.filter(|contribution| contribution.data.get("resource").is_some())
.map(|contribution| contribution.id.as_str())
.collect()
}
pub fn read_contribution_resource(
package: &AgentPackage,
contribution_id: &str,
) -> Result<Option<AgentPackageResource>> {
let contribution = package
.contributions
.iter()
.find(|contribution| contribution.id == contribution_id)
.ok_or_else(|| package_error(format!("unknown contribution id `{contribution_id}`")))?;
let Some(relative) = contribution_resource_relative(contribution)? else {
return Ok(None);
};
if !package.storage.path.exists() {
return Ok(None);
}
let storage = fs::canonicalize(&package.storage.path).map_err(|error| {
package_error(format!(
"resolving storage.path {}: {error}",
package.storage.path.display()
))
})?;
if !storage.starts_with(&package.workspace) || !storage.is_dir() {
return Err(package_error("package storage is unavailable"));
}
let candidate = storage.join(&relative);
if !candidate.exists() {
return Ok(None);
}
let resource = fs::canonicalize(&candidate).map_err(|error| {
package_error(format!(
"resolving contribution resource {}: {error}",
candidate.display()
))
})?;
if !resource.starts_with(&storage) {
return Err(package_error(format!(
"contribution `{contribution_id}` resource resolves outside package storage"
)));
}
let text = read_bounded(&resource, MAX_RESOURCE_BYTES, "contribution resource")?;
let extension = resource.extension().and_then(|value| value.to_str());
let (format, media_type, data) = if extension == Some("json") {
let data = serde_json::from_str(&text).map_err(|error| {
package_error(format!(
"parsing contribution `{contribution_id}` JSON resource: {error}"
))
})?;
("json", "application/json", data)
} else {
let media_type = match extension {
Some("md" | "markdown") => "text/markdown",
Some("yaml" | "yml") => "application/yaml",
_ => "text/plain",
};
("text", media_type, Value::String(text))
};
Ok(Some(AgentPackageResource {
schema: RESOURCE_SCHEMA.to_string(),
contribution_id: contribution_id.to_string(),
format: format.to_string(),
media_type: media_type.to_string(),
data,
}))
}
pub(crate) fn workspace_package_instruction_files(workspace: &Path) -> Vec<PathBuf> {
match load_workspace_agent_package(workspace) {
Ok(Some(package)) => package.agent.instructions,
Ok(None) => Vec::new(),
Err(error) => {
eprintln!(
"warning: ignoring invalid Supercode agent package in {}: {error}",
workspace.display()
);
Vec::new()
}
}
}