use crate::cli::{
SkillAdminAction, SkillAdminOptions, SkillAdminRequest, SkillAgentSelection, SkillScope,
};
use agent_first_data::{cli_output, OutputFormat};
use serde::Serialize;
use serde_json::{json, Value};
use std::io::Write;
use std::path::{Path, PathBuf};
const SKILL_NAME: &str = "agent-first-psql";
const SKILL_FILE_NAME: &str = "SKILL.md";
const SKILL_SOURCE: &str = include_str!("../skills/agent-first-psql.md");
const MARKER: &str = "afpsql-managed-skill: true";
const GENERATED_BY: &str = "Generated by afpsql skill install";
pub fn run(req: SkillAdminRequest) -> i32 {
let result = handle_action(req.action);
let (code, value) = match result {
Ok(value) => (0, value),
Err(err) => (
1,
agent_first_data::build_cli_error(&err.message, err.hint.as_deref()),
),
};
emit_value(&value, req.output);
code
}
fn emit_value(value: &Value, output: OutputFormat) {
let rendered = cli_output(value, output);
let _ = writeln!(std::io::stdout(), "{rendered}");
}
pub(crate) fn handle_action(action: SkillAdminAction) -> Result<Value, AdminError> {
match action {
SkillAdminAction::Status(options) => status(options),
SkillAdminAction::Install(options) => install(options),
SkillAdminAction::Uninstall(options) => uninstall(options),
}
}
fn status(options: SkillAdminOptions) -> Result<Value, AdminError> {
let targets = resolve_targets(&options)?;
let mut statuses = Vec::with_capacity(targets.len());
for target in &targets {
statuses.push(target_status(target)?);
}
let installed_all = statuses.iter().all(|status| {
status
.get("installed")
.and_then(Value::as_bool)
.unwrap_or(false)
});
let valid_all = statuses.iter().all(|status| {
status
.get("valid")
.and_then(Value::as_bool)
.unwrap_or(false)
});
Ok(agent_first_data::build_json(
"skill_status",
json!({
"skill": SKILL_NAME,
"installed_all": installed_all,
"valid_all": valid_all,
"targets": statuses,
}),
None,
))
}
fn install(options: SkillAdminOptions) -> Result<Value, AdminError> {
validate_skill_text(SKILL_SOURCE)?;
let targets = resolve_targets(&options)?;
let content = managed_skill_contents();
let mut installed = Vec::with_capacity(targets.len());
for target in &targets {
std::fs::create_dir_all(&target.skill_dir)
.map_err(|e| AdminError::io("create skill dir", e))?;
if target.skill_path.exists()
&& !is_managed_or_bundled_skill(&target.skill_path)?
&& !options.force
{
return Err(AdminError::invalid_request(
format!(
"refusing to overwrite unmanaged skill at {}",
target.skill_path.display()
),
Some("pass --force to replace it, or choose another --skills-dir".to_string()),
));
}
std::fs::write(&target.skill_path, &content)
.map_err(|e| AdminError::io("write skill", e))?;
validate_installed_skill(&target.skill_path)?;
installed.push(target_status(target)?);
}
Ok(agent_first_data::build_json(
"skill_install",
json!({
"skill": SKILL_NAME,
"installed": true,
"targets": installed,
"hint": "restart the agent so it reloads installed skills"
}),
None,
))
}
fn uninstall(options: SkillAdminOptions) -> Result<Value, AdminError> {
let targets = resolve_targets(&options)?;
let mut removed = Vec::with_capacity(targets.len());
for target in &targets {
if !target.skill_path.exists() {
removed.push(target_uninstall_status(target, false));
continue;
}
if !is_managed_or_bundled_skill(&target.skill_path)? && !options.force {
return Err(AdminError::invalid_request(
format!(
"refusing to remove unmanaged skill at {}",
target.skill_path.display()
),
Some(
"only skills generated by afpsql skill install can be removed without --force"
.to_string(),
),
));
}
std::fs::remove_file(&target.skill_path).map_err(|e| AdminError::io("remove skill", e))?;
let _ = std::fs::remove_dir(&target.skill_dir);
removed.push(target_uninstall_status(target, true));
}
Ok(agent_first_data::build_json(
"skill_uninstall",
json!({
"skill": SKILL_NAME,
"removed_any": removed.iter().any(|status| {
status.get("removed").and_then(Value::as_bool).unwrap_or(false)
}),
"targets": removed,
}),
None,
))
}
#[derive(Clone, Copy)]
enum SkillAgent {
Codex,
ClaudeCode,
}
struct SkillTarget {
agent: SkillAgent,
scope: SkillScope,
skills_dir: PathBuf,
skill_dir: PathBuf,
skill_path: PathBuf,
}
fn resolve_targets(options: &SkillAdminOptions) -> Result<Vec<SkillTarget>, AdminError> {
if options.skills_dir.is_some() && options.agent == SkillAgentSelection::All {
return Err(AdminError::invalid_request(
"--skills-dir requires --agent codex or --agent claude-code".to_string(),
Some("custom skills directories are ambiguous when --agent all is used".to_string()),
));
}
match (options.agent, options.scope) {
(SkillAgentSelection::All, SkillScope::Personal) => Ok(vec![
resolve_target(SkillAgent::Codex, SkillScope::Personal, None)?,
resolve_target(SkillAgent::ClaudeCode, SkillScope::Personal, None)?,
]),
(SkillAgentSelection::All, SkillScope::Project) => Err(AdminError::invalid_request(
"project skill scope is only supported for Claude Code".to_string(),
Some("use --agent claude-code --scope project".to_string()),
)),
(SkillAgentSelection::Codex, SkillScope::Project) => Err(AdminError::invalid_request(
"Codex project skill scope is not supported".to_string(),
Some(
"use personal scope for Codex, or --agent claude-code --scope project".to_string(),
),
)),
(SkillAgentSelection::Codex, SkillScope::Personal) => Ok(vec![resolve_target(
SkillAgent::Codex,
SkillScope::Personal,
options.skills_dir.as_deref(),
)?]),
(SkillAgentSelection::ClaudeCode, scope) => Ok(vec![resolve_target(
SkillAgent::ClaudeCode,
scope,
options.skills_dir.as_deref(),
)?]),
}
}
fn resolve_target(
agent: SkillAgent,
scope: SkillScope,
skills_dir: Option<&str>,
) -> Result<SkillTarget, AdminError> {
let skills_dir = match skills_dir {
Some(dir) => expand_tilde(dir)?,
None => default_skills_dir(agent, scope)?,
};
let skill_dir = skills_dir.join(SKILL_NAME);
let skill_path = skill_dir.join(SKILL_FILE_NAME);
Ok(SkillTarget {
agent,
scope,
skills_dir,
skill_dir,
skill_path,
})
}
fn default_skills_dir(agent: SkillAgent, scope: SkillScope) -> Result<PathBuf, AdminError> {
match (agent, scope) {
(SkillAgent::Codex, SkillScope::Personal) => {
if let Some(codex_home) = std::env::var_os("CODEX_HOME") {
Ok(PathBuf::from(codex_home).join("skills"))
} else {
Ok(home_dir()?.join(".codex").join("skills"))
}
}
(SkillAgent::Codex, SkillScope::Project) => Err(AdminError::invalid_request(
"Codex project skill scope is not supported".to_string(),
None,
)),
(SkillAgent::ClaudeCode, SkillScope::Personal) => {
Ok(home_dir()?.join(".claude").join("skills"))
}
(SkillAgent::ClaudeCode, SkillScope::Project) => std::env::current_dir()
.map(|dir| dir.join(".claude").join("skills"))
.map_err(|e| AdminError::io("resolve current directory", e)),
}
}
fn target_status(target: &SkillTarget) -> Result<Value, AdminError> {
let installed = target.skill_path.is_file();
let mut valid = false;
let mut validation_error = Value::Null;
let mut managed = false;
if installed {
let text = std::fs::read_to_string(&target.skill_path)
.map_err(|e| AdminError::io("read skill", e))?;
managed = skill_text_is_managed_or_bundled(&text);
match validate_skill_frontmatter(&text) {
Ok(()) => valid = true,
Err(err) => validation_error = json!(err),
}
}
Ok(json!({
"agent": target.agent.label(),
"scope": target.scope.label(),
"skills_dir": target.skills_dir,
"skill_path": target.skill_path,
"installed": installed,
"managed": managed,
"valid": valid,
"validation_error": validation_error,
}))
}
fn target_uninstall_status(target: &SkillTarget, removed: bool) -> Value {
json!({
"agent": target.agent.label(),
"scope": target.scope.label(),
"skills_dir": target.skills_dir,
"skill_path": target.skill_path,
"removed": removed,
})
}
impl SkillAgent {
fn label(self) -> &'static str {
match self {
SkillAgent::Codex => "codex",
SkillAgent::ClaudeCode => "claude-code",
}
}
}
impl SkillScope {
fn label(self) -> &'static str {
match self {
SkillScope::Personal => "personal",
SkillScope::Project => "project",
}
}
}
fn managed_skill_contents() -> String {
let mut lines = SKILL_SOURCE.lines();
let mut output = String::new();
let mut inserted = false;
if let Some(first) = lines.next() {
output.push_str(first);
output.push('\n');
}
for line in lines {
output.push_str(line);
output.push('\n');
if !inserted && line.trim() == "---" {
output.push_str("<!-- ");
output.push_str(GENERATED_BY);
output.push_str(" -->\n");
output.push_str("<!-- ");
output.push_str(MARKER);
output.push_str(" -->\n\n");
inserted = true;
}
}
if !inserted {
output.push_str("<!-- ");
output.push_str(GENERATED_BY);
output.push_str(" -->\n");
output.push_str("<!-- ");
output.push_str(MARKER);
output.push_str(" -->\n");
}
output
}
fn validate_installed_skill(path: &Path) -> Result<(), AdminError> {
let text =
std::fs::read_to_string(path).map_err(|e| AdminError::io("read installed skill", e))?;
validate_skill_text(&text)
}
fn validate_skill_text(text: &str) -> Result<(), AdminError> {
validate_skill_frontmatter(text).map_err(|err| {
AdminError::invalid_request(
format!("invalid Agent-First PSQL skill front matter: {err}"),
Some("quote scalar values that contain ': ', especially description".to_string()),
)
})
}
fn validate_skill_frontmatter(text: &str) -> Result<(), String> {
let mut lines = text.lines().enumerate();
let Some((_, first)) = lines.next() else {
return Err("missing YAML front matter".to_string());
};
if first.trim() != "---" {
return Err("missing opening --- YAML front matter delimiter".to_string());
}
let mut found_end = false;
let mut has_name = false;
let mut has_description = false;
for (idx, line) in lines {
let line_no = idx + 1;
let trimmed = line.trim();
if trimmed == "---" {
found_end = true;
break;
}
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
if line.starts_with(' ') || line.starts_with('\t') {
return Err(format!("line {line_no}: nested YAML is not supported here"));
}
let Some((key, value)) = line.split_once(':') else {
return Err(format!("line {line_no}: expected key: value"));
};
let key = key.trim();
if key.is_empty() {
return Err(format!("line {line_no}: empty key"));
}
let value = value.trim_start();
if key == "name" {
has_name = true;
}
if key == "description" {
has_description = true;
}
if value.starts_with('"') || value.starts_with('\'') {
continue;
}
if value.contains(": ") {
return Err(format!(
"line {line_no}: unquoted scalar contains ': '; quote the value"
));
}
}
if !found_end {
return Err("missing closing --- YAML front matter delimiter".to_string());
}
if !has_name {
return Err("missing required name field".to_string());
}
if !has_description {
return Err("missing required description field".to_string());
}
Ok(())
}
fn is_managed_or_bundled_skill(path: &Path) -> Result<bool, AdminError> {
if !path.exists() {
return Ok(false);
}
let text = std::fs::read_to_string(path).map_err(|e| AdminError::io("read skill", e))?;
Ok(skill_text_is_managed_or_bundled(&text))
}
fn skill_text_is_managed_or_bundled(text: &str) -> bool {
(text.contains(MARKER) && text.contains(GENERATED_BY))
|| normalize_skill_text(text) == normalize_skill_text(SKILL_SOURCE)
}
fn normalize_skill_text(text: &str) -> String {
text.lines()
.filter(|line| {
let trimmed = line.trim();
!trimmed.contains(MARKER) && !trimmed.contains(GENERATED_BY)
})
.collect::<Vec<_>>()
.join("\n")
.trim()
.to_string()
}
fn home_dir() -> Result<PathBuf, AdminError> {
std::env::var_os("HOME")
.or_else(|| std::env::var_os("USERPROFILE"))
.map(PathBuf::from)
.ok_or_else(|| {
AdminError::invalid_request(
"cannot determine home directory".to_string(),
Some("pass --skills-dir explicitly".to_string()),
)
})
}
fn expand_tilde(input: &str) -> Result<PathBuf, AdminError> {
if input == "~" {
return home_dir();
}
if let Some(rest) = input.strip_prefix("~/") {
return Ok(home_dir()?.join(rest));
}
Ok(PathBuf::from(input))
}
#[derive(Debug, Serialize)]
pub(crate) struct AdminError {
message: String,
#[serde(skip_serializing_if = "Option::is_none")]
hint: Option<String>,
}
impl AdminError {
fn invalid_request(message: String, hint: Option<String>) -> Self {
Self { message, hint }
}
fn io(action: &str, err: std::io::Error) -> Self {
Self {
message: format!("{action} failed: {err}"),
hint: None,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
fn temp_skills_dir(name: &str) -> PathBuf {
let suffix = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
std::env::temp_dir().join(format!(
"afpsql_skill_{name}_{}_{}",
std::process::id(),
suffix
))
}
fn codex_options(dir: &Path, force: bool) -> SkillAdminOptions {
SkillAdminOptions {
agent: SkillAgentSelection::Codex,
scope: SkillScope::Personal,
skills_dir: Some(dir.to_string_lossy().to_string()),
force,
}
}
fn claude_options(dir: &Path, force: bool) -> SkillAdminOptions {
SkillAdminOptions {
agent: SkillAgentSelection::ClaudeCode,
scope: SkillScope::Personal,
skills_dir: Some(dir.to_string_lossy().to_string()),
force,
}
}
#[test]
fn validates_bundled_skill_frontmatter() {
assert!(validate_skill_frontmatter(SKILL_SOURCE).is_ok());
}
#[test]
fn rejects_unquoted_colon_space_in_frontmatter() {
let bad = "---\nname: agent-first-psql\ndescription: broken: yaml\n---\n";
let err = validate_skill_frontmatter(bad);
assert!(err.is_err());
if let Err(err) = err {
assert!(err.contains("unquoted scalar"));
}
}
#[test]
fn install_status_uninstall_codex_skill() {
let dir = temp_skills_dir("codex");
let options = codex_options(&dir, false);
let installed = handle_action(SkillAdminAction::Install(options.clone()));
assert!(installed.is_ok());
let skill_path = dir.join(SKILL_NAME).join(SKILL_FILE_NAME);
assert!(skill_path.is_file());
let text = std::fs::read_to_string(&skill_path).unwrap_or_default();
assert!(text.contains(MARKER));
assert!(validate_skill_frontmatter(&text).is_ok());
let status = handle_action(SkillAdminAction::Status(options.clone()));
assert!(status.is_ok());
if let Ok(value) = status {
assert_eq!(value["installed_all"], true);
assert_eq!(value["valid_all"], true);
assert_eq!(value["targets"][0]["agent"], "codex");
}
let removed = handle_action(SkillAdminAction::Uninstall(options));
assert!(removed.is_ok());
assert!(!skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn install_status_uninstall_claude_code_skill() {
let dir = temp_skills_dir("claude");
let options = claude_options(&dir, false);
let installed = handle_action(SkillAdminAction::Install(options.clone()));
assert!(installed.is_ok());
let skill_path = dir.join(SKILL_NAME).join(SKILL_FILE_NAME);
assert!(skill_path.is_file());
let status = handle_action(SkillAdminAction::Status(options.clone()));
assert!(status.is_ok());
if let Ok(value) = status {
assert_eq!(value["targets"][0]["agent"], "claude-code");
assert_eq!(value["targets"][0]["valid"], true);
}
let removed = handle_action(SkillAdminAction::Uninstall(options));
assert!(removed.is_ok());
assert!(!skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn install_and_uninstall_refuse_unmanaged_skill() {
let dir = temp_skills_dir("unmanaged");
let skill_dir = dir.join(SKILL_NAME);
let skill_path = skill_dir.join(SKILL_FILE_NAME);
assert!(std::fs::create_dir_all(&skill_dir).is_ok());
assert!(
std::fs::write(&skill_path, "---\nname: custom\ndescription: custom\n---\n").is_ok()
);
let options = codex_options(&dir, false);
let install = handle_action(SkillAdminAction::Install(options.clone()));
assert!(install.is_err());
let uninstall = handle_action(SkillAdminAction::Uninstall(options));
assert!(uninstall.is_err());
assert!(skill_path.exists());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn all_personal_resolves_codex_and_claude_code_targets() {
let options = SkillAdminOptions {
agent: SkillAgentSelection::All,
scope: SkillScope::Personal,
skills_dir: None,
force: false,
};
let targets = resolve_targets(&options);
assert!(targets.is_ok());
if let Ok(targets) = targets {
assert_eq!(targets.len(), 2);
assert_eq!(targets[0].agent.label(), "codex");
assert_eq!(targets[1].agent.label(), "claude-code");
}
}
}