qq-cli 0.1.0

A modular Rust QQ task interface for coding agents
//! Offline task instructions and a portable, self-contained skill artifact.
use std::fs::File;
use std::io::Write;
use std::path::Path;

use crate::core::contracts::discovery::{SkillExport, SkillGuide, SkillName, SkillSummary};

use crate::cli::args::SkillCommand;
use crate::core::error::{AppError, Result};
use crate::core::output::Envelope;
use crate::storage::state::reject_links;

struct Task {
    id: &'static str,
    summary: &'static str,
    commands: &'static [&'static str],
    body: &'static str,
}
const SHARED: &str = include_str!("../../skills/qq-cli/references/shared.md");
const TASKS: &[Task] = &[
    Task {
        id: "discover",
        summary: "Find recipients and resolve instance ambiguity",
        commands: &[
            "contact.list",
            "contact.get",
            "group.list",
            "group.get",
            "group.members",
            "conversation.list",
            "conversation.get",
            "napcat.instances",
            "status",
        ],
        body: include_str!("../../skills/qq-cli/references/discover.md"),
    },
    Task {
        id: "read",
        summary: "Read, search or export bounded history",
        commands: &[
            "message.get",
            "message.history",
            "message.search",
            "message.export",
        ],
        body: include_str!("../../skills/qq-cli/references/read.md"),
    },
    Task {
        id: "send",
        summary: "Send or reply with durable operation keys",
        commands: &["message.send", "message.reply", "request.get"],
        body: include_str!("../../skills/qq-cli/references/send.md"),
    },
    Task {
        id: "media",
        summary: "Download referenced attachments or prune owned cache",
        commands: &["media.download", "media.prune"],
        body: include_str!("../../skills/qq-cli/references/media.md"),
    },
    Task {
        id: "events",
        summary: "Observe a bounded stream and report gaps",
        commands: &["event.watch"],
        body: include_str!("../../skills/qq-cli/references/events.md"),
    },
    Task {
        id: "runtime",
        summary: "Manage fixed installations and independent instances",
        commands: &[
            "doctor",
            "napcat.install",
            "napcat.installations",
            "napcat.uninstall",
            "napcat.start",
            "napcat.instances",
            "napcat.stop",
            "napcat.restart",
            "napcat.remove",
            "napcat.update",
            "napcat.rollback",
            "request.get",
        ],
        body: include_str!("../../skills/qq-cli/references/runtime.md"),
    },
];

pub fn list() -> Vec<SkillSummary> {
    TASKS
        .iter()
        .map(|task| SkillSummary {
            task: task.id,
            summary: task.summary,
        })
        .collect()
}
pub fn get(id: &str) -> Result<SkillGuide> {
    let task = TASKS
        .iter()
        .find(|task| task.id == id)
        .ok_or_else(|| AppError::new("TASK_NOT_FOUND", "Unknown task; use skill list.", 2))?;
    Ok(SkillGuide {
        task: task.id,
        commands: task.commands,
        instructions: format!("{}\n{}", SHARED.trim_end(), task.body),
    })
}

pub fn execute(command: &SkillCommand) -> Result<Envelope> {
    match command {
        SkillCommand::List => Ok(Envelope::typed(list())),
        SkillCommand::Get { task } => Ok(Envelope::typed(get(task)?)),
        SkillCommand::Export { output } => export(output),
    }
}

fn export(output: &Path) -> Result<Envelope> {
    reject_links(output)?;
    let output = std::path::absolute(output).map_err(|_| export_error())?;
    let parent = output
        .parent()
        .ok_or_else(export_error)?
        .canonicalize()
        .map_err(|_| export_error())?;
    reject_links(&parent)?;
    if output.try_exists().map_err(|_| export_error())? {
        return Err(AppError::new(
            "OUTPUT_EXISTS",
            "Skill export target already exists; choose a new directory.",
            2,
        ));
    }
    let temporary = tempfile::Builder::new()
        .prefix(".qq-cli-skill-")
        .tempdir_in(parent)
        .map_err(|_| export_error())?;
    std::fs::create_dir(temporary.path().join("references")).map_err(|_| export_error())?;
    let mut assets = vec![
        (
            "SKILL.md".to_owned(),
            include_str!("../../skills/qq-cli/SKILL.md"),
        ),
        ("references/shared.md".into(), SHARED),
    ];
    assets.extend(
        TASKS
            .iter()
            .map(|task| (format!("references/{}.md", task.id), task.body)),
    );
    for (path, body) in &assets {
        let mut file = File::create_new(temporary.path().join(path)).map_err(|_| export_error())?;
        file.write_all(body.as_bytes())
            .and_then(|_| file.sync_all())
            .map_err(|_| export_error())?;
    }
    reject_links(&output)?;
    if output.try_exists().map_err(|_| export_error())? {
        return Err(AppError::new(
            "OUTPUT_EXISTS",
            "Skill export target appeared during preparation; it was preserved.",
            2,
        ));
    }
    // On the supported Windows target, rename publishes the complete directory and refuses an existing target.
    std::fs::rename(temporary.path(), &output).map_err(|_| export_error())?;
    let _ = temporary.keep();
    Ok(Envelope::typed(SkillExport {
        output,
        skill: SkillName::QqCli,
        version: env!("CARGO_PKG_VERSION"),
        files: assets.len(),
    }))
}

fn export_error() -> AppError {
    AppError::new(
        "SKILL_EXPORT_IO",
        "The skill package could not be prepared or published safely.",
        6,
    )
}