1use std::fs;
10use std::path::{Component, Path, PathBuf};
11
12use serde::{Deserialize, Serialize};
13use serde_json::Value;
14
15use crate::error::{Error, Result};
16
17pub const AGENT_PACKAGE_MANIFEST: &str = ".supercode/package.toml";
19pub const AGENT_PACKAGE_SCHEMA_VERSION: u32 = 1;
21pub const CONTRIBUTION_SCHEMA: &str = "supercode/contribution-v1";
23pub const RESOURCE_SCHEMA: &str = "supercode/package-resource-v1";
25
26const MAX_MANIFEST_BYTES: u64 = 64 * 1024;
27const MAX_CONTRIBUTION_BYTES: u64 = 256 * 1024;
28const MAX_CONTRIBUTIONS: usize = 128;
29const MAX_RESOURCE_BYTES: u64 = 512 * 1024;
30
31#[derive(Debug, Clone, PartialEq, Serialize)]
33#[serde(rename_all = "camelCase")]
34pub struct AgentPackage {
35 pub schema_version: u32,
37 pub id: String,
39 pub name: String,
41 pub version: String,
43 #[serde(skip_serializing_if = "Option::is_none")]
45 pub description: Option<String>,
46 pub workspace: PathBuf,
48 pub package_root: PathBuf,
50 pub manifest_path: PathBuf,
52 pub agent: AgentFolder,
54 pub capabilities: AgentPackageCapabilities,
56 pub storage: AgentPackageStorage,
58 pub contributions: Vec<AgentContribution>,
60}
61
62#[derive(Debug, Clone, PartialEq, Serialize)]
64#[serde(rename_all = "camelCase")]
65pub struct AgentFolder {
66 pub root: PathBuf,
68 pub instructions: Vec<PathBuf>,
70 #[serde(skip_serializing_if = "Option::is_none")]
72 pub skills_root: Option<PathBuf>,
73}
74
75#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize)]
77#[serde(rename_all = "camelCase")]
78pub struct AgentPackageCapabilities {
79 pub required: Vec<String>,
81 pub optional: Vec<String>,
83}
84
85#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
87#[serde(rename_all = "camelCase")]
88pub struct AgentPackageStorage {
89 pub relative_path: PathBuf,
91 pub path: PathBuf,
93}
94
95#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
101#[serde(rename_all = "camelCase")]
102pub struct AgentContribution {
103 pub schema: String,
105 pub id: String,
107 pub kind: String,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub title: Option<String>,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
114 pub placement: Option<Value>,
115 #[serde(default)]
117 pub data: Value,
118 #[serde(default, skip_deserializing)]
120 pub source: PathBuf,
121}
122
123#[derive(Debug, Clone, PartialEq, Serialize)]
128#[serde(rename_all = "camelCase")]
129pub struct AgentPackageResource {
130 pub schema: String,
132 pub contribution_id: String,
134 pub format: String,
136 pub media_type: String,
138 pub data: Value,
140}
141
142#[derive(Debug, Deserialize)]
143struct RawManifest {
144 schema_version: u32,
145 id: String,
146 name: Option<String>,
147 #[serde(default)]
148 version: String,
149 description: Option<String>,
150 agent: RawAgentFolder,
151 #[serde(default)]
152 capabilities: RawCapabilities,
153 storage: RawStorage,
154 #[serde(default)]
155 contributions: RawContributions,
156}
157
158#[derive(Debug, Deserialize)]
159struct RawAgentFolder {
160 #[serde(default = "default_agent_root")]
161 root: PathBuf,
162 #[serde(default = "default_instructions")]
163 instructions: Vec<PathBuf>,
164 #[serde(default = "default_skills_root")]
165 skills: PathBuf,
166}
167
168#[derive(Debug, Default, Deserialize)]
169struct RawCapabilities {
170 #[serde(default)]
171 required: Vec<String>,
172 #[serde(default)]
173 optional: Vec<String>,
174}
175
176#[derive(Debug, Deserialize)]
177struct RawStorage {
178 path: PathBuf,
179}
180
181#[derive(Debug, Deserialize)]
182struct RawContributions {
183 #[serde(default = "default_contributions_root")]
184 root: PathBuf,
185}
186
187impl Default for RawContributions {
188 fn default() -> Self {
189 Self {
190 root: default_contributions_root(),
191 }
192 }
193}
194
195fn default_agent_root() -> PathBuf {
196 PathBuf::from("agent")
197}
198
199fn default_instructions() -> Vec<PathBuf> {
200 vec![PathBuf::from("AGENTS.md")]
201}
202
203fn default_skills_root() -> PathBuf {
204 PathBuf::from("skills")
205}
206
207fn default_contributions_root() -> PathBuf {
208 PathBuf::from("contributions")
209}
210
211fn package_error(message: impl Into<String>) -> Error {
212 Error::tool("agent_package", message)
213}
214
215fn validate_id(value: &str, field: &str) -> Result<()> {
216 let valid = !value.is_empty()
217 && value.len() <= 128
218 && value
219 .bytes()
220 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'.' | b'-' | b'_'));
221 if valid {
222 Ok(())
223 } else {
224 Err(package_error(format!(
225 "{field} must contain only ASCII letters, digits, `.`, `-`, or `_`"
226 )))
227 }
228}
229
230fn validate_capabilities(values: Vec<String>, field: &str) -> Result<Vec<String>> {
231 let mut out = Vec::with_capacity(values.len());
232 for value in values {
233 validate_id(&value, field)?;
234 if !out.contains(&value) {
235 out.push(value);
236 }
237 }
238 Ok(out)
239}
240
241fn safe_relative(path: &Path, field: &str) -> Result<()> {
242 if path.as_os_str().is_empty()
243 || path.is_absolute()
244 || path
245 .components()
246 .any(|component| !matches!(component, Component::Normal(_)))
247 {
248 return Err(package_error(format!(
249 "{field} must be a non-empty relative path without `.` or `..`: {}",
250 path.display()
251 )));
252 }
253 Ok(())
254}
255
256fn read_bounded(path: &Path, max_bytes: u64, label: &str) -> Result<String> {
257 let metadata = fs::metadata(path)
258 .map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))?;
259 if !metadata.is_file() {
260 return Err(package_error(format!(
261 "{label} is not a regular file: {}",
262 path.display()
263 )));
264 }
265 if metadata.len() > max_bytes {
266 return Err(package_error(format!(
267 "{label} exceeds the {max_bytes}-byte limit: {}",
268 path.display()
269 )));
270 }
271 fs::read_to_string(path)
272 .map_err(|error| package_error(format!("reading {label} {}: {error}", path.display())))
273}
274
275fn contained_existing(root: &Path, relative: &Path, field: &str) -> Result<PathBuf> {
276 safe_relative(relative, field)?;
277 let candidate = root.join(relative);
278 let canonical = fs::canonicalize(&candidate).map_err(|error| {
279 package_error(format!(
280 "resolving {field} {}: {error}",
281 candidate.display()
282 ))
283 })?;
284 if !canonical.starts_with(root) {
285 return Err(package_error(format!(
286 "{field} resolves outside the package root: {}",
287 candidate.display()
288 )));
289 }
290 Ok(canonical)
291}
292
293pub fn load_workspace_agent_package(workspace: &Path) -> Result<Option<AgentPackage>> {
299 let workspace = fs::canonicalize(workspace).map_err(|error| {
300 package_error(format!(
301 "resolving workspace {}: {error}",
302 workspace.display()
303 ))
304 })?;
305 let manifest_path = workspace.join(AGENT_PACKAGE_MANIFEST);
306 if !manifest_path.exists() {
307 return Ok(None);
308 }
309 let manifest_path = fs::canonicalize(&manifest_path).map_err(|error| {
310 package_error(format!("resolving {}: {error}", manifest_path.display()))
311 })?;
312 let package_root = manifest_path
313 .parent()
314 .expect("the manifest always has a .supercode parent")
315 .to_path_buf();
316 if !package_root.starts_with(&workspace) {
317 return Err(package_error(
318 "agent package manifest resolves outside workspace",
319 ));
320 }
321
322 let text = read_bounded(&manifest_path, MAX_MANIFEST_BYTES, "agent package manifest")?;
323 let raw: RawManifest = toml::from_str(&text)
324 .map_err(|error| package_error(format!("parsing {}: {error}", manifest_path.display())))?;
325 if raw.schema_version != AGENT_PACKAGE_SCHEMA_VERSION {
326 return Err(package_error(format!(
327 "unsupported agent package schema_version {}; expected {}",
328 raw.schema_version, AGENT_PACKAGE_SCHEMA_VERSION
329 )));
330 }
331 validate_id(&raw.id, "package id")?;
332 let name = raw.name.unwrap_or_else(|| raw.id.clone());
333 if name.trim().is_empty() {
334 return Err(package_error("package name cannot be empty"));
335 }
336
337 let agent_root = contained_existing(&package_root, &raw.agent.root, "agent.root")?;
338 if !agent_root.is_dir() {
339 return Err(package_error(format!(
340 "agent.root is not a directory: {}",
341 agent_root.display()
342 )));
343 }
344 let mut instructions = Vec::with_capacity(raw.agent.instructions.len());
345 for relative in raw.agent.instructions {
346 let path = contained_existing(&agent_root, &relative, "agent.instructions")?;
347 if !path.is_file() {
348 return Err(package_error(format!(
349 "agent instruction is not a file: {}",
350 path.display()
351 )));
352 }
353 instructions.push(path);
354 }
355 let skills_candidate = agent_root.join(&raw.agent.skills);
356 safe_relative(&raw.agent.skills, "agent.skills")?;
357 let skills_root = if skills_candidate.exists() {
358 let path = contained_existing(&agent_root, &raw.agent.skills, "agent.skills")?;
359 if !path.is_dir() {
360 return Err(package_error(format!(
361 "agent.skills is not a directory: {}",
362 path.display()
363 )));
364 }
365 Some(path)
366 } else {
367 None
368 };
369
370 safe_relative(&raw.storage.path, "storage.path")?;
371 let storage_path = workspace.join(&raw.storage.path);
372 if storage_path.exists() {
373 let canonical_storage = fs::canonicalize(&storage_path).map_err(|error| {
374 package_error(format!(
375 "resolving storage.path {}: {error}",
376 storage_path.display()
377 ))
378 })?;
379 if !canonical_storage.starts_with(&workspace) {
380 return Err(package_error(format!(
381 "storage.path resolves outside the workspace: {}",
382 storage_path.display()
383 )));
384 }
385 if !canonical_storage.is_dir() {
386 return Err(package_error(format!(
387 "storage.path is not a directory: {}",
388 storage_path.display()
389 )));
390 }
391 }
392 let storage = AgentPackageStorage {
393 path: storage_path,
394 relative_path: raw.storage.path,
395 };
396
397 let contributions_root = package_root.join(&raw.contributions.root);
398 safe_relative(&raw.contributions.root, "contributions.root")?;
399 let contributions = if contributions_root.exists() {
400 let contributions_root =
401 contained_existing(&package_root, &raw.contributions.root, "contributions.root")?;
402 if !contributions_root.is_dir() {
403 return Err(package_error(format!(
404 "contributions.root is not a directory: {}",
405 contributions_root.display()
406 )));
407 }
408 load_contributions(&contributions_root, &raw.id)?
409 } else {
410 Vec::new()
411 };
412
413 Ok(Some(AgentPackage {
414 schema_version: raw.schema_version,
415 id: raw.id,
416 name,
417 version: if raw.version.trim().is_empty() {
418 "0.0.0".to_string()
419 } else {
420 raw.version
421 },
422 description: raw.description.filter(|value| !value.trim().is_empty()),
423 workspace,
424 package_root,
425 manifest_path,
426 agent: AgentFolder {
427 root: agent_root,
428 instructions,
429 skills_root,
430 },
431 capabilities: AgentPackageCapabilities {
432 required: validate_capabilities(raw.capabilities.required, "required capability")?,
433 optional: validate_capabilities(raw.capabilities.optional, "optional capability")?,
434 },
435 storage,
436 contributions,
437 }))
438}
439
440fn load_contributions(root: &Path, package_id: &str) -> Result<Vec<AgentContribution>> {
441 let mut files = fs::read_dir(root)
442 .map_err(|error| package_error(format!("reading {}: {error}", root.display())))?
443 .filter_map(std::result::Result::ok)
444 .map(|entry| entry.path())
445 .filter(|path| path.extension().and_then(|value| value.to_str()) == Some("json"))
446 .collect::<Vec<_>>();
447 files.sort();
448 if files.len() > MAX_CONTRIBUTIONS {
449 return Err(package_error(format!(
450 "contributions.root contains {} JSON files; limit is {MAX_CONTRIBUTIONS}",
451 files.len()
452 )));
453 }
454
455 let mut contributions = Vec::with_capacity(files.len());
456 let mut ids = std::collections::HashSet::new();
457 for path in files {
458 let canonical = fs::canonicalize(&path).map_err(|error| {
459 package_error(format!(
460 "resolving contribution {}: {error}",
461 path.display()
462 ))
463 })?;
464 if !canonical.starts_with(root) {
465 return Err(package_error(format!(
466 "contribution resolves outside contributions.root: {}",
467 path.display()
468 )));
469 }
470 let text = read_bounded(&canonical, MAX_CONTRIBUTION_BYTES, "contribution")?;
471 let mut contribution: AgentContribution = serde_json::from_str(&text).map_err(|error| {
472 package_error(format!(
473 "parsing contribution {}: {error}",
474 canonical.display()
475 ))
476 })?;
477 if contribution.schema != CONTRIBUTION_SCHEMA {
478 return Err(package_error(format!(
479 "contribution {} has unsupported schema `{}`",
480 canonical.display(),
481 contribution.schema
482 )));
483 }
484 validate_id(&contribution.id, "contribution id")?;
485 if contribution.id != package_id && !contribution.id.starts_with(&format!("{package_id}."))
486 {
487 return Err(package_error(format!(
488 "contribution id `{}` must be `{package_id}` or start with `{package_id}.`",
489 contribution.id
490 )));
491 }
492 validate_id(&contribution.kind, "contribution kind")?;
493 contribution_resource_relative(&contribution)?;
494 if !ids.insert(contribution.id.clone()) {
495 return Err(package_error(format!(
496 "duplicate contribution id `{}`",
497 contribution.id
498 )));
499 }
500 contribution.source = canonical;
501 contributions.push(contribution);
502 }
503 Ok(contributions)
504}
505
506fn contribution_resource_relative(contribution: &AgentContribution) -> Result<Option<PathBuf>> {
507 let Some(resource) = contribution.data.get("resource") else {
508 return Ok(None);
509 };
510 let resource = resource.as_str().ok_or_else(|| {
511 package_error(format!(
512 "contribution `{}` data.resource must be a string",
513 contribution.id
514 ))
515 })?;
516 let relative = PathBuf::from(resource);
517 safe_relative(
518 &relative,
519 &format!("contribution `{}` data.resource", contribution.id),
520 )?;
521 Ok(Some(relative))
522}
523
524pub fn declared_resource_contribution_ids(package: &AgentPackage) -> Vec<&str> {
526 package
527 .contributions
528 .iter()
529 .filter(|contribution| contribution.data.get("resource").is_some())
530 .map(|contribution| contribution.id.as_str())
531 .collect()
532}
533
534pub fn read_contribution_resource(
541 package: &AgentPackage,
542 contribution_id: &str,
543) -> Result<Option<AgentPackageResource>> {
544 let contribution = package
545 .contributions
546 .iter()
547 .find(|contribution| contribution.id == contribution_id)
548 .ok_or_else(|| package_error(format!("unknown contribution id `{contribution_id}`")))?;
549 let Some(relative) = contribution_resource_relative(contribution)? else {
550 return Ok(None);
551 };
552 if !package.storage.path.exists() {
553 return Ok(None);
554 }
555 let storage = fs::canonicalize(&package.storage.path).map_err(|error| {
556 package_error(format!(
557 "resolving storage.path {}: {error}",
558 package.storage.path.display()
559 ))
560 })?;
561 if !storage.starts_with(&package.workspace) || !storage.is_dir() {
562 return Err(package_error("package storage is unavailable"));
563 }
564 let candidate = storage.join(&relative);
565 if !candidate.exists() {
566 return Ok(None);
567 }
568 let resource = fs::canonicalize(&candidate).map_err(|error| {
569 package_error(format!(
570 "resolving contribution resource {}: {error}",
571 candidate.display()
572 ))
573 })?;
574 if !resource.starts_with(&storage) {
575 return Err(package_error(format!(
576 "contribution `{contribution_id}` resource resolves outside package storage"
577 )));
578 }
579 let text = read_bounded(&resource, MAX_RESOURCE_BYTES, "contribution resource")?;
580 let extension = resource.extension().and_then(|value| value.to_str());
581 let (format, media_type, data) = if extension == Some("json") {
582 let data = serde_json::from_str(&text).map_err(|error| {
583 package_error(format!(
584 "parsing contribution `{contribution_id}` JSON resource: {error}"
585 ))
586 })?;
587 ("json", "application/json", data)
588 } else {
589 let media_type = match extension {
590 Some("md" | "markdown") => "text/markdown",
591 Some("yaml" | "yml") => "application/yaml",
592 _ => "text/plain",
593 };
594 ("text", media_type, Value::String(text))
595 };
596 Ok(Some(AgentPackageResource {
597 schema: RESOURCE_SCHEMA.to_string(),
598 contribution_id: contribution_id.to_string(),
599 format: format.to_string(),
600 media_type: media_type.to_string(),
601 data,
602 }))
603}
604
605pub(crate) fn workspace_package_instruction_files(workspace: &Path) -> Vec<PathBuf> {
609 match load_workspace_agent_package(workspace) {
610 Ok(Some(package)) => package.agent.instructions,
611 Ok(None) => Vec::new(),
612 Err(error) => {
613 eprintln!(
614 "warning: ignoring invalid Supercode agent package in {}: {error}",
615 workspace.display()
616 );
617 Vec::new()
618 }
619 }
620}