Skip to content
Merged
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added
- **`skills` command — self-served, version-matched agent guides** — `pass-cli skills` ships an AI-agent usage guide embedded in the binary, mirroring the pattern that makes tools like agent-browser easy for agents to adopt: `pass-cli skills list` lists the available skills and `pass-cli skills get core` prints the safe-usage guide (`exec`/`export`/`inject`/`agent`/`list`/`get` plus the leak traps to avoid), with `--full` appending a complete command reference. Because the content ships *in the binary* (via `//go:embed`), the guidance always matches the installed version and can't drift from shipped behavior. `pass-cli skills install` writes a small discovery stub into a detected agent skills directory (`~/.claude/skills` or `~/.agents/skills`, or `--dir <path>`) that points agents at `pass-cli skills get core`; an existing, differing stub is preserved unless `--force` is given. This is the first step toward pass-cli being a first-class tool for humans **and** their AI agents.

## [0.19.0] - 2026-07-02

### Added
Expand Down
225 changes: 225 additions & 0 deletions cmd/skills.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,225 @@
package cmd

import (
"fmt"
"os"
"path/filepath"
"strings"
"text/tabwriter"

"github.com/arimxyer/pass-cli/internal/skills"

"github.com/spf13/cobra"
)

var (
skillsGetFull bool
skillsInstallDir string
skillsInstallForce bool
)

var skillsCmd = &cobra.Command{
Use: "skills",
GroupID: "utilities",
Short: "Agent-facing usage guides shipped with this binary",
Long: `Skills are version-matched usage guides for driving pass-cli as an AI
agent. They ship embedded in this binary, so the guidance always matches the
installed version and never goes stale.

For AI agents: start with the core safe-usage guide.

pass-cli skills get core # exec/export/inject/agent/list/get + leak traps
pass-cli skills get core --full # also include the full command reference

Run 'pass-cli skills install' to drop a small discovery stub into your agent's
skills directory (e.g. ~/.claude/skills) that points back at this command.

Examples:
# List available skills
pass-cli skills list

# Print the core agent guide
pass-cli skills get core

# Install the discovery stub for AI agents
pass-cli skills install`,
// No RunE: bare `pass-cli skills` prints help (its subcommands), matching
// the other parent commands (vault, config, keychain).
}

var skillsListCmd = &cobra.Command{
Use: "list",
Short: "List available skills",
Args: cobra.NoArgs,
RunE: runSkillsList,
}

var skillsGetCmd = &cobra.Command{
Use: "get <name>",
Short: "Print a skill's guide to stdout",
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
content, err := skills.Get(args[0], skillsGetFull)
if err != nil {
return err
}
_, err = fmt.Fprint(cmd.OutOrStdout(), content)
return err
},
}

var skillsInstallCmd = &cobra.Command{
Use: "install",
Short: "Write the agent discovery stub into a skills directory",
Long: `Install writes a small discovery stub (a Claude Code / agent skill) that
points AI agents at 'pass-cli skills get core'. The stub is intentionally thin
so it never goes stale; the real guidance is served by the CLI.

By default the stub is written to the first existing of ~/.claude/skills or
~/.agents/skills (falling back to creating ~/.claude/skills), as
<dir>/pass-cli/SKILL.md. Use --dir to choose a different location.

An existing stub that differs is left untouched unless --force is given.`,
Args: cobra.NoArgs,
RunE: runSkillsInstall,
}

func init() {
rootCmd.AddCommand(skillsCmd)
skillsCmd.AddCommand(skillsListCmd)
skillsCmd.AddCommand(skillsGetCmd)
skillsCmd.AddCommand(skillsInstallCmd)

skillsGetCmd.Flags().BoolVar(&skillsGetFull, "full", false, "include the full command reference")
skillsInstallCmd.Flags().StringVar(&skillsInstallDir, "dir", "", "skills directory to install into (default: auto-detect)")
skillsInstallCmd.Flags().BoolVar(&skillsInstallForce, "force", false, "overwrite an existing, differing stub")
}

func runSkillsList(cmd *cobra.Command, _ []string) error {
list, err := skills.List()
if err != nil {
return err
}
var sb strings.Builder
sb.WriteString("Available skills:\n")
tw := tabwriter.NewWriter(&sb, 0, 0, 2, ' ', 0)
for _, s := range list {
_, _ = fmt.Fprintf(tw, " %s\t%s\n", s.Name, summaryLine(s.Description))
}
if err := tw.Flush(); err != nil {
return err
}
sb.WriteString("\nRun 'pass-cli skills get <name>' to print a guide (add --full for the command reference).\n")

_, err = fmt.Fprint(cmd.OutOrStdout(), sb.String())
return err
}

func runSkillsInstall(cmd *cobra.Command, _ []string) error {
dir, err := resolveSkillsDir(skillsInstallDir)
if err != nil {
return err
}
msg, err := installStub(dir, skillsInstallForce)
if err != nil {
return err
}
_, err = fmt.Fprintln(cmd.OutOrStdout(), msg)
return err
}

// installStub writes the discovery stub to <dir>/pass-cli/SKILL.md and returns a
// human-readable status message. An existing stub that already matches is left
// untouched (idempotent); an existing stub that differs is preserved unless
// force is set. Kept separate from the Cobra command so the write behavior is
// unit-testable against a temp dir.
func installStub(dir string, force bool) (string, error) {
stub, err := skills.StubContent()
if err != nil {
return "", err
}
target := filepath.Join(dir, "pass-cli", "SKILL.md")

if existing, err := os.ReadFile(target); err == nil {
if string(existing) == stub {
return "Already up to date: " + target, nil
}
if !force {
return "", fmt.Errorf("a different stub already exists at %s; re-run with --force to overwrite", target)
}
}

if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
return "", fmt.Errorf("failed to create %s: %w", filepath.Dir(target), err)
}
if err := os.WriteFile(target, []byte(stub), 0o644); err != nil {
return "", fmt.Errorf("failed to write %s: %w", target, err)
}

return "Installed discovery stub: " + target +
"\nAI agents will now be pointed at 'pass-cli skills get core'.", nil
}

// resolveSkillsDir picks the directory to install the stub into. An explicit
// override is expanded and returned as-is; otherwise the first existing of
// ~/.claude/skills or ~/.agents/skills is used, falling back to ~/.claude/skills.
func resolveSkillsDir(override string) (string, error) {
if override != "" {
return expandHome(override), nil
}
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("cannot determine home directory: %w", err)
}
candidates := []string{
filepath.Join(home, ".claude", "skills"),
filepath.Join(home, ".agents", "skills"),
}
for _, c := range candidates {
if info, err := os.Stat(c); err == nil && info.IsDir() {
return c, nil
}
}
return candidates[0], nil
}

func expandHome(path string) string {
if path == "~" || len(path) >= 2 && path[:2] == "~/" {
if home, err := os.UserHomeDir(); err == nil {
if path == "~" {
return home
}
return filepath.Join(home, path[2:])
}
}
return path
}

// summaryLine renders a skill's one-line summary for `skills list`. The
// frontmatter description is written long and keyword-rich for AI-agent
// discovery matching, so for the human list we take just its first sentence
// (each description leads with a self-contained one), with a generous rune cap
// as a backstop against a description that has no sentence break.
func summaryLine(desc string) string {
desc = strings.TrimSpace(desc)
if i := strings.Index(desc, ". "); i >= 0 {
desc = desc[:i+1] // keep the period, drop the rest
}
return truncate(desc, 120)
}

// truncate shortens s to at most max runes (not bytes), appending an ellipsis
// when it cuts. Rune-aware so a multi-byte character near the limit isn't split.
func truncate(s string, max int) string {
if max <= 0 {
return ""
}
runes := []rune(s)
if len(runes) <= max {
return s
}
if max == 1 {
return string(runes[:1])
}
return string(runes[:max-1]) + "…"
}
102 changes: 102 additions & 0 deletions cmd/skills_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
package cmd

import (
"os"
"path/filepath"
"strings"
"testing"

"github.com/arimxyer/pass-cli/internal/skills"
)

func TestInstallStub(t *testing.T) {
dir := t.TempDir()
target := filepath.Join(dir, "pass-cli", "SKILL.md")
stub, err := skills.StubContent()
if err != nil {
t.Fatalf("StubContent: %v", err)
}

// Fresh install writes the stub verbatim.
msg, err := installStub(dir, false)
if err != nil {
t.Fatalf("fresh install: %v", err)
}
if !strings.Contains(msg, "Installed discovery stub") {
t.Errorf("fresh install message = %q", msg)
}
got, err := os.ReadFile(target)
if err != nil {
t.Fatalf("reading written stub: %v", err)
}
if string(got) != stub {
t.Error("written stub does not match StubContent()")
}

// Re-install with identical content is an idempotent no-op.
msg, err = installStub(dir, false)
if err != nil {
t.Fatalf("idempotent install: %v", err)
}
if !strings.Contains(msg, "Already up to date") {
t.Errorf("idempotent message = %q", msg)
}

// A differing existing stub is preserved unless --force.
if err := os.WriteFile(target, []byte("changed by user\n"), 0o644); err != nil {
t.Fatalf("mutating stub: %v", err)
}
if _, err := installStub(dir, false); err == nil {
t.Error("install without force overwrote a differing stub")
}
if kept, _ := os.ReadFile(target); string(kept) != "changed by user\n" {
t.Error("differing stub was clobbered despite no --force")
}

// --force overwrites it back to the canonical stub.
if _, err := installStub(dir, true); err != nil {
t.Fatalf("force install: %v", err)
}
if forced, _ := os.ReadFile(target); string(forced) != stub {
t.Error("--force did not restore the canonical stub")
}
}

func TestSummaryLine(t *testing.T) {
cases := []struct {
in string
want string
}{
// First sentence only — no mid-word truncation.
{"Core guide for driving pass-cli safely. Read this before anything.", "Core guide for driving pass-cli safely."},
// No sentence break → returned as-is (within the cap).
{"A single clause with no period", "A single clause with no period"},
{" leading/trailing space trimmed. rest ", "leading/trailing space trimmed."},
}
for _, c := range cases {
if got := summaryLine(c.in); got != c.want {
t.Errorf("summaryLine(%q) = %q, want %q", c.in, got, c.want)
}
}
}

func TestTruncate(t *testing.T) {
cases := []struct {
in string
max int
want string
}{
{"short", 100, "short"},
{"", 5, ""},
{"abcdef", 4, "abc…"},
{"abc", 3, "abc"},
{"abcd", 0, ""},
// Multi-byte runes must not be split mid-character.
{"héllo wörld", 5, "héll…"},
}
for _, c := range cases {
if got := truncate(c.in, c.max); got != c.want {
t.Errorf("truncate(%q, %d) = %q, want %q", c.in, c.max, got, c.want)
}
}
}
1 change: 1 addition & 0 deletions docs/05-operations/_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,5 @@ Operational guides for maintaining, monitoring, and securing pass-cli in product
{{< card link="health-checks" title="Health Checks" icon="heart" subtitle="System diagnostics and vault health monitoring" >}}
{{< card link="security-operations" title="Security Operations" icon="shield-exclamation" subtitle="Best practices, checklists, and incident response" >}}
{{< card link="agent" title="Background Agent" icon="lightning-bolt" subtitle="Promptless, cached credential access via a local socket" >}}
{{< card link="agent-skills" title="AI Agent Skills" icon="sparkles" subtitle="Version-matched agent usage guides served by the CLI" >}}
{{< /cards >}}
45 changes: 45 additions & 0 deletions docs/05-operations/agent-skills.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
---
title: "AI Agent Skills"
weight: 4
toc: true
---

pass-cli ships an **agent usage guide inside the binary**. Any AI agent (Claude
Code, Cursor, Codex, …) driving pass-cli can load version-matched guidance
straight from the CLI, so the instructions never drift from the installed
version.

## For agents: start here

```sh
pass-cli skills get core # safe-usage guide: exec/export/inject/agent/list/get + leak traps
pass-cli skills get core --full # also include the full command reference
pass-cli skills list # list every skill shipped with this version
```

`skills get core` is the entry point. It explains the one rule that matters —
**never let a secret value land in the transcript, a log, or a watched file** —
and which command to reach for (`exec` by default; `inject` for composite
secrets; `list -q` to discover services; `get` only as a last resort).

## Installing the discovery stub

`skills install` drops a small skill file into your agent's skills directory so
the agent discovers pass-cli automatically and is pointed at `skills get core`:

```sh
pass-cli skills install # auto-detects ~/.claude/skills or ~/.agents/skills
pass-cli skills install --dir <path> # choose a specific directory
pass-cli skills install --force # overwrite an existing, differing stub
```

The stub is intentionally thin — the real, version-matched guidance stays in the
binary. Re-running `skills install` after upgrading pass-cli is safe (it's a
no-op when the stub is already current).

## Why embedded, not a static doc

The skill content is compiled into the binary via `//go:embed`, so it's always
in lockstep with the commands and flags that binary actually has. A stale copy
pasted into a project's docs or a user's dotfiles can't drift out of sync,
because agents read the guide from the CLI itself.
Loading
Loading