Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@ not per individual commit.

## [Unreleased]

### Added

- Kiro installs write a short `ks-<name>/SKILL.md` pointer per skill into `.kiro/skills/` and list
them in every agent, so Kiro shows each skill natively. The pointer loads the body with
`get_skill`, reading the file only if the server is not connected. `install --no-skill-pointers` opts out.

### Changed

- The orchestrators (`konductor`, `konductor-mux-orchestrator`, `konductor-cmux-orchestrator`)
Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -207,7 +207,11 @@ auto-detection. Paths are relative to `--target <dir>`, or `$HOME` if you don't
| `claude` | `.claude/agents/` | `.claude/skills/` | `.claude/skills/sop-<name>/SKILL.md` |

Kiro skills live in `.konductor/skills/`, not `.kiro/skills/`, so Kiro doesn't expose every skill
to every agent. `kiro-cli-v2` and `kiro-v3` share those paths, so installing one over the other
body to every agent. Each skill also gets a short `ks-<name>/SKILL.md` pointer under `.kiro/skills/`
with its name and a short description, so Kiro lists it natively. The pointer tells the agent to load
the body with `get_skill` on the `konductor-skills` MCP server, and to read the file only if that
server is not connected. Pass `--no-skill-pointers` to `install` to skip the pointers (`update` keeps
that choice). `kiro-cli-v2` and `kiro-v3` share those paths, so installing one over the other
replaces it (with a warning). `claude` installs alongside either one in the same target.

Without `--from`, `install` fetches a published GitHub release, including the platform's
Expand Down
24 changes: 24 additions & 0 deletions cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,30 @@ install merges into `<target>/.konductor/skills/`: a skill directory this instal
not emit (e.g. hand-authored) is left untouched, but a skill directory it does own is
fully replaced so a file removed from the source doesn't linger in the destination.

#### Skill pointers (Kiro harnesses)

Only a small pointer per skill goes into `.kiro/skills/ks-<name>/SKILL.md`. It carries the
skill's name and a description cut to 200 characters, so Kiro lists every skill natively.
The pointer tells the agent to call `get_skill` on the `konductor-skills` MCP server first,
and to read `<target>/.konductor/skills/<name>/SKILL.md` only if that server is not
connected. If `get_skill` says the skill is not found, the agent is told to stop.

- Each Kiro agent also gets a `skill://.kiro/skills/ks-*/SKILL.md` resource, so the
pointers are listed for it explicitly.
- The `ks-` prefix stays: pointers would otherwise clash with same-named skills other
tools install in `.kiro/skills/`, and the pointer text names the real skill.
- Pointers add every skill's name and description to every agent's prompt, and a
home-directory install puts them in `~/.kiro/skills/`, where other Kiro agents see them
too. `install --no-skill-pointers` skips the pointers and the agent resource, removes
ones an earlier install wrote, and is recorded in `.konductor/no-skill-pointers`, so
`update` keeps it. A later `install` without the flag turns pointers back on.
- Because the fallback path is absolute, a project install writes your local path into
`.kiro/skills/ks-*/SKILL.md`. Don't commit that directory.
- Pointers are tracked in the manifest. `update` removes the pointer of a skill the source
dropped, and `uninstall` removes them all. Claude installs get none.
- A read of the fallback file does not go through `get_skill`, so it is not counted in
skill-load telemetry.

### Installing without `--from`: the GitHub-release / main-branch-`dist/` fallback chain

```bash
Expand Down
8 changes: 8 additions & 0 deletions cli/konductor-rs/src/cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,14 @@ pub enum Commands {
#[arg(long, display_order = 4)]
no_telemetry: bool,

/// Kiro harnesses only: do not write the `ks-<name>` skill pointers
/// under `.kiro/skills/` or the matching `skill://` agent resource,
/// and remove ones an earlier install wrote. The choice is recorded
/// for this target, so `update` keeps it; a later `install` without
/// this flag turns the pointers back on.
#[arg(long = "no-skill-pointers", action = ArgAction::SetTrue)]
no_skill_pointers: bool,

/// Opt in to reading `GITHUB_TOKEN` from the environment for the
/// no-`--from` remote install path. Without this flag,
/// `GITHUB_TOKEN` is never read even if set in the shell. Has no
Expand Down
7 changes: 6 additions & 1 deletion cli/konductor-rs/src/cli/dispatch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -37,15 +37,17 @@ pub fn dispatch(command: Commands, verbose: bool, json: bool, color: ColorMode)
harness,
link_bin,
no_telemetry,
no_skill_pointers,
use_github_token,
release_version,
force,
} => crate::cli::install::dispatch_install_with(
} => crate::cli::install::dispatch_install_with_skill_pointer_choice(
from,
target,
harness,
link_bin,
no_telemetry,
no_skill_pointers,
use_github_token,
release_version,
force,
Expand Down Expand Up @@ -1011,6 +1013,7 @@ mod tests {
harness: "kiro-cli-v2".to_string(),
link_bin: true,
no_telemetry: false,
no_skill_pointers: false,
use_github_token: false,
release_version: None,
force: false,
Expand Down Expand Up @@ -1050,6 +1053,7 @@ mod tests {
harness: "kiro-cli-v2".to_string(),
link_bin: false,
no_telemetry: false,
no_skill_pointers: false,
use_github_token: false,
release_version: None,
force: false,
Expand Down Expand Up @@ -1084,6 +1088,7 @@ mod tests {
harness: "kiro-cli-v2".to_string(),
link_bin: true,
no_telemetry: false,
no_skill_pointers: false,
use_github_token: false,
release_version: None,
force: false,
Expand Down
68 changes: 67 additions & 1 deletion cli/konductor-rs/src/cli/install.rs
Original file line number Diff line number Diff line change
Expand Up @@ -698,6 +698,67 @@ pub fn dispatch_install_with(
)
}

/// Harness names whose installs write `ks-<name>` skill pointers.
fn harness_writes_skill_pointers(harness: &str) -> bool {
matches!(harness, "kiro-cli-v2" | "kiro-v3")
}

/// `dispatch_install_with` after recording this run's `--no-skill-pointers`
/// choice for a Kiro target. The strategy reads the recorded choice
/// during the install, and `update` reads it too, so the opt-out
/// outlasts this run. A changed choice also bypasses the
/// already-at-version skip, since that skip would otherwise ignore it.
#[allow(clippy::too_many_arguments)]
pub fn dispatch_install_with_skill_pointer_choice(
from: Option<String>,
target: Option<String>,
harness: String,
link_bin: bool,
no_telemetry: bool,
no_skill_pointers: bool,
use_github_token: bool,
release_version: Option<String>,
force: bool,
verbose: bool,
json: bool,
color: ColorMode,
) -> u8 {
let mut force = force;
if harness_writes_skill_pointers(&harness) {
if let Ok(destination) = resolve_destination(target.as_deref()) {
match kiro_cli::set_skill_pointers_opt_out(&destination, no_skill_pointers) {
Ok(changed) => force |= changed,
Err(message) => {
super::report::report_error(
"install",
"install.skill_pointer_choice_failed",
&destination,
no_telemetry,
&format!("could not record --no-skill-pointers: {message}"),
Vec::new(),
json,
color,
);
return EXIT_USAGE_ERROR;
}
}
}
}
dispatch_install_with(
from,
target,
harness,
link_bin,
no_telemetry,
use_github_token,
release_version,
force,
verbose,
json,
color,
)
}

/// The full `dispatch_install_with` implementation, parameterized by
/// `remote_installer` -- the no-`--from` remote-install attempt. In
/// production, `dispatch_install_with` passes a closure that reaches
Expand Down Expand Up @@ -1558,7 +1619,12 @@ impl InstallCounts {
skill_dirs.insert((".konductor/skills/", name));
}
} else if let Some(rest) = file.path.strip_prefix(".kiro/skills/") {
if let Some(name) = rest.split('/').next().filter(|s| !s.is_empty()) {
// `ks-` pointers stand for skills already counted above.
if let Some(name) = rest
.split('/')
.next()
.filter(|s| !s.is_empty() && !s.starts_with(kiro_cli::SKILL_POINTER_PREFIX))
{
skill_dirs.insert((".kiro/skills/", name));
}
} else if let Some(rest) = file.path.strip_prefix(claude_skills_prefix.as_str()) {
Expand Down
2 changes: 1 addition & 1 deletion cli/konductor-rs/src/cli/install/claude.rs
Original file line number Diff line number Diff line change
Expand Up @@ -915,7 +915,7 @@ fn strip_trailing_punctuation_artifacts(text: &str) -> &str {
/// derived values). This is what makes an Overview line containing a
/// colon safe as a YAML scalar: unlike a plain/unquoted scalar, `": "`
/// inside a double-quoted string is never a mapping separator.
fn yaml_double_quote(text: &str) -> String {
pub(super) fn yaml_double_quote(text: &str) -> String {
let mut out = String::with_capacity(text.len() + 2);
out.push('"');
for c in text.chars() {
Expand Down
74 changes: 58 additions & 16 deletions cli/konductor-rs/src/cli/install/kiro_cli.rs
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,20 @@
// Skills land under `.konductor/skills/`, not `.kiro/skills/`: Kiro CLI's
// native skill discovery scans `.kiro/skills/` unconditionally and makes
// every skill visible to every agent, which defeats per-agent scoping.
// Keeping skills outside that scanned directory means a skill is only
// Keeping skill bodies outside that scanned directory means a body is only
// visible to an agent that references it via a `skill://` resource entry.
// Only the small `ks-<name>/SKILL.md` pointers (`skill_pointers.rs`) land in
// `.kiro/skills/`, so every skill's name and description is discoverable.
//
// Skill install merges into `.konductor/skills/`: a directory this install
// did not emit is left untouched; one it does own is replaced wholesale so
// removed files don't linger.
//
// This file's logic is split under `kiro_cli/`: `plan.rs` (write-ahead
// planning), `copy.rs` (copy/install execution), `fs_util.rs` (shared
// filesystem primitives). This file keeps the `InstallStrategy` impl and
// re-exports what sibling modules and tests depend on.
// planning), `copy.rs` (copy/install execution), `skill_pointers.rs`
// (native skill pointers), `fs_util.rs` (shared filesystem primitives).
// This file keeps the `InstallStrategy` impl and re-exports what sibling
// modules and tests depend on.

use std::path::Path;

Expand Down Expand Up @@ -309,6 +312,7 @@ impl InstallStrategy for KiroCliInstallStrategy {
mod copy;
mod fs_util;
mod plan;
mod skill_pointers;

// Re-exports so sibling `install` modules (`claude`, `mcp_server`) and this
// module's own tests can keep using unqualified paths after the split into
Expand All @@ -324,6 +328,13 @@ pub(super) use plan::{
plan_claude_hooks_file, plan_claude_settings_grant, plan_skill_dir_recursive,
read_skill_scopes_sidecar, read_sop_scopes_sidecar, PlannedFile,
};
pub(super) use skill_pointers::{
install_kiro_skill_pointers, plan_kiro_skill_pointer_files, SKILL_POINTER_PREFIX,
SKILL_POINTER_RESOURCE,
};
pub(crate) use skill_pointers::{
remove_skill_pointers_opt_out, set_skill_pointers_opt_out, skill_pointers_enabled,
};

#[cfg(test)]
pub(super) mod test_support {
Expand Down Expand Up @@ -1583,7 +1594,10 @@ mod tests {
"file://{}",
target_dir.join(".kiro/context/routing-rules.md").display()
);
assert_eq!(value["resources"], serde_json::json!([expected]));
assert_eq!(
value["resources"],
serde_json::json!([expected, "skill://.kiro/skills/ks-*/SKILL.md"])
);

fs::remove_dir_all(&target_dir).ok();
fs::remove_dir_all(&repo_root).ok();
Expand Down Expand Up @@ -1626,14 +1640,17 @@ mod tests {
"file://{}",
target_dir.join(".kiro/context/routing-rules.md").display()
);
assert_eq!(value["resources"], serde_json::json!([expected]));
assert_eq!(
value["resources"],
serde_json::json!([expected, "skill://.kiro/skills/ks-*/SKILL.md"])
);

fs::remove_dir_all(&target_dir).ok();
fs::remove_dir_all(&repo_root).ok();
}

#[test]
fn install_from_local_agent_with_no_context_names_is_unchanged() {
fn install_from_local_agent_with_no_context_names_only_gains_the_pointer_glob() {
let target_dir = scratch_dir("no-context-target");
let repo_root = scratch_dir("no-context-repo");
seed_synthed_agent(
Expand All @@ -1654,7 +1671,10 @@ mod tests {
let installed_agent = target_dir.join(".kiro/agents/k-example.json");
let value: serde_json::Value =
serde_json::from_str(&fs::read_to_string(&installed_agent).unwrap()).unwrap();
assert_eq!(value["resources"], serde_json::json!(["file://AGENTS.md"]));
assert_eq!(
value["resources"],
serde_json::json!(["file://AGENTS.md", "skill://.kiro/skills/ks-*/SKILL.md"])
);

fs::remove_dir_all(&target_dir).ok();
fs::remove_dir_all(&repo_root).ok();
Expand Down Expand Up @@ -1808,9 +1828,13 @@ mod tests {
);
assert_eq!(
value["resources"],
serde_json::json!(["file:///etc/hand-authored.md", expected_context]),
serde_json::json!([
"file:///etc/hand-authored.md",
expected_context,
"skill://.kiro/skills/ks-*/SKILL.md"
]),
"hand-authored absolute entry must pass through untouched, in its original position, \
with the rewritten context entry deterministically after it"
with the rewritten context entry deterministically after it, then the pointer glob"
);

fs::remove_dir_all(&target_dir).ok();
Expand Down Expand Up @@ -1862,8 +1886,14 @@ mod tests {
"file://{}",
target_b.join(".kiro/context/routing-rules.md").display()
);
assert_eq!(value_a["resources"], serde_json::json!([expected_a]));
assert_eq!(value_b["resources"], serde_json::json!([expected_b]));
assert_eq!(
value_a["resources"],
serde_json::json!([expected_a, "skill://.kiro/skills/ks-*/SKILL.md"])
);
assert_eq!(
value_b["resources"],
serde_json::json!([expected_b, "skill://.kiro/skills/ks-*/SKILL.md"])
);
assert_ne!(
value_a["resources"], value_b["resources"],
"each root's installed agent must carry its own absolute path"
Expand Down Expand Up @@ -2130,7 +2160,10 @@ mod tests {
.join(".konductor/skills/constraints/SKILL.md")
.display()
);
assert_eq!(value["resources"], serde_json::json!([expected]));
assert_eq!(
value["resources"],
serde_json::json!([expected, "skill://.kiro/skills/ks-*/SKILL.md"])
);

fs::remove_dir_all(&target_dir).ok();
fs::remove_dir_all(&repo_root).ok();
Expand Down Expand Up @@ -2248,7 +2281,10 @@ mod tests {
serde_json::from_str(&fs::read_to_string(&installed_agent).unwrap()).unwrap();
assert_eq!(
value["resources"],
serde_json::json!(["skill://.kiro/skills/ws-*/SKILL.md"])
serde_json::json!([
"skill://.kiro/skills/ws-*/SKILL.md",
"skill://.kiro/skills/ks-*/SKILL.md"
])
);

fs::remove_dir_all(&target_dir).ok();
Expand Down Expand Up @@ -2421,8 +2457,14 @@ mod tests {
.join(".konductor/skills/constraints/SKILL.md")
.display()
);
assert_eq!(value_a["resources"], serde_json::json!([expected_a]));
assert_eq!(value_b["resources"], serde_json::json!([expected_b]));
assert_eq!(
value_a["resources"],
serde_json::json!([expected_a, "skill://.kiro/skills/ks-*/SKILL.md"])
);
assert_eq!(
value_b["resources"],
serde_json::json!([expected_b, "skill://.kiro/skills/ks-*/SKILL.md"])
);
assert_ne!(value_a["resources"], value_b["resources"]);

fs::remove_dir_all(&repo_root).ok();
Expand Down
Loading
Loading