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
2 changes: 1 addition & 1 deletion .claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "ksrc",
"description": "Ksrc CLI skill for searching/reading Kotlin dependency sources",
"version": "0.5.2",
"version": "0.6.0",
"repository": "https://github.com/respawn-app/ksrc"
}
20 changes: 20 additions & 0 deletions .github/workflows/auto-assign-issues.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
name: Auto-assign issues

on:
issues:
types: [opened, reopened]

permissions:
issues: write

jobs:
auto-assign:
runs-on: ubuntu-latest
steps:
- name: Auto-assign issue
uses: pozil/auto-assign-issue@v2
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
assignees: Nek-12
numOfAssignee: 1
allowSelfAssign: true
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
- Search changes: must keep `rg` call scoped to resolved JARs only.
- After code changes, rebuild the binary to `./bin/ksrc` so the symlinked CLI updates for the user.
- Brew manipulation: The brew tap with the ksrc formulat is separate repo at https://github.com/respawn-app/homebrew-tap . It's usually cloned at the parent dir of the cwd (./../homebrew-tap/)
- MCP server: `ksrc mcp` (stdio). Default tools: `search`, `cat`, `deps`. Enable extras via `--tools=<list>` or `--tools=all`.

## Tests
- Unit: parsing, version selection, file‑id handling.
Expand All @@ -42,4 +43,5 @@
## Clean Merge Expectations
- Keep changes focused;
- Update ./docs and ./skills when CLI flags, outputs, APIs or formats change.
- Release process: see ./docs/release-workflow.md
- Update AGENTS.md (this file) with learnings/rules/memories for future you.
21 changes: 20 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -57,9 +57,28 @@ Install from the public GitHub path:
$skill-installer install https://github.com/respawn-app/ksrc/tree/main/skills/ksrc
```

### MCP (Model Context Protocol)

Use when your agent doesn't have `bash` tool. Configure your MCP client to spawn the stdio server:

```json
{
"mcpServers": {
"ksrc": {
"command": "ksrc",
"args": ["mcp"]
}
}
}
```

Default tools: `search`, `cat`, `deps`. Enable more via `--tools=<list>` (e.g., `--tools=search,cat,deps,resolve` or `--tools=all`).

You shouldn't need the skill if you use mcp, but if your agent has access to `bash` tool, prefer CLI+bash instead of the mcp.

### AGENTS.md prompt

> Use `ksrc` bash command to discover Kotlin/gradle library dependency sources. Start with `ksrc --help`.
> Avoid directly accessing `.gradle`; instead, proactively use `ksrc` bash tool to inspect source code of dependencies to learn API shapes or implementations. Start with `ksrc --help`.

## Usage

Expand Down
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.5.2
0.6.0
27 changes: 27 additions & 0 deletions docs/cli-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,33 @@ Diagnostics for project detection, Gradle cache accessibility, and source availa

---

### `ksrc mcp`
Run an MCP server over stdio for tool integrations.

**Usage**
```
ksrc mcp [flags]
```

**Flags**
- `--tools <list>`: Comma-separated tool list (default: `search,cat,deps`; use `all` for all tools)

**Default tools**
- `search`
- `cat`
- `deps`

**Optional tools (enable via --tools)**
- `fetch`
- `resolve`
- `where`

**Notes**
- Transport is stdio only; clients should spawn `ksrc mcp` via `mcp.json`.
- Outputs are plain text matching CLI formats.

---

## File Identifier
`<file-id>` is a fully qualified path to a file inside a source JAR:
`group:artifact:version!/path/inside/jar.kt`
Expand Down
5 changes: 5 additions & 0 deletions docs/decisions.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,3 +60,8 @@ Rationale: avoid expensive Gradle runs unless needed; prioritize the most likely
- CLI delegates resolution to `internal/resolution` to keep command wiring thin.
- Gradle traversal is separated from invocation/parsing with an injectable resolver for tests.
- Search strategy selection is separated from rg output parsing; zip capability is cached per process.

## 2026-01-31: MCP tools return plaintext only (no structuredContent)
- Problem: some MCP harnesses (e.g. Codex) prefer `structuredContent` over `content`. The Go SDK auto-populates `structuredContent` for typed handlers (`ToolHandlerFor`), and when the output type was `struct{}`, it serialized to `{}`. That caused harnesses to ignore the real plaintext output in `content`.
- Decision: switch MCP tools to untyped handlers (`ToolHandler`) and supply explicit `InputSchema` to keep validation while ensuring we only emit `content` text. Errors are returned as `IsError=true` with a text payload in `content`.
- Tradeoff: we lose auto-generated output schemas/structured output, but avoid empty `{}` structured payloads and keep cross-harness behavior consistent for plaintext tools.
59 changes: 59 additions & 0 deletions docs/release-workflow.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
# Release workflow

This document describes how a release is cut, what must be bumped, and what automation runs.

## Files and locations

- Version source: `VERSION`
- Claude plugin version: `.claude-plugin/plugin.json`
- Release workflow: `.github/workflows/release.yml`
- Tap update script: `scripts/update-brew-tap.sh`
- Homebrew tap repo (separate): `../homebrew-tap` or https://github.com/respawn-app/homebrew-tap.
- Formula: `../homebrew-tap/Formula/ksrc.rb`
- Tap CI:
- `../homebrew-tap/.github/workflows/tests.yml` (brew test-bot)
- `../homebrew-tap/.github/workflows/publish.yml` (brew pr-pull)

## What to bump before a release

1. `VERSION`
- This becomes the tag `vX.Y.Z` and the CLI version.
2. `.claude-plugin/plugin.json`
- Keep plugin version aligned with the release version.

Optional (as needed):
- Update docs in `docs/` and skills in `skills/` when CLI flags, outputs, APIs or formats change.

## How a release is cut

Trigger the workflow:
- `gh workflow run release.yml --ref main`

What it does:
1. Reads `VERSION` and computes the tag `vX.Y.Z`.
2. Creates the git tag (if missing) and pushes it.
3. Builds release binaries for all OS/arch pairs and uploads them to a draft GitHub release.
4. Updates the Homebrew tap by opening a PR in `respawn-app/homebrew-tap`:
- `scripts/update-brew-tap.sh` updates the source tarball URL + sha256.
- The script also removes any existing `bottle do` block, so bottles are regenerated.

## Tap publishing (bottles)

Bottles are generated and uploaded by the tap repo:

1. The release workflow opens a tap PR with label `pr-pull`.
2. `../homebrew-tap/.github/workflows/publish.yml` runs `brew pr-pull` on that PR.
3. `brew pr-pull` builds bottles and writes the `bottle do` block back into the formula.
4. The PR is merged, and users can install via bottles by default.

Notes:
- `brew install -s ksrc` still builds from source because the formula points at the source tarball.
- If bottles become mismatched with the formula (version/revision/license), re-run pr-pull on a fresh PR with no bottle block.

## Post-release checks

- Verify the draft release assets are present and correct.
- Verify the tap PR is opened and `pr-pull` ran successfully.
- On macOS 26 Tahoe (arm64), run:
- `brew tap respawn-app/tap`
- `brew install ksrc`
8 changes: 7 additions & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,15 @@ module github.com/respawn-app/ksrc

go 1.25.5

require github.com/spf13/cobra v1.10.2
require (
github.com/modelcontextprotocol/go-sdk v1.1.0
github.com/spf13/cobra v1.10.2
)

require (
github.com/google/jsonschema-go v0.3.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/spf13/pflag v1.0.9 // indirect
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
golang.org/x/oauth2 v0.30.0 // indirect
)
12 changes: 12 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -1,10 +1,22 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/jsonschema-go v0.3.0 h1:6AH2TxVNtk3IlvkkhjrtbUc4S8AvO0Xii0DxIygDg+Q=
github.com/google/jsonschema-go v0.3.0/go.mod h1:r5quNTdLOYEz95Ru18zA0ydNbBuYoo9tgaYcxEYhJVE=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/modelcontextprotocol/go-sdk v1.1.0 h1:Qjayg53dnKC4UZ+792W21e4BpwEZBzwgRW6LrjLWSwA=
github.com/modelcontextprotocol/go-sdk v1.1.0/go.mod h1:6fM3LCm3yV7pAs8isnKLn07oKtB0MP9LHd3DfAcKw10=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/yosida95/uritemplate/v3 v3.0.2 h1:Ed3Oyj9yrmi9087+NczuL5BwkIc4wvTb5zIM+UJPGz4=
github.com/yosida95/uritemplate/v3 v3.0.2/go.mod h1:ILOh0sOhIJR3+L/8afwt/kE++YT040gmv5BQTMR2HP4=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI=
golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU=
golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo=
golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
11 changes: 7 additions & 4 deletions internal/cat/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"bytes"
"fmt"
"io"
"regexp"
"strings"
)

Expand All @@ -19,15 +20,15 @@ func ParseLineRange(value string) (*LineRange, error) {
if value == "" {
return nil, nil
}
parts := strings.Split(value, ",")
if len(parts) != 2 {
matches := lineRangeRe.FindStringSubmatch(value)
if matches == nil {
return nil, fmt.Errorf("invalid line range: %q", value)
}
start, err := parsePositive(parts[0])
start, err := parsePositive(matches[1])
if err != nil {
return nil, err
}
end, err := parsePositive(parts[1])
end, err := parsePositive(matches[2])
if err != nil {
return nil, err
}
Expand All @@ -46,6 +47,8 @@ func parsePositive(s string) (int, error) {
return n, nil
}

var lineRangeRe = regexp.MustCompile(`^\s*(\d+)\s*(?:,|:|-|\.{2}|;|\s)\s*(\d+)\s*$`)

// ReadFileFromZip reads a file from a zip/jar and optionally slices by line range.
func ReadFileFromZip(zipPath, innerPath string, lr *LineRange) ([]byte, error) {
zr, err := zip.OpenReader(zipPath)
Expand Down
26 changes: 20 additions & 6 deletions internal/cat/cat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,28 @@ import (
)

func TestParseLineRange(t *testing.T) {
lr, err := ParseLineRange("1,3")
if err != nil {
t.Fatalf("unexpected error: %v", err)
cases := []struct {
in string
start int
end int
}{
{"1,3", 1, 3},
{"1:3", 1, 3},
{"1-3", 1, 3},
{"1 3", 1, 3},
{"1..3", 1, 3},
{"1;3", 1, 3},
}
if lr.Start != 1 || lr.End != 3 {
t.Fatalf("unexpected range: %+v", lr)
for _, tc := range cases {
lr, err := ParseLineRange(tc.in)
if err != nil {
t.Fatalf("unexpected error for %q: %v", tc.in, err)
}
if lr.Start != tc.start || lr.End != tc.end {
t.Fatalf("unexpected range for %q: %+v", tc.in, lr)
}
}
if _, err := ParseLineRange("1:3"); err == nil {
if _, err := ParseLineRange("1 3 4"); err == nil {
t.Fatal("expected error for invalid range")
}
}
Expand Down
5 changes: 2 additions & 3 deletions internal/cli/cat.go
Original file line number Diff line number Diff line change
Expand Up @@ -91,7 +91,7 @@ func newCatCmd(app *App) *cobra.Command {
cmd.Flags().BoolVar(&flags.IncludeBuildSrc, "buildsrc", true, "include buildSrc dependencies (set --buildsrc=false to disable)")
cmd.Flags().BoolVar(&flags.IncludeBuildscript, "buildscript", true, "include buildscript classpath dependencies (set --buildscript=false to disable)")
cmd.Flags().BoolVar(&flags.IncludeIncludedBuilds, "include-builds", true, "include composite builds (includeBuild) (set --include-builds=false to disable)")
cmd.Flags().StringVar(&lines, "lines", "", "line range (start,end)")
cmd.Flags().StringVar(&lines, "lines", "", "line range (start,end | start:end | start-end | start..end | start;end)")

return cmd
}
Expand All @@ -108,8 +108,7 @@ func findJarByCoord(sources []resolve.SourceJar, coord resolve.Coord) (string, e
func findFileInJars(sources []resolve.SourceJar, inner string) (string, string, error) {
inner = strings.TrimPrefix(inner, "/")
for _, s := range sources {
data, err := cat.ReadFileFromZip(s.Path, inner, nil)
if err == nil && len(data) > 0 {
if _, err := cat.ReadFileFromZip(s.Path, inner, nil); err == nil {
return s.Path, inner, nil
}
}
Expand Down
58 changes: 58 additions & 0 deletions internal/cli/cat_empty_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package cli

import (
"archive/zip"
"os"
"path/filepath"
"testing"

"github.com/respawn-app/ksrc/internal/resolve"
)

func TestFindFileInJarsAllowsEmptyFile(t *testing.T) {
jarPath := filepath.Join(t.TempDir(), "empty.jar")
inner := "com/example/Empty.kt"
if err := writeZipFile(jarPath, inner, ""); err != nil {
t.Fatalf("write zip: %v", err)
}

sources := []resolve.SourceJar{{
Coord: resolve.Coord{Group: "com.example", Artifact: "demo", Version: "1.0.0"},
Path: jarPath,
}}

path, foundInner, err := findFileInJars(sources, inner)
if err != nil {
t.Fatalf("findFileInJars error: %v", err)
}
if path != jarPath {
t.Fatalf("unexpected jar path: %s", path)
}
if foundInner != inner {
t.Fatalf("unexpected inner path: %s", foundInner)
}
}

func writeZipFile(path, inner, content string) error {
f, err := os.Create(path)
if err != nil {
return err
}
zw := zip.NewWriter(f)
w, err := zw.Create(inner)
if err != nil {
_ = zw.Close()
_ = f.Close()
return err
}
if _, err := w.Write([]byte(content)); err != nil {
_ = zw.Close()
_ = f.Close()
return err
}
if err := zw.Close(); err != nil {
_ = f.Close()
return err
}
return f.Close()
}
Loading
Loading