1use std::{
2 collections::{BTreeMap, HashMap, HashSet},
3 path::Path,
4 sync::{Arc, Mutex},
5};
6
7use cookie_agent_config::{SkillContext, SkillDocument};
8use cookie_agent_protocol::{
9 EventPayload, PermissionAction, PermissionEffect, PermissionRule, RunId, SessionId,
10 SessionPermissionOverlay, Sha256Digest, SkillDescriptor, SkillsGetResult, SkillsListResult,
11 ToolCallId,
12};
13
14use super::{ActiveRun, Engine, EngineError, prompt_blocks::push_prompt_block};
15use crate::{ToolCall, TurnAgentContext};
16use crate::{permissions, policy::FrozenRunPolicy};
17pub const RESERVED_STAGED_SKILL_PREFIX: &str = "\0cookie-staged-skill:";
18
19#[derive(Default)]
21pub(crate) struct SkillRuntimeState {
22 pub(crate) grants: Mutex<HashMap<SessionId, BTreeMap<String, SkillGrantOverlay>>>,
23 pub(crate) models: Mutex<HashMap<SessionId, cookie_agent_protocol::ModelKey>>,
24 pub(crate) pending_forks: Mutex<HashMap<ToolCallId, PreparedSkillInvocation>>,
25 pub(crate) pending_child: Mutex<HashMap<SessionId, PreparedSkillInvocation>>,
26 pub(crate) direct_calls: Mutex<HashSet<ToolCallId>>,
27}
28
29#[derive(Clone, Debug)]
30pub struct SkillInvocation {
31 pub name: String,
32 pub rendered: String,
33 pub context: Option<SkillContext>,
34}
35
36#[derive(Clone, Debug)]
37pub(crate) struct SkillGrantOverlay {
38 source: String,
39 overlay: SessionPermissionOverlay,
40}
41
42#[derive(Clone, Debug)]
43pub(crate) struct PreparedSkillInvocation {
44 name: String,
45 args: String,
46 rendered: String,
47 source_path: String,
48 base_dir: String,
49 supporting_files: Vec<String>,
50 grants: Vec<PermissionRule>,
51 model: Option<cookie_agent_protocol::ModelKey>,
52 context: Option<SkillContext>,
53}
54
55impl PreparedSkillInvocation {
56 pub(super) fn grants(&self) -> Option<SessionPermissionOverlay> {
57 (!self.grants.is_empty()).then(|| SessionPermissionOverlay {
58 rules: self.grants.clone(),
59 })
60 }
61
62 pub(crate) fn staged_payload(&self) -> cookie_agent_protocol::StagedSkillPayload {
63 cookie_agent_protocol::StagedSkillPayload {
64 provenance: cookie_agent_protocol::StagedSkillProvenance::SkillFork,
65 name: self.name.clone(),
66 args: self.args.clone(),
67 rendered_body: self.rendered.clone(),
68 source_path: self.source_path.clone(),
69 base_dir: self.base_dir.clone(),
70 supporting_files: self.supporting_files.clone(),
71 grants: self.grants.clone(),
72 model: self.model.clone(),
73 }
74 }
75}
76
77impl Engine {
78 #[must_use]
79 pub fn is_reserved_staged_skill_prompt(prompt: &str) -> bool {
80 prompt.starts_with(RESERVED_STAGED_SKILL_PREFIX)
81 }
82
83 pub fn skill_tool_available(&self, session: SessionId) -> Result<bool, EngineError> {
84 let projection = self.inner.store.get(session)?;
85 let policy = permissions::governing_agent_for_skills(&projection);
86 let grants = self.skill_grants_for_session(session);
87 Ok(self.inner.skills.skills().any(|(name, skill)| {
88 model_skill_visibility(
89 &policy,
90 &projection.permission_overlay,
91 grants.as_ref(),
92 self.inner.store.cwd(),
93 name,
94 skill,
95 )
96 .1
97 }))
98 }
99
100 pub(crate) fn skill_grants_for_session(
101 &self,
102 session: SessionId,
103 ) -> Option<SessionPermissionOverlay> {
104 self.inner
105 .skills_runtime
106 .grants
107 .lock()
108 .unwrap_or_else(|poisoned| poisoned.into_inner())
109 .get(&session)
110 .map(|grants| {
111 let rules = grants
112 .values()
113 .flat_map(|grant| {
114 debug_assert!(grant.source.starts_with("skill:"));
115 grant.overlay.rules.iter().cloned()
116 })
117 .collect();
118 SessionPermissionOverlay { rules }
119 })
120 }
121
122 pub fn list_skills(&self, session: SessionId) -> Result<SkillsListResult, EngineError> {
123 let projection = self.inner.store.get(session)?;
124 let policy = permissions::governing_agent_for_skills(&projection);
125 let grants = self.skill_grants_for_session(session);
126 let mut skills = self
127 .inner
128 .skills
129 .discoveries()
130 .iter()
131 .filter_map(|discovery| {
132 let skill = self.inner.skills.get(&discovery.name)?;
133 let (effect, visible) = model_skill_visibility(
134 &policy,
135 &projection.permission_overlay,
136 grants.as_ref(),
137 self.inner.store.cwd(),
138 &discovery.name,
139 skill,
140 );
141 let mut descriptor =
142 skill_descriptor(skill, discovery.precedence_winner, effect, visible);
143 descriptor.location = discovery.path.to_string_lossy().into_owned();
144 descriptor.source = discovery.source;
145 Some(descriptor)
146 })
147 .collect::<Vec<_>>();
148 skills.sort_by(|left, right| {
149 left.name
150 .cmp(&right.name)
151 .then_with(|| left.location.cmp(&right.location))
152 });
153 Ok(SkillsListResult { skills })
154 }
155
156 pub fn get_skill(
157 &self,
158 session: SessionId,
159 name: &str,
160 args: &str,
161 ) -> Result<SkillsGetResult, EngineError> {
162 let projection = self.inner.store.get(session)?;
163 let policy = permissions::governing_agent_for_skills(&projection);
164 let skill = self.skill(name)?;
165 let effect = permissions::effective_permission_with_overlay(
166 &policy,
167 Some(&projection.permission_overlay),
168 PermissionAction::Skill,
169 name,
170 self.inner.store.cwd(),
171 )
172 .0;
173 if effect == PermissionEffect::Deny {
174 return Err(EngineError::Permission(format!("skill `{name}` is denied")));
175 }
176 Ok(SkillsGetResult {
177 skill: skill_descriptor(skill, true, effect, true),
178 rendered: render_skill(skill, args),
179 })
180 }
181
182 pub fn get_user_skill(
183 &self,
184 session: SessionId,
185 name: &str,
186 args: &str,
187 ) -> Result<SkillsGetResult, EngineError> {
188 let result = self.get_skill(session, name, args)?;
189 if !result.skill.user_invocable {
190 return Err(EngineError::Permission(format!(
191 "skill `{name}` is not user-invocable"
192 )));
193 }
194 Ok(result)
195 }
196
197 pub fn get_model_skill(
198 &self,
199 session: SessionId,
200 name: &str,
201 args: &str,
202 ) -> Result<SkillsGetResult, EngineError> {
203 let projection = self.inner.store.get(session)?;
204 let policy = permissions::governing_agent_for_skills(&projection);
205 let grants = self.skill_grants_for_session(session);
206 let visible = |skill_name: &str, skill: &SkillDocument| {
207 model_skill_visibility(
208 &policy,
209 &projection.permission_overlay,
210 grants.as_ref(),
211 self.inner.store.cwd(),
212 skill_name,
213 skill,
214 )
215 .1
216 };
217 let Some(skill) = self
218 .inner
219 .skills
220 .get(name)
221 .filter(|skill| visible(name, skill))
222 else {
223 let hints = self
224 .inner
225 .skills
226 .skills()
227 .filter(|(skill_name, skill)| visible(skill_name, skill))
228 .map(|(skill_name, _)| skill_name.as_str())
229 .collect::<Vec<_>>()
230 .join(", ");
231 return Err(EngineError::ToolFailed(format!(
232 "unknown or unavailable model skill `{name}`; valid skills: {hints}"
233 )));
234 };
235 let effect = model_skill_visibility(
236 &policy,
237 &projection.permission_overlay,
238 grants.as_ref(),
239 self.inner.store.cwd(),
240 name,
241 skill,
242 )
243 .0;
244 Ok(SkillsGetResult {
245 skill: skill_descriptor(skill, true, effect, true),
246 rendered: render_skill(skill, args),
247 })
248 }
249
250 pub async fn invoke_skill(
251 &self,
252 session: SessionId,
253 run: Option<cookie_agent_protocol::RunId>,
254 name: &str,
255 args: &str,
256 permission_preapproved: bool,
257 ) -> Result<SkillInvocation, EngineError> {
258 let projection = self.inner.store.get(session)?;
259 let policy = permissions::governing_agent_for_skills(&projection);
260 let skill = self.skill(name)?.clone();
261 if !permission_preapproved && !skill.frontmatter.user_invocable {
262 return Err(EngineError::Permission(format!(
263 "skill `{name}` is not user-invocable"
264 )));
265 }
266 let effect = permissions::effective_permission_with_overlay(
267 &policy,
268 Some(&projection.permission_overlay),
269 PermissionAction::Skill,
270 name,
271 self.inner.store.cwd(),
272 )
273 .0;
274 if !permission_preapproved && effect != PermissionEffect::Allow {
275 return Err(EngineError::Permission(format!(
276 "skill `{name}` requires {}",
277 if effect == PermissionEffect::Deny {
278 "permission that is denied"
279 } else {
280 "approval"
281 }
282 )));
283 }
284 let plan = prepared_skill_invocation(&skill, args);
285 self.install_prepared_skill(session, run, &plan).await
286 }
287
288 pub(super) async fn install_prepared_skill(
289 &self,
290 session: SessionId,
291 run: Option<RunId>,
292 plan: &PreparedSkillInvocation,
293 ) -> Result<SkillInvocation, EngineError> {
294 let projection = self.inner.store.get(session)?;
295 let name = &plan.name;
296 let duplicate = projection.log.event_snapshot().iter().any(|event| {
297 matches!(
298 &event.payload,
299 EventPayload::SkillLoaded { name: prior_name, rendered_body, .. }
300 if prior_name == name && rendered_body.as_bytes() == plan.rendered.as_bytes()
301 )
302 });
303 let event = if duplicate {
304 EventPayload::SkillInvocationNoted { name: name.clone() }
305 } else {
306 EventPayload::SkillLoaded {
307 name: name.clone(),
308 rendered_body: plan.rendered.clone(),
309 source_path: plan.source_path.clone(),
310 args: plan.args.clone(),
311 base_dir: plan.base_dir.clone(),
312 supporting_files: plan.supporting_files.clone(),
313 }
314 };
315 self.append(session, run, super::event_origin("engine:skills"), event)
316 .await?;
317 let mut overlays = self
318 .inner
319 .skills_runtime
320 .grants
321 .lock()
322 .unwrap_or_else(|poisoned| poisoned.into_inner());
323 let per_skill = overlays.entry(session).or_default();
324 if plan.grants.is_empty() {
325 per_skill.remove(name);
326 } else {
327 per_skill.insert(
328 name.clone(),
329 SkillGrantOverlay {
330 source: format!("skill:{name}"),
331 overlay: SessionPermissionOverlay {
332 rules: plan.grants.clone(),
333 },
334 },
335 );
336 }
337 if per_skill.is_empty() {
338 overlays.remove(&session);
339 }
340 drop(overlays);
341 if let Some(model) = &plan.model {
342 self.inner
343 .skills_runtime
344 .models
345 .lock()
346 .unwrap_or_else(|poisoned| poisoned.into_inner())
347 .insert(session, model.clone());
348 }
349 Ok(SkillInvocation {
350 name: name.clone(),
351 rendered: plan.rendered.clone(),
352 context: plan.context,
353 })
354 }
355
356 pub fn skill_invocation_context(
357 &self,
358 name: &str,
359 ) -> Result<Option<SkillContext>, EngineError> {
360 Ok(self.skill(name)?.frontmatter.context)
361 }
362
363 pub fn is_direct_skill_call(&self, call_id: ToolCallId) -> bool {
364 self.inner
365 .skills_runtime
366 .direct_calls
367 .lock()
368 .unwrap_or_else(|poisoned| poisoned.into_inner())
369 .contains(&call_id)
370 }
371
372 #[cfg(test)]
373 pub(crate) fn stage_skill_fork_for_test(
374 &self,
375 call_id: ToolCallId,
376 payload: &cookie_agent_protocol::StagedSkillPayload,
377 ) {
378 self.inner
379 .skills_runtime
380 .pending_forks
381 .lock()
382 .expect("pending skill forks lock poisoned")
383 .insert(call_id, prepared_skill_from_payload(payload));
384 }
385
386 pub(super) async fn execute_direct_skill(
387 &self,
388 active: Arc<ActiveRun>,
389 run: RunId,
390 name: String,
391 args: String,
392 ) -> Result<(), EngineError> {
393 self.get_user_skill(active.session, &name, &args)?;
394 let call_id = ToolCallId::new_v7();
395 self.inner
396 .skills_runtime
397 .direct_calls
398 .lock()
399 .unwrap_or_else(|poisoned| poisoned.into_inner())
400 .insert(call_id);
401 let result = self
402 .execute_prepared_call(
403 active,
404 run,
405 ToolCall {
406 id: call_id,
407 name: "skill".into(),
408 arguments: serde_json::json!({"name": name, "args": args}),
409 },
410 )
411 .await;
412 self.inner
413 .skills_runtime
414 .direct_calls
415 .lock()
416 .unwrap_or_else(|poisoned| poisoned.into_inner())
417 .remove(&call_id);
418 result.map(|_| ()).map_err(|error| {
419 if error.to_string().contains("tool_denied") {
420 EngineError::Permission(error.to_string())
421 } else {
422 error
423 }
424 })
425 }
426
427 pub async fn execute_skill_fork(
428 &self,
429 session: SessionId,
430 run: RunId,
431 call_id: ToolCallId,
432 name: &str,
433 args: &str,
434 ) -> Result<cookie_agent_protocol::PersistedToolResult, EngineError> {
435 let plan = self.prepare_skill_invocation(name, args)?;
436 let agent_type = self
437 .delegate_targets(session)?
438 .into_iter()
439 .next()
440 .ok_or_else(|| {
441 EngineError::ToolFailed("fork skill requires an available delegation target".into())
442 })?;
443 self.inner
444 .skills_runtime
445 .pending_forks
446 .lock()
447 .unwrap_or_else(|poisoned| poisoned.into_inner())
448 .insert(call_id, plan.clone());
449 let active = self
450 .inner
451 .sessions
452 .active
453 .lock()
454 .unwrap_or_else(|poisoned| poisoned.into_inner())
455 .get(&run)
456 .cloned()
457 .filter(|active| active.session == session)
458 .ok_or(EngineError::MissingRun(run))?;
459 let result = self
460 .execute_prepared_call(
461 active,
462 run,
463 ToolCall {
464 id: call_id,
465 name: "delegate_subagent".into(),
466 arguments: serde_json::json!({
467 "description": format!("Skill {name}"),
468 "prompt": format!("Apply the staged skill `{name}`."),
469 "agent_type": agent_type,
470 "background": false,
471 "resume_session_id": null,
472 "inherit_context": false,
473 }),
474 },
475 )
476 .await;
477 self.inner
478 .skills_runtime
479 .pending_forks
480 .lock()
481 .unwrap_or_else(|poisoned| poisoned.into_inner())
482 .remove(&call_id);
483 result
484 }
485
486 async fn execute_prepared_call(
487 &self,
488 active: Arc<ActiveRun>,
489 run: RunId,
490 call: ToolCall,
491 ) -> Result<cookie_agent_protocol::PersistedToolResult, EngineError> {
492 let binding = active
493 .policy
494 .selected_suffix
495 .first()
496 .ok_or(EngineError::NoRunnableModel)?;
497 let turn_context = Arc::new(TurnAgentContext {
498 agent: active.policy.agent.agent.clone(),
499 model: binding.selection.model.clone(),
500 adapter: crate::policy::wire_adapter(binding.descriptor.adapter_id.as_str()),
501 adapter_family: crate::policy::adapter_family(binding.descriptor.adapter_id.as_str()),
502 capabilities: active
503 .policy
504 .model_capabilities(binding)
505 .ok_or(EngineError::NoRunnableModel)?,
506 });
507 let prepared = self
508 .prepare_tool_call(
509 active.session,
510 run,
511 call,
512 &active.policy,
513 turn_context.clone(),
514 )
515 .await;
516 self.execute_tool(active, run, prepared, turn_context)
517 .await
518 .map_err(|failure| EngineError::ToolFailed(failure.message))
519 }
520
521 fn prepare_skill_invocation(
522 &self,
523 name: &str,
524 args: &str,
525 ) -> Result<PreparedSkillInvocation, EngineError> {
526 let skill = self.skill(name)?.clone();
527 Ok(prepared_skill_invocation(&skill, args))
528 }
529
530 pub(super) fn prepare_user_skill_invocation(
531 &self,
532 name: &str,
533 args: &str,
534 ) -> Result<PreparedSkillInvocation, EngineError> {
535 let skill = self.skill(name)?.clone();
536 if !skill.frontmatter.user_invocable {
537 return Err(EngineError::Permission(format!(
538 "skill `{name}` is not user-invocable"
539 )));
540 }
541 Ok(prepared_skill_invocation(&skill, args))
542 }
543
544 pub(crate) fn stage_child_skill_from_event(
545 &self,
546 session: SessionId,
547 payload: &cookie_agent_protocol::StagedSkillPayload,
548 ) {
549 self.inner
550 .skills_runtime
551 .pending_child
552 .lock()
553 .unwrap_or_else(|poisoned| poisoned.into_inner())
554 .insert(session, prepared_skill_from_payload(payload));
555 }
556
557 pub(super) fn pending_child_skill(
558 &self,
559 session: SessionId,
560 ) -> Option<PreparedSkillInvocation> {
561 self.inner
562 .skills_runtime
563 .pending_child
564 .lock()
565 .unwrap_or_else(|poisoned| poisoned.into_inner())
566 .get(&session)
567 .cloned()
568 }
569
570 pub(super) fn take_pending_child_skill(
571 &self,
572 session: SessionId,
573 ) -> Option<PreparedSkillInvocation> {
574 self.inner
575 .skills_runtime
576 .pending_child
577 .lock()
578 .unwrap_or_else(|poisoned| poisoned.into_inner())
579 .remove(&session)
580 }
581
582 pub(crate) fn transfer_pending_skill_fork(
583 &self,
584 call_id: ToolCallId,
585 _child_session: SessionId,
586 ) {
587 self.inner
588 .skills_runtime
589 .pending_forks
590 .lock()
591 .unwrap_or_else(|poisoned| poisoned.into_inner())
592 .remove(&call_id);
593 }
594
595 pub fn take_skill_model_override(
596 &self,
597 session: SessionId,
598 ) -> Option<cookie_agent_protocol::ModelKey> {
599 self.inner
600 .skills_runtime
601 .models
602 .lock()
603 .unwrap_or_else(|poisoned| poisoned.into_inner())
604 .remove(&session)
605 }
606
607 pub(crate) fn clear_skill_turn_state(&self, session: SessionId) {
608 self.inner
609 .skills_runtime
610 .grants
611 .lock()
612 .unwrap_or_else(|poisoned| poisoned.into_inner())
613 .remove(&session);
614 self.inner
615 .skills_runtime
616 .models
617 .lock()
618 .unwrap_or_else(|poisoned| poisoned.into_inner())
619 .remove(&session);
620 }
621
622 fn skill(&self, name: &str) -> Result<&SkillDocument, EngineError> {
623 self.inner.skills.get(name).ok_or_else(|| {
624 let names = self
625 .inner
626 .skills
627 .skills()
628 .map(|(name, _)| name.as_str())
629 .collect::<Vec<_>>()
630 .join(", ");
631 EngineError::ToolFailed(format!("unknown skill `{name}`; valid skills: {names}"))
632 })
633 }
634
635 pub(crate) fn compose_skill_listing(
636 &self,
637 session: SessionId,
638 policy: &mut FrozenRunPolicy,
639 prospective_grants: Option<&SessionPermissionOverlay>,
640 ) -> Result<(), EngineError> {
641 let projection = self.inner.store.get(session)?;
642 let visible = self
643 .inner
644 .skills
645 .skills()
646 .filter_map(|(name, skill)| {
647 model_skill_visibility(
648 &policy.agent,
649 &projection.permission_overlay,
650 prospective_grants,
651 self.inner.store.cwd(),
652 name,
653 skill,
654 )
655 .1
656 .then_some(skill)
657 })
658 .collect::<Vec<_>>();
659 if visible.is_empty() {
660 return Ok(());
661 }
662 let context_tokens = policy.selected_suffix[0]
663 .descriptor
664 .capabilities
665 .limits
666 .context
667 .unwrap_or(8_192);
668 let listing = cookie_agent_config::render_available_skills(visible, context_tokens)
669 .map_err(|error| EngineError::Config(Box::new(error)))?;
670 push_prompt_block(&mut policy.agent.composed_prompt, &listing);
671 policy.agent.prompt_fingerprint =
672 Sha256Digest::of_bytes(policy.agent.composed_prompt.as_bytes());
673 let mut document_fingerprint = policy
674 .agent
675 .document_fingerprint
676 .as_str()
677 .as_bytes()
678 .to_vec();
679 document_fingerprint.extend_from_slice(listing.as_bytes());
680 policy.agent.document_fingerprint = Sha256Digest::of_bytes(&document_fingerprint);
681 Ok(())
682 }
683}
684
685fn skill_descriptor(
686 skill: &SkillDocument,
687 precedence_winner: bool,
688 permission_effect: PermissionEffect,
689 visible: bool,
690) -> SkillDescriptor {
691 SkillDescriptor {
692 name: skill.frontmatter.name.clone(),
693 description: skill.frontmatter.description.clone(),
694 when_to_use: skill.frontmatter.when_to_use.clone(),
695 location: skill.path.to_string_lossy().into_owned(),
696 source: skill.source,
697 precedence_winner,
698 permission_effect,
699 visible,
700 user_invocable: skill.frontmatter.user_invocable,
701 argument_hint: skill.frontmatter.argument_hint.clone(),
702 }
703}
704
705fn model_skill_visibility(
706 policy: &cookie_agent_protocol::AgentSnapshot,
707 overlay: &SessionPermissionOverlay,
708 grants: Option<&SessionPermissionOverlay>,
709 workspace: &Path,
710 name: &str,
711 skill: &SkillDocument,
712) -> (PermissionEffect, bool) {
713 let effect = permissions::effective_permission_with_overlay(
714 policy,
715 Some(overlay),
716 PermissionAction::Skill,
717 name,
718 workspace,
719 )
720 .0;
721 let action_visible = permissions::PermissionPipeline::tool_visible_with_grants(
722 policy,
723 Some(overlay),
724 grants,
725 "skill",
726 workspace,
727 );
728 (
729 effect,
730 action_visible
731 && effect != PermissionEffect::Deny
732 && !skill.frontmatter.disable_model_invocation,
733 )
734}
735
736fn prepared_skill_invocation(skill: &SkillDocument, args: &str) -> PreparedSkillInvocation {
737 PreparedSkillInvocation {
738 name: skill.frontmatter.name.clone(),
739 args: args.to_owned(),
740 rendered: render_skill(skill, args),
741 source_path: skill.path.to_string_lossy().into_owned(),
742 base_dir: skill.base_dir.to_string_lossy().into_owned(),
743 supporting_files: skill
744 .supporting_files
745 .iter()
746 .map(|path| path.to_string_lossy().into_owned())
747 .collect(),
748 grants: skill
749 .frontmatter
750 .allowed_tools
751 .iter()
752 .map(|grant| PermissionRule {
753 action: grant.action,
754 resource: grant.pattern.clone(),
755 effect: PermissionEffect::Allow,
756 })
757 .collect(),
758 model: skill.frontmatter.model.clone(),
759 context: skill.frontmatter.context,
760 }
761}
762
763fn prepared_skill_from_payload(
764 payload: &cookie_agent_protocol::StagedSkillPayload,
765) -> PreparedSkillInvocation {
766 PreparedSkillInvocation {
767 name: payload.name.clone(),
768 args: payload.args.clone(),
769 rendered: payload.rendered_body.clone(),
770 source_path: payload.source_path.clone(),
771 base_dir: payload.base_dir.clone(),
772 supporting_files: payload.supporting_files.clone(),
773 grants: payload.grants.clone(),
774 model: payload.model.clone(),
775 context: Some(SkillContext::Fork),
776 }
777}
778
779fn render_skill(skill: &SkillDocument, args: &str) -> String {
780 let positional = args.split_whitespace().collect::<Vec<_>>();
781 let mut variables = BTreeMap::new();
782 variables.insert("ARGUMENTS".to_owned(), args.to_owned());
783 variables.insert(
784 "{COOKIE_SKILL_DIR}".to_owned(),
785 skill.base_dir.to_string_lossy().into_owned(),
786 );
787 for (index, value) in positional.into_iter().enumerate() {
788 variables.insert((index + 1).to_string(), value.to_owned());
789 }
790 let mut output = String::new();
791 let bytes = skill.body.as_bytes();
792 let mut index = 0;
793 while index < bytes.len() {
794 if bytes[index] == b'$' {
795 let rest = &skill.body[index + 1..];
796 let key = if rest.starts_with("{COOKIE_SKILL_DIR}") {
797 Some("{COOKIE_SKILL_DIR}")
798 } else if rest.starts_with("ARGUMENTS") {
799 Some("ARGUMENTS")
800 } else {
801 let digits = rest.bytes().take_while(u8::is_ascii_digit).count();
802 (digits > 0).then_some(&rest[..digits])
803 };
804 if let Some(key) = key {
805 output.push_str(variables.get(key).map_or("", String::as_str));
806 index += 1 + key.len();
807 continue;
808 }
809 }
810 let character = skill.body[index..]
811 .chars()
812 .next()
813 .expect("character boundary");
814 output.push(character);
815 index += character.len_utf8();
816 }
817 output
818}