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
17 changes: 17 additions & 0 deletions .surface-skill-drift
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
# Known skill drift — skill references flags that don't exist on the surface command.
# These are pre-existing mismatches, not regressions. Fix in SKILL.md when convenient.
# Format: FLAG <command-path> <flag>
FLAG basecamp comment --in
FLAG basecamp comments list --in
FLAG basecamp comments update --in
FLAG basecamp people --in
FLAG basecamp people add --in
FLAG basecamp people remove --in
FLAG basecamp show --in
FLAG basecamp subscriptions --in
FLAG basecamp subscriptions subscribe --in
FLAG basecamp checkins answer create --question
FLAG basecamp message --subject
FLAG basecamp timeline --limit
FLAG basecamp todo --content
FLAG basecamp webhooks create --url
30 changes: 27 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,10 @@

See [CONTRIBUTING.md](CONTRIBUTING.md) for build setup, testing, and PR workflow.

The standard development loop in this repo: make changes, run `bin/ci`, fix
what it catches, repeat until green, then push. Treat `bin/ci` as your
inner-loop companion, not a final hurdle.

## Repository Structure

```
Expand Down Expand Up @@ -49,11 +53,31 @@ Key endpoints used by the CLI:

## Testing

`bin/ci` is the local CI gate. It runs every check that remote CI runs:
formatting, vetting, linting, unit tests, e2e tests, naming conventions,
CLI surface snapshots, skill drift detection, SDK provenance, and go mod tidy.
Run it early and often — after finishing a feature, after fixing a bug, before
pushing. If you're about to `git push` and haven't run `bin/ci` in this
session, stop and run it first.

**Skill drift**: when you change CLI commands or flags, `check-skill-drift`
verifies that `skills/basecamp/SKILL.md` still references valid commands and
flags from the `.surface` snapshot. If you add, rename, or remove commands/flags,
update the skill to match.

```bash
bin/ci # The single command — run this
```

When iterating on a specific area, use targeted make targets for faster
feedback, then finish with `bin/ci` before pushing:

```bash
make build # Build binary to ./bin/basecamp
make test # Run Go unit tests
make test-e2e # Run BATS end-to-end tests
make check # All checks (fmt-check, vet, lint, test, test-e2e)
make test # Go unit tests
make test-e2e # BATS end-to-end tests
make lint # Linter
make check # All checks (what bin/ci runs)
```

Requirements: Go 1.26+, [bats-core](https://github.com/bats-core/bats-core) for e2e tests.
Expand Down
25 changes: 8 additions & 17 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,22 +3,11 @@
## Development Setup

```bash
# Clone
git clone https://github.com/basecamp/basecamp-cli
cd basecamp-cli

# Install dev tools (golangci-lint, govulncheck, etc.)
make tools

# Build
make build

# Run tests
make test # Go unit tests
make test-e2e # End-to-end tests (requires bats-core)

# Run all checks
make check # fmt-check, vet, lint, test, test-e2e
bin/setup # Install toolchain and dev tools
make build # Build
bin/ci # Verify everything passes
```

## SDK Development
Expand All @@ -45,11 +34,13 @@ The `go.work` file is gitignored - your local setup won't affect the repo.

## Pull Request Process

1. **Run checks locally** before pushing:
1. **Run CI locally** before pushing:
```bash
make check
make lint
bin/ci
```
This runs formatting, vetting, linting, unit tests, e2e tests, naming checks,
CLI surface checks, provenance checks, and tidy checks. Fix anything that fails
before pushing.

2. **Add tests** for new functionality

Expand Down
8 changes: 7 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -271,7 +271,7 @@ replace-check:

# Run all checks (local CI gate)
.PHONY: check
check: fmt-check vet lint test test-e2e check-naming check-surface provenance-check tidy-check
check: fmt-check vet lint test test-e2e check-naming check-surface check-skill-drift provenance-check tidy-check

# Full pre-flight for release: check + replace-check + vuln + race + surface compat
.PHONY: release-check
Expand Down Expand Up @@ -324,6 +324,11 @@ check-surface-compat: build
echo "First release — no baseline to compare against"; \
fi

# Verify skill references match current CLI surface (catches stale commands/flags)
.PHONY: check-skill-drift
check-skill-drift:
@scripts/check-skill-drift.sh

# Guard against bcq/BCQ creeping back (allowlist in .naming-allowlist)
.PHONY: check-naming
check-naming:
Expand Down Expand Up @@ -444,6 +449,7 @@ help:
@echo " check Run all checks (local CI gate)"
@echo " check-surface Generate CLI surface snapshot (validates --help --agent output)"
@echo " check-surface-diff Compare CLI surface snapshots (fails on removals)"
@echo " check-skill-drift Verify skill references match CLI surface"
@echo ""
@echo "Dependencies:"
@echo " update-nix-hash Recompute Nix vendorHash via Docker"
Expand Down
3 changes: 3 additions & 0 deletions bin/ci
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
#!/usr/bin/env bash
set -euo pipefail
exec make check
10 changes: 2 additions & 8 deletions internal/commands/files.go
Original file line number Diff line number Diff line change
Expand Up @@ -130,17 +130,11 @@ func runFilesList(cmd *cobra.Command, project, vaultID string) error {
return output.ErrUsage("Invalid folder ID")
}

// Get vault details using SDK
vault, err := app.Account().Vaults().Get(cmd.Context(), vaultIDNum)
if err != nil {
// Validate vault exists
if _, err := app.Account().Vaults().Get(cmd.Context(), vaultIDNum); err != nil {
return convertSDKError(err)
}

vaultTitle := vault.Title
if vaultTitle == "" {
vaultTitle = "Docs & Files"
}

// Get folders (subvaults) using SDK
var folders []basecamp.Vault
foldersResult, err := app.Account().Vaults().List(cmd.Context(), vaultIDNum, nil)
Expand Down
139 changes: 139 additions & 0 deletions scripts/check-skill-drift.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
#!/usr/bin/env bash
# Verify that commands and flags referenced in skill files exist in the CLI surface.
# Catches stale skill references: renamed commands, removed flags, etc.
set -euo pipefail

SKILL="${1:-skills/basecamp/SKILL.md}"
SURFACE="${2:-.surface}"
BASELINE="${3:-.surface-skill-drift}"

# Built-in flags not tracked in the surface
BUILTINS="--help --version"

if [ ! -f "$SKILL" ]; then
echo "ERROR: skill file not found: $SKILL" >&2
exit 1
fi
if [ ! -f "$SURFACE" ]; then
echo "ERROR: surface file not found: $SURFACE" >&2
exit 1
fi

errors=0
cmd_checked=0
flag_checked=0
new_drift=0

# Check whether an entry is in the baseline file (avoids Bash 4+ associative arrays).
is_baselined() {
[ -f "$BASELINE" ] && grep -qxF "$1" "$BASELINE"
}

# Resolve a candidate command path to the longest matching CMD in .surface.
# Prints the match (or nothing). Rejects fallback to bare "basecamp" —
# every extracted candidate has at least one subcommand word, so matching
# only the root means none of the subcommands exist.
resolve_cmd() {
local candidate="$1"
read -ra parts <<< "$candidate"
for ((i=${#parts[@]}; i>=1; i--)); do
local try="${parts[*]:0:$i}"
if grep -qxF "CMD $try" "$SURFACE"; then
Comment thread
jeremy marked this conversation as resolved.
# Reject bare root — candidate always has subcommand tokens
if [ "$try" = "basecamp" ] && [ "${#parts[@]}" -gt 1 ]; then
return
fi
echo "$try"
return
fi
done
}

# Check whether a flag exists on a command or any of its subcommands.
# Descendant matching is intentional: Cobra commands inherit flags and shortcut
# commands delegate to subcommands (e.g. "basecamp cards --in" runs "cards list"
# which has --in). Strict matching would produce false positives.
# Note: uses grep -c instead of grep -q to avoid pipefail + SIGPIPE false negatives.
flag_exists() {
local cmd="$1" flag="$2"
local count
count=$(grep "^FLAG ${cmd} " "$SURFACE" | grep -cF " ${flag} type=" || true)
Comment thread
jeremy marked this conversation as resolved.
[ "$count" -gt 0 ]
}

# Strip YAML frontmatter — trigger keywords (e.g. "basecamp project") are
# natural-language match phrases, not CLI command references.
skill_body=$(awk '/^---$/{n++; next} n>=2' "$SKILL")

# --- Phase 1: Command references ---
# Extract "basecamp <subcommand>..." patterns, resolve to longest matching CMD.
while IFS= read -r candidate; do
matched=$(resolve_cmd "$candidate")

if [ -z "$matched" ]; then
key="CMD ${candidate}"
if is_baselined "$key"; then
: # known drift
else
echo "DRIFT: command not in surface: $candidate"
new_drift=$((new_drift + 1))
fi
errors=$((errors + 1))
fi
cmd_checked=$((cmd_checked + 1))
done < <(echo "$skill_body" | grep -oE 'basecamp( [a-z][-a-z0-9]+)+' | sort -u)

# --- Phase 2: Flag references ---
# For lines with "basecamp <cmd> ... --flag", verify each flag exists on the
# resolved command or one of its subcommands.
tmpfile=$(mktemp)
trap 'rm -f "$tmpfile"' EXIT

echo "$skill_body" | grep -nE 'basecamp [a-z].+--[a-z]' > "$tmpfile" || true

while IFS=: read -r lineno line; do
# Extract command candidate using BASH_REMATCH to avoid grep|head SIGPIPE
if [[ "$line" =~ basecamp(\ [a-z][-a-z0-9]+)+ ]]; then
cmd_candidate="${BASH_REMATCH[0]}"
else
continue
fi

matched=$(resolve_cmd "$cmd_candidate")
[ -z "$matched" ] && continue

for flag in $(echo "$line" | grep -oE '\-\-[a-z][-a-z0-9]*' | sort -u); do
# Skip cobra built-ins
case " $BUILTINS " in *" $flag "*) continue ;; esac

flag_checked=$((flag_checked + 1))
if flag_exists "$matched" "$flag"; then
: # found
else
key="FLAG ${matched} ${flag}"
if is_baselined "$key"; then
: # known drift
else
echo "DRIFT: flag ${flag} not found on ${matched} (line ${lineno})"
new_drift=$((new_drift + 1))
fi
errors=$((errors + 1))
fi
done
done < "$tmpfile"

# --- Summary ---
baselined=$((errors - new_drift))
if [ $new_drift -gt 0 ]; then
echo ""
echo "Found ${new_drift} new skill drift issue(s) (${baselined} baselined)."
echo "Update ${SKILL} to match the current CLI surface (.surface),"
echo "or add entries to ${BASELINE} if the drift is intentional."
exit 1
fi

if [ $baselined -gt 0 ]; then
echo "Skill drift check passed (${cmd_checked} commands, ${flag_checked} flags; ${baselined} baselined)"
else
echo "Skill drift check passed (${cmd_checked} commands, ${flag_checked} flags validated)"
fi
Loading