Skip to content
Open
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
46 changes: 46 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,49 @@ jobs:
with:
files: ./coverage.out
fail_ci_if_error: false

skill-add:
# End-to-end check that `notte skill add` actually installs the skill from
# the nottelabs/notte-skills repo. Catches regressions where the npx
# source URL or skill name drifts and the tool reports "No skills found".
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- uses: actions/setup-go@v6
with:
go-version-file: go.mod
cache: true

- uses: actions/setup-node@v6
with:
node-version: '20'

- name: Build CLI
run: go build -o notte ./cmd/notte

- name: notte skill add
run: |
set -euo pipefail
workdir="$(mktemp -d)"
cp notte "$workdir/notte"
cd "$workdir"
./notte skill add
if [ ! -f .agents/skills/notte-browser/SKILL.md ]; then
echo "::error::notte skill add did not install notte-browser/SKILL.md"
ls -laR .agents || true
exit 1
fi

- name: notte skill add --upgrade (re-runs against installed skill)
run: |
set -euo pipefail
workdir="$(mktemp -d)"
cp notte "$workdir/notte"
cd "$workdir"
./notte skill add
./notte skill add --upgrade
if [ ! -f .agents/skills/notte-browser/SKILL.md ]; then
echo "::error::notte skill add --upgrade did not leave notte-browser/SKILL.md installed"
exit 1
fi
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ The `--help` output is comprehensive and most agents can figure it out from ther
Add the skill to your AI coding assistant for richer context:

```bash
npx skills add nottelabs/notte-cli
npx skills add nottelabs/notte-skills
```

This works with Claude Code, Cursor, Windsurf, and other MCP-compatible assistants.
Expand Down
56 changes: 32 additions & 24 deletions internal/cmd/skills.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,17 @@ import (
"github.com/spf13/cobra"
)

// Skill source / skill name. The skill files live in the nottelabs/notte-skills
// repository (vendored here as the notte-skills submodule). Pointing the
// npx tool at nottelabs/notte-cli would clone this repo, where the skills
// are an empty submodule and no SKILL.md is found.
const (
skillSource = "nottelabs/notte-skills"
skillName = "notte-browser"
)

var skillAddUpgrade bool

var skillCmd = &cobra.Command{
Use: "skill",
Short: "Manage Notte skills for AI coding assistants",
Expand All @@ -22,7 +33,10 @@ var skillAddCmd = &cobra.Command{
Short: "Install the Notte skill for your AI coding assistant",
Long: `Install the Notte browser automation skill using npx.

This command runs: npx skills add nottelabs/notte-cli
This command runs: npx skills add nottelabs/notte-skills

With --upgrade (or -f), it runs: npx skills update notte-browser
to refresh an already-installed skill to the latest version.

The skill enables AI coding assistants (like Cursor, Claude Code, etc.)
to control browser sessions through natural language commands.`,
Expand All @@ -35,53 +49,47 @@ var skillRemoveCmd = &cobra.Command{
Short: "Remove the Notte skill from your AI coding assistant",
Long: `Remove the Notte browser automation skill using npx.

This command runs: npx skills remove nottelabs/notte-cli`,
This command runs: npx skills remove --skill notte-browser`,
RunE: runSkillRemove,
}

func init() {
rootCmd.AddCommand(skillCmd)
skillCmd.AddCommand(skillAddCmd)
skillCmd.AddCommand(skillRemoveCmd)

skillAddCmd.Flags().BoolVarP(&skillAddUpgrade, "upgrade", "f", false,
"Force a reinstall by updating an already-installed skill to the latest version")
}

func runSkillAdd(cmd *cobra.Command, args []string) error {
PrintInfo("Installing Notte skill via npx...")

// Create the npx command
npxCmd := exec.CommandContext(cmd.Context(), "npx", "skills", "add", "nottelabs/notte-cli")

// Connect stdout and stderr to show output in real-time
npxCmd.Stdout = os.Stdout
npxCmd.Stderr = os.Stderr
npxCmd.Stdin = os.Stdin

// Run the command
if err := npxCmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("skill installation failed with exit code %d", exitErr.ExitCode())
}
return fmt.Errorf("failed to run npx: %w", err)
var npxArgs []string
if skillAddUpgrade {
PrintInfo("Upgrading Notte skill via npx...")
npxArgs = []string{"skills", "update", skillName}
} else {
PrintInfo("Installing Notte skill via npx...")
npxArgs = []string{"skills", "add", skillSource}
}

return nil
return runNpx(cmd, "skill installation", npxArgs)
}

func runSkillRemove(cmd *cobra.Command, args []string) error {
PrintInfo("Removing Notte skill via npx...")
return runNpx(cmd, "skill removal", []string{"skills", "remove", "--skill", skillName, "-y"})
}

// Create the npx command
npxCmd := exec.CommandContext(cmd.Context(), "npx", "skills", "remove", "nottelabs/notte-cli")
func runNpx(cmd *cobra.Command, action string, args []string) error {
npxCmd := exec.CommandContext(cmd.Context(), "npx", args...)

// Connect stdout and stderr to show output in real-time
npxCmd.Stdout = os.Stdout
npxCmd.Stderr = os.Stderr
npxCmd.Stdin = os.Stdin

// Run the command
if err := npxCmd.Run(); err != nil {
if exitErr, ok := err.(*exec.ExitError); ok {
return fmt.Errorf("skill removal failed with exit code %d", exitErr.ExitCode())
return fmt.Errorf("%s failed with exit code %d", action, exitErr.ExitCode())
}
return fmt.Errorf("failed to run npx: %w", err)
}
Expand Down
32 changes: 27 additions & 5 deletions internal/cmd/skills_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import (
)

func TestSkillCommandStructure(t *testing.T) {
// Verify skill command exists and has correct properties
if skillCmd == nil {
t.Fatal("skillCmd is nil")
}
Expand All @@ -20,7 +19,6 @@ func TestSkillCommandStructure(t *testing.T) {
}

func TestSkillAddCommandStructure(t *testing.T) {
// Verify skill add command exists and has correct properties
if skillAddCmd == nil {
t.Fatal("skillAddCmd is nil")
}
Expand All @@ -38,8 +36,20 @@ func TestSkillAddCommandStructure(t *testing.T) {
}
}

func TestSkillAddUpgradeFlag(t *testing.T) {
upgrade := skillAddCmd.Flags().Lookup("upgrade")
if upgrade == nil {
t.Fatal("skillAddCmd should have an --upgrade flag")
}
if upgrade.Shorthand != "f" {
t.Errorf("expected --upgrade shorthand to be 'f', got %q", upgrade.Shorthand)
}
if upgrade.Value.Type() != "bool" {
t.Errorf("expected --upgrade to be a bool flag, got %s", upgrade.Value.Type())
}
}

func TestSkillRemoveCommandStructure(t *testing.T) {
// Verify skill remove command exists and has correct properties
if skillRemoveCmd == nil {
t.Fatal("skillRemoveCmd is nil")
}
Expand All @@ -56,7 +66,6 @@ func TestSkillRemoveCommandStructure(t *testing.T) {
t.Error("skillRemoveCmd.RunE should not be nil")
}

// Check that 'rm' is an alias
hasAlias := false
for _, alias := range skillRemoveCmd.Aliases {
if alias == "rm" {
Expand All @@ -70,7 +79,6 @@ func TestSkillRemoveCommandStructure(t *testing.T) {
}

func TestSkillSubcommands(t *testing.T) {
// Verify both add and remove are registered as subcommands of skill
subcommands := make(map[string]bool)
for _, cmd := range skillCmd.Commands() {
subcommands[cmd.Use] = true
Expand All @@ -84,3 +92,17 @@ func TestSkillSubcommands(t *testing.T) {
t.Error("'remove' command should be a subcommand of 'skill'")
}
}

func TestSkillSourcePointsToSkillsRepo(t *testing.T) {
// Regression guard: the npx skills tool clones whatever repo this points
// at and searches it for SKILL.md files. The skill content lives in
// nottelabs/notte-skills; pointing at nottelabs/notte-cli (the CLI repo)
// would find only the empty submodule directory and report "No skills
// found".
if skillSource != "nottelabs/notte-skills" {
t.Errorf("skillSource should be 'nottelabs/notte-skills', got %q", skillSource)
}
if skillName != "notte-browser" {
t.Errorf("skillName should be 'notte-browser', got %q", skillName)
}
}
Loading