Skip to content

Commit 12a4f1e

Browse files
authored
feat: install get-clawshell-stats skill during onboard (#108)
Teach OpenClaw and Hermes how to call the `/admin/stats` endpoint and report running counters (total requests, upstream token usage, and per-sender email-filter activity) back to the user.
1 parent 7f1d067 commit 12a4f1e

4 files changed

Lines changed: 368 additions & 150 deletions

File tree

‎src/main.rs‎

Lines changed: 155 additions & 120 deletions
Original file line numberDiff line numberDiff line change
@@ -129,14 +129,12 @@ struct WrittenOpenclawSkill {
129129
manifest_entry: onboard::ManagedSkillManifestEntry,
130130
}
131131

132-
fn write_onboard_openclaw_skill(
133-
ob_config: &crate::onboard::OnboardConfig,
132+
/// Write a single ClawShell-managed skill bundle into
133+
/// `<openclaw_root>/skills/<skill_name>/` and return its manifest entry.
134+
fn write_openclaw_skill_bundle(
135+
skill: onboard::OnboardSkillBundle,
134136
openclaw_config_path: &Path,
135-
) -> Result<Option<WrittenOpenclawSkill>, Box<dyn Error>> {
136-
let Some(skill) = onboard::render_email_messages_skill(ob_config) else {
137-
return Ok(None);
138-
};
139-
137+
) -> Result<WrittenOpenclawSkill, Box<dyn Error>> {
140138
let openclaw_root = onboard::openclaw_config_root(openclaw_config_path);
141139
let skill_dir = openclaw_root.join("skills").join(skill.name);
142140
std::fs::create_dir_all(&skill_dir)?;
@@ -163,10 +161,37 @@ fn write_onboard_openclaw_skill(
163161
);
164162
}
165163

166-
Ok(Some(WrittenOpenclawSkill {
164+
Ok(WrittenOpenclawSkill {
167165
path: skill_dir,
168166
manifest_entry,
169-
}))
167+
})
168+
}
169+
170+
/// Write every ClawShell-managed skill that applies to this onboarding
171+
/// run into OpenClaw's skills directory. The stats skill is always
172+
/// written; the email skill is written only when the onboarding config
173+
/// has email integration enabled. Returns the written skills in
174+
/// installation order.
175+
fn write_onboard_openclaw_skill(
176+
ob_config: &crate::onboard::OnboardConfig,
177+
openclaw_config_path: &Path,
178+
) -> Result<Vec<WrittenOpenclawSkill>, Box<dyn Error>> {
179+
let mut written = Vec::new();
180+
181+
let stats_skill = onboard::render_admin_stats_skill(ob_config);
182+
written.push(write_openclaw_skill_bundle(
183+
stats_skill,
184+
openclaw_config_path,
185+
)?);
186+
187+
if let Some(email_skill) = onboard::render_email_messages_skill(ob_config) {
188+
written.push(write_openclaw_skill_bundle(
189+
email_skill,
190+
openclaw_config_path,
191+
)?);
192+
}
193+
194+
Ok(written)
170195
}
171196

172197
/// Resolve the home directory and uid:gid of the user running onboarding.
@@ -201,23 +226,15 @@ fn resolve_hermes_target_user() -> Result<(PathBuf, u32, u32), Box<dyn Error>> {
201226
Ok((home, 0, 0))
202227
}
203228

204-
/// Write the ClawShell-managed email skill into the invoking user's
205-
/// `~/.hermes/skills/<skill_name>/` directory. Returns the install path
206-
/// when the skill was written, or `None` when the skill render function
207-
/// declined (e.g. email integration not configured).
208-
///
209-
/// Unlike `write_onboard_openclaw_skill`, this doesn't upsert a
210-
/// `managed_skills` manifest entry — Hermes discovers skills from its own
211-
/// `~/.hermes/skills/` tree directly and doesn't share OpenClaw's
212-
/// manifest bookkeeping.
213-
fn write_onboard_hermes_skill(
214-
ob_config: &crate::onboard::OnboardConfig,
215-
) -> Result<Option<PathBuf>, Box<dyn Error>> {
216-
let Some(skill) = onboard::render_email_messages_skill(ob_config) else {
217-
return Ok(None);
218-
};
219-
220-
let (home_dir, uid, gid) = resolve_hermes_target_user()?;
229+
/// Write a single ClawShell-managed skill bundle into
230+
/// `<home>/.hermes/skills/<skill_name>/` and chown the result to the
231+
/// invoking user.
232+
fn write_hermes_skill_bundle(
233+
skill: onboard::OnboardSkillBundle,
234+
home_dir: &Path,
235+
uid: u32,
236+
gid: u32,
237+
) -> Result<PathBuf, Box<dyn Error>> {
221238
let skill_dir = home_dir.join(".hermes").join("skills").join(skill.name);
222239
std::fs::create_dir_all(&skill_dir)?;
223240

@@ -240,7 +257,32 @@ fn write_onboard_hermes_skill(
240257
);
241258
}
242259

243-
Ok(Some(skill_dir))
260+
Ok(skill_dir)
261+
}
262+
263+
/// Write every ClawShell-managed skill that applies to this onboarding
264+
/// run into `~/.hermes/skills/`. The stats skill is always written; the
265+
/// email skill is written only when email integration is enabled.
266+
///
267+
/// Unlike `write_onboard_openclaw_skill`, this doesn't upsert a
268+
/// `managed_skills` manifest entry — Hermes discovers skills from its own
269+
/// `~/.hermes/skills/` tree directly and doesn't share OpenClaw's
270+
/// manifest bookkeeping.
271+
fn write_onboard_hermes_skill(
272+
ob_config: &crate::onboard::OnboardConfig,
273+
) -> Result<Vec<PathBuf>, Box<dyn Error>> {
274+
let (home_dir, uid, gid) = resolve_hermes_target_user()?;
275+
276+
let mut written = Vec::new();
277+
278+
let stats_skill = onboard::render_admin_stats_skill(ob_config);
279+
written.push(write_hermes_skill_bundle(stats_skill, &home_dir, uid, gid)?);
280+
281+
if let Some(email_skill) = onboard::render_email_messages_skill(ob_config) {
282+
written.push(write_hermes_skill_bundle(email_skill, &home_dir, uid, gid)?);
283+
}
284+
285+
Ok(written)
244286
}
245287

246288
fn resolve_openclaw_owner_spec(openclaw_path: &Path) -> Result<Option<String>, Box<dyn Error>> {
@@ -1114,40 +1156,27 @@ fn apply_openclaw_onboarding_steps(
11141156
) -> Result<(), Box<dyn Error>> {
11151157
const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS;
11161158

1117-
// Step 6: Write OpenClaw skill files
1159+
// Step 6: Write OpenClaw skill files. The stats skill is always
1160+
// installed; the email skill is added on top when email integration
1161+
// is configured for this onboarding.
11181162
tui::print_step(6, TOTAL_STEPS, "OpenClaw skill setup...");
11191163
println!();
1120-
let openclaw_skill = if onboard::should_setup_email_skill(ob_config) {
1121-
let openclaw_skill_edit_approved =
1122-
tui::prompt_confirm("Write OpenClaw skill files for email integration", true)?;
1123-
let openclaw_skill = if openclaw_skill_edit_approved {
1124-
write_onboard_openclaw_skill(ob_config, openclaw_path)?
1125-
} else {
1126-
None
1127-
};
1128-
if openclaw_skill_edit_approved {
1129-
if let Some(skill) = openclaw_skill.as_ref() {
1130-
onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?;
1131-
tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written");
1132-
tui::print_info("OpenClaw skill", &skill.path.display().to_string());
1133-
} else {
1134-
tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills skipped");
1135-
}
1136-
} else {
1137-
tui::print_step_done(
1138-
6,
1139-
TOTAL_STEPS,
1140-
"OpenClaw skills skipped (approval not granted)",
1141-
);
1164+
let openclaw_skill_edit_approved = tui::prompt_confirm("Write OpenClaw skill files", true)?;
1165+
let openclaw_skills: Vec<WrittenOpenclawSkill> = if openclaw_skill_edit_approved {
1166+
let written = write_onboard_openclaw_skill(ob_config, openclaw_path)?;
1167+
for skill in &written {
1168+
onboard::upsert_managed_skill_manifest_entry(config_file, &skill.manifest_entry)?;
1169+
tui::print_info("OpenClaw skill", &skill.path.display().to_string());
11421170
}
1143-
openclaw_skill
1171+
tui::print_step_done(6, TOTAL_STEPS, "OpenClaw skills written");
1172+
written
11441173
} else {
11451174
tui::print_step_done(
11461175
6,
11471176
TOTAL_STEPS,
1148-
"OpenClaw skills skipped (email integration not configured)",
1177+
"OpenClaw skills skipped (approval not granted)",
11491178
);
1150-
None
1179+
Vec::new()
11511180
};
11521181

11531182
// Step 7: Backup OpenClaw configuration file if present.
@@ -1264,17 +1293,18 @@ fn apply_openclaw_onboarding_steps(
12641293
"Temporarily setting `gateway.reload.mode` to `off` during config updates, then restoring `hybrid`.",
12651294
);
12661295
openclaw_cli::apply_onboard_openclaw_config(&mut openclaw_runner, ob_config)?;
1267-
if let Some(skill) = openclaw_skill.as_ref() {
1296+
for skill in &openclaw_skills {
12681297
align_owner_with_openclaw_path(&skill.path, openclaw_path)?;
12691298
}
12701299
tui::print_step_done(8, TOTAL_STEPS, "OpenClaw config updated");
12711300
Ok(())
12721301
}
12731302

12741303
/// Steps 6, 7, 8 of the onboarding wizard when the user picked Hermes as
1275-
/// the downstream agent target. Installs the email skill (if email is
1276-
/// enabled) into the user's `~/.hermes/skills/` tree, then runs
1277-
/// `hermes config set` to point Hermes at ClawShell.
1304+
/// the downstream agent target. Installs the ClawShell-managed skill
1305+
/// bundles (stats always, email when configured) into the user's
1306+
/// `~/.hermes/skills/` tree, then runs `hermes config set` to point
1307+
/// Hermes at ClawShell.
12781308
///
12791309
/// Steps 7 and 8a ("backup OpenClaw" / "preview OpenClaw edits") are
12801310
/// rendered as neutral info lines so the overall 9-step numbering stays
@@ -1284,32 +1314,28 @@ fn apply_hermes_onboarding_steps(
12841314
) -> Result<(), Box<dyn Error>> {
12851315
const TOTAL_STEPS: usize = ONBOARD_TOTAL_STEPS;
12861316

1287-
// Step 6: Write Hermes skill files into ~/.hermes/skills/ if email enabled.
1317+
// Step 6: Write Hermes skill files into ~/.hermes/skills/. The stats
1318+
// skill is always written; the email skill is written on top when
1319+
// email integration is configured.
12881320
tui::print_step(6, TOTAL_STEPS, "Hermes skill setup...");
1289-
if onboard::should_setup_email_skill(ob_config) {
1290-
match write_onboard_hermes_skill(ob_config) {
1291-
Ok(Some(path)) => {
1292-
tui::print_step_done(6, TOTAL_STEPS, "Hermes skill written");
1321+
match write_onboard_hermes_skill(ob_config) {
1322+
Ok(paths) if paths.is_empty() => {
1323+
tui::print_step_done(6, TOTAL_STEPS, "Hermes skills skipped");
1324+
}
1325+
Ok(paths) => {
1326+
for path in &paths {
12931327
tui::print_info("Hermes skill", &path.display().to_string());
12941328
}
1295-
Ok(None) => {
1296-
tui::print_step_done(6, TOTAL_STEPS, "Hermes skill skipped");
1297-
}
1298-
Err(error) => {
1299-
tui::print_error(&format!("Failed to write Hermes skill: {error}"));
1300-
tui::print_step_done(
1301-
6,
1302-
TOTAL_STEPS,
1303-
"Hermes skill skipped (write failed — see error above)",
1304-
);
1305-
}
1329+
tui::print_step_done(6, TOTAL_STEPS, "Hermes skills written");
1330+
}
1331+
Err(error) => {
1332+
tui::print_error(&format!("Failed to write Hermes skills: {error}"));
1333+
tui::print_step_done(
1334+
6,
1335+
TOTAL_STEPS,
1336+
"Hermes skills skipped (write failed — see error above)",
1337+
);
13061338
}
1307-
} else {
1308-
tui::print_step_done(
1309-
6,
1310-
TOTAL_STEPS,
1311-
"Hermes skills skipped (email integration not configured)",
1312-
);
13131339
}
13141340

13151341
// Step 7: Not applicable for Hermes (no backup needed — hermes config set
@@ -1740,28 +1766,36 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
17401766
} else {
17411767
None
17421768
};
1743-
let openclaw_skill_dir = openclaw_path.as_ref().map(|path| {
1744-
onboard::openclaw_config_root(path)
1745-
.join("skills")
1746-
.join(onboard::EMAIL_MESSAGES_SKILL_NAME)
1747-
});
1748-
let openclaw_skill_manifest = if clawshell_config_file.exists() {
1749-
onboard::read_managed_skill_manifest_entry(
1750-
&clawshell_config_file,
1751-
onboard::EMAIL_MESSAGES_SKILL_NAME,
1752-
)
1753-
} else {
1754-
None
1755-
};
1756-
let openclaw_skill_inspection = if let Some(skill_dir) = openclaw_skill_dir.as_ref() {
1757-
onboard::inspect_managed_skill_for_uninstall(
1758-
skill_dir,
1759-
onboard::EMAIL_MESSAGES_SKILL_NAME,
1760-
openclaw_skill_manifest.as_ref(),
1761-
)
1762-
} else {
1763-
onboard::ManagedSkillInspection::missing()
1764-
};
1769+
// Build (skill_name, skill_dir, inspection) triples for every managed
1770+
// OpenClaw skill, so the preview and removal blocks can iterate. Stats
1771+
// is listed first so it's still cleaned up even if the email skill is
1772+
// absent (or vice-versa).
1773+
let openclaw_skill_entries: Vec<(&'static str, PathBuf, onboard::ManagedSkillInspection)> =
1774+
if let Some(openclaw_path) = openclaw_path.as_ref() {
1775+
let skills_root = onboard::openclaw_config_root(openclaw_path).join("skills");
1776+
[
1777+
onboard::ADMIN_STATS_SKILL_NAME,
1778+
onboard::EMAIL_MESSAGES_SKILL_NAME,
1779+
]
1780+
.into_iter()
1781+
.map(|name| {
1782+
let skill_dir = skills_root.join(name);
1783+
let manifest = if clawshell_config_file.exists() {
1784+
onboard::read_managed_skill_manifest_entry(&clawshell_config_file, name)
1785+
} else {
1786+
None
1787+
};
1788+
let inspection = onboard::inspect_managed_skill_for_uninstall(
1789+
&skill_dir,
1790+
name,
1791+
manifest.as_ref(),
1792+
);
1793+
(name, skill_dir, inspection)
1794+
})
1795+
.collect()
1796+
} else {
1797+
Vec::new()
1798+
};
17651799

17661800
tui::print_warning("This will remove the following:");
17671801
tui::print_info("ClawShell", "Stop if running");
@@ -1770,10 +1804,11 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
17701804
if service_exists {
17711805
tui::print_info("Service", &service_path.display().to_string());
17721806
}
1773-
if let Some(skill_dir) = openclaw_skill_dir.as_ref()
1774-
&& skill_dir.exists()
1775-
{
1776-
match openclaw_skill_inspection.state {
1807+
for (_skill_name, skill_dir, inspection) in &openclaw_skill_entries {
1808+
if !skill_dir.exists() {
1809+
continue;
1810+
}
1811+
match inspection.state {
17771812
onboard::ManagedSkillUninstallState::ManagedUnchanged => {
17781813
tui::print_info(
17791814
"OpenClaw skill",
@@ -1787,7 +1822,7 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
17871822
);
17881823
tui::print_warning(&format!(
17891824
"Managed OpenClaw skill has local modifications: {}",
1790-
openclaw_skill_inspection.detail
1825+
inspection.detail
17911826
));
17921827
}
17931828
onboard::ManagedSkillUninstallState::Unmanaged => {
@@ -1797,7 +1832,7 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
17971832
);
17981833
tui::print_warning(&format!(
17991834
"Skill ownership is not verified: {}",
1800-
openclaw_skill_inspection.detail
1835+
inspection.detail
18011836
));
18021837
}
18031838
onboard::ManagedSkillUninstallState::Missing => {}
@@ -1848,19 +1883,19 @@ fn cmd_uninstall(skip_confirm: bool) -> Result<(), Box<dyn std::error::Error>> {
18481883
}
18491884
}
18501885

1851-
// 0b. Remove ClawShell-managed OpenClaw skill if present.
1852-
if let Some(skill_dir) = openclaw_skill_dir.as_ref()
1853-
&& skill_dir.exists()
1854-
{
1855-
let remove_skill_dir = |path: &Path| match std::fs::remove_dir_all(path) {
1856-
Ok(()) => tui::print_success(&format!("OpenClaw skill removed: {}", path.display())),
1857-
Err(error) => tui::print_warning(&format!(
1858-
"Failed to remove OpenClaw skill at {}: {error}",
1859-
path.display()
1860-
)),
1861-
};
1862-
1863-
match openclaw_skill_inspection.state {
1886+
// 0b. Remove ClawShell-managed OpenClaw skills if present.
1887+
let remove_skill_dir = |path: &Path| match std::fs::remove_dir_all(path) {
1888+
Ok(()) => tui::print_success(&format!("OpenClaw skill removed: {}", path.display())),
1889+
Err(error) => tui::print_warning(&format!(
1890+
"Failed to remove OpenClaw skill at {}: {error}",
1891+
path.display()
1892+
)),
1893+
};
1894+
for (_skill_name, skill_dir, inspection) in &openclaw_skill_entries {
1895+
if !skill_dir.exists() {
1896+
continue;
1897+
}
1898+
match inspection.state {
18641899
onboard::ManagedSkillUninstallState::ManagedUnchanged => {
18651900
let remove_skill = if skip_confirm {
18661901
true

‎src/onboard/mod.rs‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -30,8 +30,8 @@ pub use managed_skills::{
3030
upsert_managed_skill_manifest_entry, write_managed_skill_metadata,
3131
};
3232
pub use openclaw_json::{patch_openclaw_config_for_clawshell, remove_clawshell_openclaw_entries};
33-
pub use skills::{render_email_messages_skill, should_setup_email_skill};
33+
pub use skills::{render_admin_stats_skill, render_email_messages_skill};
3434
pub use types::{
35-
EMAIL_MESSAGES_SKILL_NAME, OnboardAuthMethod, OnboardConfig, OnboardTarget,
36-
OpenclawFileRemovalPreview,
35+
ADMIN_STATS_SKILL_NAME, EMAIL_MESSAGES_SKILL_NAME, OnboardAuthMethod, OnboardConfig,
36+
OnboardSkillBundle, OnboardTarget, OpenclawFileRemovalPreview,
3737
};

0 commit comments

Comments
 (0)