Skip to content

Commit 73ef0ce

Browse files
authored
feat(cli): link every remote Skill to its exact source (#130)
1 parent 963a001 commit 73ef0ce

13 files changed

Lines changed: 595 additions & 103 deletions

File tree

crates/skilld-command/src/lib.rs

Lines changed: 188 additions & 58 deletions
Large diffs are not rendered by default.

crates/skilld-command/src/output.rs

Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ use skilld_core::{ListedSkill, SkillListing, UpdatePlanV1};
44
use skilld_ui::text::{grouped_number, is_unsafe_terminal, sanitize, width, wrap};
55
use skilld_ui::{Role, paint};
66

7+
use crate::provenance::{RemoteProvenance, source_status_caution};
78
use crate::run::{FileContent, PulledFile, RunOutcome, SkillOrigin, TransientSkill};
89
use crate::{CommandError, CommandErrorKind};
910

@@ -115,9 +116,18 @@ pub(crate) struct SearchItem {
115116
pub name: String,
116117
pub selector: String,
117118
pub description: Option<String>,
119+
pub owner: String,
120+
pub repository: String,
118121
pub stargazer_count: u64,
119122
}
120123

124+
impl SearchItem {
125+
/// `owner/repository`, the way GitHub names it.
126+
fn slug(&self) -> String {
127+
format!("{}/{}", self.owner, self.repository)
128+
}
129+
}
130+
121131
pub(crate) fn render_search(
122132
outcome: &SearchOutcome,
123133
mode: OutputMode,
@@ -247,6 +257,8 @@ fn render_plain(outcome: &SearchOutcome) -> String {
247257
output.push('\t');
248258
output.push_str(&escape_plain(&item.selector));
249259
output.push('\t');
260+
output.push_str(&escape_plain(&item.slug()));
261+
output.push('\t');
250262
output.push_str(&item.stargazer_count.to_string());
251263
output.push('\t');
252264
output.push_str(&escape_plain(
@@ -296,23 +308,32 @@ fn render_human(
296308
for item in &outcome.items {
297309
output.push('\n');
298310
let name = sanitize(&item.name);
311+
let slug = sanitize(&item.slug());
299312
let stars = format!("{} stars", grouped_number(item.stargazer_count));
300-
if 2 + width(&name) + 2 + width(&stars) <= columns {
301-
let gap = columns - 2 - width(&name) - width(&stars);
313+
let meta = format!(
314+
"{} · {}",
315+
paint(&slug, Role::Dim, color),
316+
paint(&stars, Role::Warn, color)
317+
);
318+
let meta_width = width(&slug) + 3 + width(&stars);
319+
if 2 + width(&name) + 2 + meta_width <= columns {
320+
let gap = columns - 2 - width(&name) - meta_width;
302321
output.push_str(" ");
303322
output.push_str(&paint(&name, Role::Emphasis, color));
304323
output.push_str(&" ".repeat(gap));
305-
output.push_str(&paint(&stars, Role::Warn, color));
324+
output.push_str(&meta);
306325
output.push('\n');
307326
} else {
308327
for line in wrap(&name, columns.saturating_sub(2)) {
309328
output.push_str(" ");
310329
output.push_str(&paint(&line, Role::Emphasis, color));
311330
output.push('\n');
312331
}
313-
output.push_str(" ");
314-
output.push_str(&paint(&stars, Role::Warn, color));
315-
output.push('\n');
332+
for line in wrap(&format!("{slug} · {stars}"), columns.saturating_sub(2)) {
333+
output.push_str(" ");
334+
output.push_str(&paint(&line, Role::Dim, color));
335+
output.push('\n');
336+
}
316337
}
317338

318339
if let Some(description) = &item.description {
@@ -588,9 +609,17 @@ fn render_load(skill: &TransientSkill, color: bool, platform: CommandPlatform) -
588609
out.push_str("skilld wrote no Skill files.\n");
589610
out.push_str(&field("Source", "skilld-maintained Skill", color));
590611
}
591-
SkillOrigin::Remote { source, .. } => {
612+
SkillOrigin::Remote {
613+
source, provenance, ..
614+
} => {
592615
out.push_str("skilld retained no Skill files.\n");
593616
out.push_str("It created no lockfile entry, Agent target, or project file.\n");
617+
out.push_str(&paint(
618+
&sanitize(&provenance.headline(&skill.name)),
619+
Role::Emphasis,
620+
color,
621+
));
622+
out.push('\n');
594623
out.push_str(&field("Source", source, color));
595624
}
596625
SkillOrigin::Local { root } => {
@@ -603,6 +632,7 @@ fn render_load(skill: &TransientSkill, color: bool, platform: CommandPlatform) -
603632
}
604633
out.push_str(&field("Source status", skill.source_status, color));
605634
out.push_str(source_status_caution(skill.source_status));
635+
out.push_str(&read_it_first(&skill.origin, color));
606636

607637
out.push('\n');
608638
out.push_str(&paint("--- SKILL.md ---", Role::Dim, color));
@@ -699,6 +729,7 @@ fn render_files(
699729
}
700730
out.push_str(&field("Source status", source_status, color));
701731
out.push_str(source_status_caution(source_status));
732+
out.push_str(&read_it_first(origin, color));
702733
out.push('\n');
703734
for file in files {
704735
let path = sanitize(&file.path);
@@ -849,17 +880,14 @@ fn shell_quote(argument: &str, platform: CommandPlatform) -> String {
849880
}
850881
}
851882

852-
/// State what the status covers, on every status.
853-
///
854-
/// A verified Artifact proves where the bytes came from. It says nothing about
855-
/// what the instructions ask an Agent to do, and the output must not imply it.
856-
fn source_status_caution(status: &str) -> &'static str {
857-
match status {
858-
"verified" => {
859-
"skilld checked where this Skill came from, not what it asks you to do.\nRead it before you follow it.\n"
883+
/// Point at the exact SKILL.md on GitHub. Local and bundled Skills sit on
884+
/// disk already, so they get no link.
885+
fn read_it_first(origin: &SkillOrigin, color: bool) -> String {
886+
match origin {
887+
SkillOrigin::Remote { provenance, .. } => {
888+
field("Read it first", &provenance.source_url, color)
860889
}
861-
"unverified" => "skilld did not check this source. Read this Skill before you follow it.\n",
862-
_ => "Read this Skill before you follow it.\n",
890+
SkillOrigin::Bundled | SkillOrigin::Local { .. } => String::new(),
863891
}
864892
}
865893

@@ -884,19 +912,51 @@ fn safe_terminal_text(value: &str) -> String {
884912

885913
#[derive(Serialize)]
886914
#[serde(tag = "_tag", rename_all = "lowercase")]
915+
#[serde(rename_all_fields = "camelCase")]
887916
enum JsonOrigin {
888-
Bundled { source: &'static str },
889-
Remote { source: String, direct: bool },
890-
Local { root: String },
917+
Bundled {
918+
source: &'static str,
919+
},
920+
Remote {
921+
source: String,
922+
direct: bool,
923+
owner: String,
924+
repository: String,
925+
skill_path: String,
926+
commit: String,
927+
source_url: String,
928+
},
929+
Local {
930+
root: String,
931+
},
891932
}
892933

893934
fn origin_json(origin: &SkillOrigin) -> JsonOrigin {
894935
match origin {
895936
SkillOrigin::Bundled => JsonOrigin::Bundled { source: "skilld" },
896-
SkillOrigin::Remote { source, direct, .. } => JsonOrigin::Remote {
897-
source: source.clone(),
898-
direct: *direct,
899-
},
937+
SkillOrigin::Remote {
938+
source,
939+
direct,
940+
provenance,
941+
..
942+
} => {
943+
let RemoteProvenance {
944+
owner,
945+
repository,
946+
skill_path,
947+
commit_sha,
948+
source_url,
949+
} = provenance.as_ref();
950+
JsonOrigin::Remote {
951+
source: source.clone(),
952+
direct: *direct,
953+
owner: owner.clone(),
954+
repository: repository.clone(),
955+
skill_path: skill_path.clone(),
956+
commit: commit_sha.clone(),
957+
source_url: source_url.clone(),
958+
}
959+
}
900960
SkillOrigin::Local { root } => JsonOrigin::Local {
901961
root: root.display().to_string(),
902962
},
Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,115 @@
1+
//! Where a remote Skill came from: the human, the Repository, and the exact file.
2+
//!
3+
//! Every surface that shows a remote Skill points at the SKILL.md the author
4+
//! committed. The lockfile records the Repository, path, and commit; this module
5+
//! turns those into one line a person can read and one URL they can open.
6+
7+
use skilld_core::{LockedSource, RemoteSelector};
8+
9+
use crate::CommandError;
10+
11+
/// The Repository, path, and commit that one remote Skill was read from.
12+
#[derive(Clone, Debug, Eq, PartialEq)]
13+
pub struct RemoteProvenance {
14+
pub owner: String,
15+
pub repository: String,
16+
pub skill_path: String,
17+
pub commit_sha: String,
18+
/// The SKILL.md file at the exact commit, on github.com.
19+
pub source_url: String,
20+
}
21+
22+
impl RemoteProvenance {
23+
pub fn new(
24+
owner: impl Into<String>,
25+
repository: impl Into<String>,
26+
skill_path: impl Into<String>,
27+
commit_sha: impl Into<String>,
28+
) -> Result<Self, CommandError> {
29+
let owner = owner.into();
30+
let repository = repository.into();
31+
let skill_path = skill_path.into();
32+
let commit_sha = commit_sha.into();
33+
let source_url = source_url(&owner, &repository, &skill_path, &commit_sha)?;
34+
Ok(Self {
35+
owner,
36+
repository,
37+
skill_path,
38+
commit_sha,
39+
source_url,
40+
})
41+
}
42+
43+
/// Read the provenance a lockfile entry recorded. Local and bundled
44+
/// Skills have no remote source, so they carry none.
45+
pub fn from_locked(source: &LockedSource) -> Result<Option<Self>, CommandError> {
46+
let LockedSource::Remote {
47+
source,
48+
commit_sha,
49+
skill_path,
50+
} = source
51+
else {
52+
return Ok(None);
53+
};
54+
let selector = RemoteSelector::parse(source).map_err(CommandError::remote)?;
55+
Self::new(
56+
selector.source().owner.as_str(),
57+
selector.source().repository.as_str(),
58+
skill_path.as_str(),
59+
commit_sha.as_str(),
60+
)
61+
.map(Some)
62+
}
63+
64+
/// `owner/repository`, the way GitHub names it.
65+
pub fn slug(&self) -> String {
66+
format!("{}/{}", self.owner, self.repository)
67+
}
68+
69+
/// The first seven characters of the commit, for reading. The URL keeps the full commit.
70+
pub fn short_commit(&self) -> &str {
71+
self.commit_sha.get(..7).unwrap_or(&self.commit_sha)
72+
}
73+
74+
/// One line: the Skill, the human who published it, and the commit.
75+
pub fn headline(&self, name: &str) -> String {
76+
format!("{name} · {} @ {}", self.slug(), self.short_commit())
77+
}
78+
}
79+
80+
/// State what the status covers, on every status.
81+
///
82+
/// A verified Artifact proves where the bytes came from. It says nothing about
83+
/// what the instructions ask an Agent to do, and the output must not imply it.
84+
pub fn source_status_caution(status: &str) -> &'static str {
85+
match status {
86+
"verified" => {
87+
"skilld checked where this Skill came from, not what it asks you to do.\nRead it before you follow it.\n"
88+
}
89+
"unverified" => "skilld did not check this source. Read this Skill before you follow it.\n",
90+
_ => "Read this Skill before you follow it.\n",
91+
}
92+
}
93+
94+
fn source_url(
95+
owner: &str,
96+
repository: &str,
97+
skill_path: &str,
98+
commit_sha: &str,
99+
) -> Result<String, CommandError> {
100+
let failed = || CommandError::service("the GitHub Skill URL could not be built");
101+
let mut url = url::Url::parse("https://github.com/").map_err(|_| failed())?;
102+
{
103+
let mut segments = url.path_segments_mut().map_err(|_| failed())?;
104+
segments
105+
.push(owner)
106+
.push(repository)
107+
.push("blob")
108+
.push(commit_sha);
109+
for segment in skill_path.split('/').filter(|segment| !segment.is_empty()) {
110+
segments.push(segment);
111+
}
112+
segments.push(crate::run::INSTRUCTIONS_FILE);
113+
}
114+
Ok(url.into())
115+
}

crates/skilld-command/src/run.rs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ use skilld_core::{PreparedFile, SkillListing};
1515
use skilld_ui::text::is_unsafe_terminal;
1616

1717
use crate::CommandError;
18+
use crate::provenance::RemoteProvenance;
1819

1920
/// The instructions file every Skill carries.
2021
pub const INSTRUCTIONS_FILE: &str = "SKILL.md";
@@ -37,6 +38,8 @@ pub enum SkillOrigin {
3738
source: String,
3839
exact_source: String,
3940
direct: bool,
41+
/// The Repository, path, and commit the bytes came from.
42+
provenance: Box<RemoteProvenance>,
4043
},
4144
}
4245

crates/skilld-command/tests/add.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,20 @@ fn add_installs_every_skill_the_repository_ref_names() {
145145
assert_eq!(result.exit_code, 0, "{}", String::from_utf8_lossy(&stderr));
146146
assert_eq!(
147147
String::from_utf8(stdout).unwrap(),
148-
"Installed Skill vue.\nInstalled Skill nuxt.\n"
148+
concat!(
149+
"Installed Skill vue.\n",
150+
"vue · vuejs/core @ aaaaaaa\n",
151+
"Source: skilld:vuejs/core/vue\n",
152+
"Source status: unverified\n",
153+
"skilld did not check this source. Read this Skill before you follow it.\n",
154+
"Read it first: https://github.com/vuejs/core/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/skills/vue/SKILL.md\n",
155+
"Installed Skill nuxt.\n",
156+
"nuxt · vuejs/core @ aaaaaaa\n",
157+
"Source: skilld:vuejs/core/nuxt\n",
158+
"Source status: unverified\n",
159+
"skilld did not check this source. Read this Skill before you follow it.\n",
160+
"Read it first: https://github.com/vuejs/core/blob/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa/skills/nuxt/SKILL.md\n",
161+
)
149162
);
150163
assert_eq!(host.list(InstallScope::Project).unwrap(), ["nuxt", "vue"]);
151164
for name in ["vue", "nuxt"] {

crates/skilld-command/tests/agent_targets.rs

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,15 @@ fn every_project_signal_selects_the_matching_agent_target() {
7171
})
7272
.unwrap();
7373

74-
assert_eq!(names, ["example"], "{}", agent.as_str());
74+
assert_eq!(
75+
names
76+
.iter()
77+
.map(|skill| skill.name.as_str())
78+
.collect::<Vec<_>>(),
79+
["example"],
80+
"{}",
81+
agent.as_str()
82+
);
7583
assert!(
7684
project.join(skills_dir).join("example/SKILL.md").exists(),
7785
"{}",
@@ -215,6 +223,7 @@ fn a_first_party_skills_directory_alone_does_not_select_openclaw() {
215223
})
216224
.unwrap();
217225

226+
let names: Vec<&str> = names.iter().map(|skill| skill.name.as_str()).collect();
218227
assert_eq!(names, ["example"]);
219228
let skills = fs::read_dir(project.join("skills"))
220229
.unwrap()

crates/skilld-command/tests/outdated.rs

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -436,7 +436,13 @@ fn view_and_outdated_preserve_valid_metacharacters_as_quoted_data() {
436436
assert_eq!(view.exit_code, 0);
437437
assert!(stderr.is_empty());
438438
assert!(stdout.contains(source));
439-
assert!(stdout.contains("\u{1b}]8;;https://github.com/skilld-dev/skills\u{1b}\\"));
439+
assert!(
440+
stdout.contains(concat!(
441+
"\u{1b}]8;;https://github.com/skilld-dev/skills/blob/",
442+
"0123456789abcdef0123456789abcdef01234567/skills/o'hare$(%22quoted%22)/SKILL.md\u{1b}\\"
443+
)),
444+
"{stdout:?}"
445+
);
440446

441447
let mut stdout = Vec::new();
442448
let mut stderr = Vec::new();

0 commit comments

Comments
 (0)