Skip to content

Commit 86a7bad

Browse files
committed
Add skills subcommand and plugin validation CI
Embed the 13 claude-plugin files in the binary at compile time via include_str!(), exposing them through `no skills install` (writes to ~/.claude/plugins/no/) and `no skills export <path>`. Add validate-plugin job to CI workflow.
1 parent a63d5b2 commit 86a7bad

8 files changed

Lines changed: 283 additions & 0 deletions

File tree

.github/workflows/ci.yml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,17 @@ jobs:
8686
- name: Run tests
8787
run: just test
8888

89+
plugin-validate:
90+
name: Validate Plugin
91+
runs-on: ubuntu-latest
92+
steps:
93+
- uses: actions/checkout@v4
94+
95+
- uses: taiki-e/install-action@just
96+
97+
- name: Validate plugin structure
98+
run: just validate-plugin
99+
89100
build-cross:
90101
name: Build (${{ matrix.name }})
91102
needs: lint

Cargo.lock

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ surge-ping = "0.8"
4040
[dev-dependencies]
4141
axum = { version = "0.8", features = ["ws"] }
4242
rumqttd = "0.20"
43+
tempfile = "3"
4344

4445
[profile.release]
4546
lto = true

justfile

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,3 +29,52 @@ test-integration:
2929

3030
run *args:
3131
cargo run -- {{args}}
32+
33+
validate-plugin:
34+
#!/usr/bin/env bash
35+
set -euo pipefail
36+
echo "Validating plugin structure..."
37+
# Check plugin.json exists and is valid JSON
38+
if ! python3 -m json.tool claude-plugin/.claude-plugin/plugin.json > /dev/null 2>&1; then
39+
echo "FAIL: claude-plugin/.claude-plugin/plugin.json is missing or invalid JSON"
40+
exit 1
41+
fi
42+
echo " plugin.json: OK"
43+
# Check all SKILL.md files exist and have YAML frontmatter
44+
skills=(
45+
"http-requests"
46+
"websocket-debugging"
47+
"network-diagnostics"
48+
"mqtt-messaging"
49+
"tcp-udp-testing"
50+
"sse-monitoring"
51+
"output-filtering"
52+
)
53+
for skill in "${skills[@]}"; do
54+
skill_file="claude-plugin/skills/${skill}/SKILL.md"
55+
if [ ! -f "${skill_file}" ]; then
56+
echo "FAIL: ${skill_file} not found"
57+
exit 1
58+
fi
59+
# Check YAML frontmatter (starts with ---)
60+
if ! head -1 "${skill_file}" | grep -q "^---$"; then
61+
echo "FAIL: ${skill_file} missing YAML frontmatter"
62+
exit 1
63+
fi
64+
# Check name in frontmatter matches directory name
65+
frontmatter_name=$(grep "^name:" "${skill_file}" | head -1 | sed 's/^name: *//')
66+
if [ "${frontmatter_name}" != "${skill}" ]; then
67+
echo "FAIL: ${skill_file} frontmatter name '${frontmatter_name}' does not match directory '${skill}'"
68+
exit 1
69+
fi
70+
echo " ${skill}/SKILL.md: OK"
71+
done
72+
# Check reference files exist
73+
for ref in cli-reference.md output-schema.md error-codes.md; do
74+
if [ ! -f "claude-plugin/references/${ref}" ]; then
75+
echo "FAIL: claude-plugin/references/${ref} not found"
76+
exit 1
77+
fi
78+
echo " references/${ref}: OK"
79+
done
80+
echo "Plugin validation passed."

src/cli.rs

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,39 @@ pub enum Command {
117117
/// Filter JSON from stdin with a jq expression.
118118
#[command(about = "Filter JSON from stdin with a jq expression")]
119119
Jq(JqArgs),
120+
121+
/// Manage AI agent skills.
122+
#[command(about = "Manage AI agent skills")]
123+
Skills {
124+
#[command(subcommand)]
125+
action: SkillsAction,
126+
},
127+
}
128+
129+
// -- Skills --
130+
131+
/// Skills subcommand: install or export AI agent skills.
132+
#[derive(Subcommand)]
133+
pub enum SkillsAction {
134+
#[command(about = "Install skills to ~/.claude/plugins/no")]
135+
Install(SkillsInstallArgs),
136+
137+
#[command(about = "Export skills to a directory")]
138+
Export(SkillsExportArgs),
139+
}
140+
141+
/// Arguments for skills install.
142+
#[derive(clap::Args)]
143+
pub struct SkillsInstallArgs {
144+
#[arg(long, value_name = "DIR", help = "Custom install directory")]
145+
pub path: Option<String>,
146+
}
147+
148+
/// Arguments for skills export.
149+
#[derive(clap::Args)]
150+
pub struct SkillsExportArgs {
151+
#[arg(help = "Target directory")]
152+
pub path: String,
120153
}
121154

122155
// -- Jq --
@@ -714,6 +747,42 @@ mod tests {
714747
assert_eq!(args.query, "8.8.8.8");
715748
}
716749

750+
#[test]
751+
fn skills_install() {
752+
let cli = Cli::try_parse_from(["no", "skills", "install"]).unwrap();
753+
let Command::Skills { action } = cli.command else {
754+
panic!("expected Skills command")
755+
};
756+
let SkillsAction::Install(args) = action else {
757+
panic!("expected Install action")
758+
};
759+
assert!(args.path.is_none());
760+
}
761+
762+
#[test]
763+
fn skills_install_with_path() {
764+
let cli = Cli::try_parse_from(["no", "skills", "install", "--path", "/tmp/custom"]).unwrap();
765+
let Command::Skills { action } = cli.command else {
766+
panic!("expected Skills command")
767+
};
768+
let SkillsAction::Install(args) = action else {
769+
panic!("expected Install action")
770+
};
771+
assert_eq!(args.path.as_deref(), Some("/tmp/custom"));
772+
}
773+
774+
#[test]
775+
fn skills_export() {
776+
let cli = Cli::try_parse_from(["no", "skills", "export", "/tmp/out"]).unwrap();
777+
let Command::Skills { action } = cli.command else {
778+
panic!("expected Skills command")
779+
};
780+
let SkillsAction::Export(args) = action else {
781+
panic!("expected Export action")
782+
};
783+
assert_eq!(args.path, "/tmp/out");
784+
}
785+
717786
#[test]
718787
fn missing_required_args_fails() {
719788
let result = Cli::try_parse_from(["no", "http"]);

src/main.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ async fn main() {
5050
Command::Ping(args) => protocols::ping::run(args, mode, no_color, timeout, count, verbose).await,
5151
Command::Whois(args) => protocols::whois::run(args, mode, no_color, timeout, verbose).await,
5252
Command::Jq(args) => protocols::jq::run(args).await,
53+
Command::Skills { action } => protocols::skills::run(action).await,
5354
};
5455

5556
if let Err(e) = result {

src/protocols/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub mod http;
1111
pub mod jq;
1212
pub mod mqtt;
1313
pub mod ping;
14+
pub mod skills;
1415
pub mod sse;
1516
pub mod tcp;
1617
pub mod udp;

src/protocols/skills.rs

Lines changed: 150 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,150 @@
1+
use crate::cli::SkillsAction;
2+
use crate::error::{ErrorCode, NetError};
3+
use crate::output::Protocol;
4+
use std::fs;
5+
use std::path::{Path, PathBuf};
6+
7+
const PLUGIN_FILES: &[(&str, &str)] = &[
8+
(
9+
".claude-plugin/plugin.json",
10+
include_str!("../../claude-plugin/.claude-plugin/plugin.json"),
11+
),
12+
("README.md", include_str!("../../claude-plugin/README.md")),
13+
("LICENSE", include_str!("../../claude-plugin/LICENSE")),
14+
(
15+
"skills/http-requests/SKILL.md",
16+
include_str!("../../claude-plugin/skills/http-requests/SKILL.md"),
17+
),
18+
(
19+
"skills/websocket-debugging/SKILL.md",
20+
include_str!("../../claude-plugin/skills/websocket-debugging/SKILL.md"),
21+
),
22+
(
23+
"skills/network-diagnostics/SKILL.md",
24+
include_str!("../../claude-plugin/skills/network-diagnostics/SKILL.md"),
25+
),
26+
(
27+
"skills/mqtt-messaging/SKILL.md",
28+
include_str!("../../claude-plugin/skills/mqtt-messaging/SKILL.md"),
29+
),
30+
(
31+
"skills/tcp-udp-testing/SKILL.md",
32+
include_str!("../../claude-plugin/skills/tcp-udp-testing/SKILL.md"),
33+
),
34+
(
35+
"skills/sse-monitoring/SKILL.md",
36+
include_str!("../../claude-plugin/skills/sse-monitoring/SKILL.md"),
37+
),
38+
(
39+
"skills/output-filtering/SKILL.md",
40+
include_str!("../../claude-plugin/skills/output-filtering/SKILL.md"),
41+
),
42+
(
43+
"references/cli-reference.md",
44+
include_str!("../../claude-plugin/references/cli-reference.md"),
45+
),
46+
(
47+
"references/output-schema.md",
48+
include_str!("../../claude-plugin/references/output-schema.md"),
49+
),
50+
(
51+
"references/error-codes.md",
52+
include_str!("../../claude-plugin/references/error-codes.md"),
53+
),
54+
];
55+
56+
fn home_dir() -> Result<PathBuf, NetError> {
57+
std::env::var("HOME")
58+
.or_else(|_| std::env::var("USERPROFILE"))
59+
.map(PathBuf::from)
60+
.map_err(|_| NetError::new(ErrorCode::IoError, "could not determine home directory", Protocol::Http))
61+
}
62+
63+
fn write_plugin(target: &Path) -> Result<(), NetError> {
64+
for (rel_path, content) in PLUGIN_FILES {
65+
let dest = target.join(rel_path);
66+
if let Some(parent) = dest.parent() {
67+
fs::create_dir_all(parent).map_err(|e| {
68+
NetError::new(
69+
ErrorCode::IoError,
70+
format!("failed to create directory {}: {e}", parent.display()),
71+
Protocol::Http,
72+
)
73+
})?;
74+
}
75+
fs::write(&dest, content).map_err(|e| {
76+
NetError::new(
77+
ErrorCode::IoError,
78+
format!("failed to write {}: {e}", dest.display()),
79+
Protocol::Http,
80+
)
81+
})?;
82+
}
83+
Ok(())
84+
}
85+
86+
pub async fn run(action: SkillsAction) -> Result<(), NetError> {
87+
match action {
88+
SkillsAction::Install(args) => {
89+
let target = match args.path {
90+
Some(p) => PathBuf::from(p),
91+
None => home_dir()?.join(".claude/plugins/no"),
92+
};
93+
write_plugin(&target)?;
94+
println!("Skills installed to {}", target.display());
95+
}
96+
SkillsAction::Export(args) => {
97+
let target = PathBuf::from(&args.path);
98+
write_plugin(&target)?;
99+
println!("Skills exported to {}", target.display());
100+
}
101+
}
102+
Ok(())
103+
}
104+
105+
#[cfg(test)]
106+
mod tests {
107+
use super::*;
108+
109+
#[test]
110+
fn plugin_files_not_empty() {
111+
for (path, content) in PLUGIN_FILES {
112+
assert!(!content.is_empty(), "embedded file {path} should not be empty");
113+
}
114+
}
115+
116+
#[test]
117+
fn plugin_files_count() {
118+
assert_eq!(PLUGIN_FILES.len(), 13);
119+
}
120+
121+
#[tokio::test]
122+
async fn install_to_temp_dir() {
123+
let dir = tempfile::tempdir().unwrap();
124+
let target = dir.path().to_path_buf();
125+
let action = SkillsAction::Install(crate::cli::SkillsInstallArgs {
126+
path: Some(target.to_string_lossy().into_owned()),
127+
});
128+
run(action).await.unwrap();
129+
130+
assert!(target.join(".claude-plugin/plugin.json").exists());
131+
assert!(target.join("skills/http-requests/SKILL.md").exists());
132+
assert!(target.join("references/cli-reference.md").exists());
133+
assert!(target.join("README.md").exists());
134+
assert!(target.join("LICENSE").exists());
135+
}
136+
137+
#[tokio::test]
138+
async fn export_to_temp_dir() {
139+
let dir = tempfile::tempdir().unwrap();
140+
let target = dir.path().join("exported");
141+
let action = SkillsAction::Export(crate::cli::SkillsExportArgs {
142+
path: target.to_string_lossy().into_owned(),
143+
});
144+
run(action).await.unwrap();
145+
146+
assert!(target.join(".claude-plugin/plugin.json").exists());
147+
assert!(target.join("skills/mqtt-messaging/SKILL.md").exists());
148+
assert!(target.join("references/error-codes.md").exists());
149+
}
150+
}

0 commit comments

Comments
 (0)