f99010fae1
CI / lint (push) Failing after 1s
CI / frontend (push) Failing after 1s
CI / scripts (push) Failing after 1s
CI / Go Test (ubuntu-latest) (push) Failing after 0s
CI / frontend-node-25 (push) Failing after 1s
CI / docs (push) Failing after 0s
CI / coverage (push) Failing after 0s
CI / e2e (push) Failing after 0s
Docker / build-and-push (push) Failing after 1s
CI / integration (push) Failing after 4m43s
CI / Go Test (windows-latest) (push) Has been cancelled
CI / Desktop Unit Tests (Windows) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux (arm64)) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Linux) (push) Has been cancelled
Desktop Artifacts / Desktop Build (Windows) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (aarch64)) (push) Has been cancelled
Desktop Artifacts (macOS) / Desktop Build (macOS (x86_64)) (push) Has been cancelled
767 lines
22 KiB
Go
767 lines
22 KiB
Go
package parser
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
"github.com/tidwall/gjson"
|
|
|
|
"go.kenn.io/agentsview/internal/testjsonl"
|
|
)
|
|
|
|
func TestInferCursorSkillNameFromReadFile(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
_, _, toolCalls := extractAssistantContent([]string{
|
|
"[Tool call] ReadFile",
|
|
" path=" + path,
|
|
})
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "ReadFile", toolCalls[0].ToolName)
|
|
assert.Equal(t, "foo", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestInferCursorSkillNameFromShellCommand(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
_, _, toolCalls := extractAssistantContent([]string{
|
|
"[Tool call] Shell",
|
|
" command=cat " + path,
|
|
})
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "Shell", toolCalls[0].ToolName)
|
|
assert.Equal(t, "foo", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestInferCursorSkillNameIgnoresShellWriteCommand(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
_, _, toolCalls := extractAssistantContent([]string{
|
|
"[Tool call] Shell",
|
|
" command=cp " + path + " /tmp/SKILL.md",
|
|
})
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Empty(t, toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestInferCursorSkillNameUsesFrontmatterName(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
|
|
_, _, toolCalls := extractAssistantContent([]string{
|
|
"[Tool call] ReadFile",
|
|
` {"path":` + quoteJSON(t, path) + `}`,
|
|
})
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "data-analytics:index", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestInferCursorSkillNameIgnoresDiscoveryAndNonSkillPaths(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
tests := []struct {
|
|
name string
|
|
line string
|
|
}{
|
|
{
|
|
name: "glob discovery",
|
|
line: "[Tool call] Glob\n " +
|
|
`{"target_directory":` + quoteJSON(t, filepath.Dir(path)) +
|
|
`,"glob_pattern":"**/SKILL.md"}`,
|
|
},
|
|
{
|
|
name: "non skill path",
|
|
line: `[Tool call] ReadFile
|
|
path=` + filepath.Join(t.TempDir(), "README.md"),
|
|
},
|
|
{
|
|
name: "empty input",
|
|
line: "[Tool call] ReadFile",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
_, _, toolCalls := extractAssistantContent(
|
|
splitTestLines(tt.line),
|
|
)
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Empty(t, toolCalls[0].SkillName)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameFromReadCommands(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
for _, cmd := range []string{
|
|
"cat " + path,
|
|
"sed -n '1,220p' " + path,
|
|
"head -40 " + path,
|
|
"tail -40 " + path,
|
|
"rg name " + path,
|
|
"grep name " + path,
|
|
"cd /tmp && sed -n '1,220p' " + path,
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+`}`,
|
|
)
|
|
assert.Equal(t, "foo", got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameIgnoresWriteCommands(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
|
|
for _, cmd := range []string{
|
|
"cp " + path + " /tmp/SKILL.md",
|
|
"mv " + path + " /tmp/SKILL.md",
|
|
"mkdir -p " + filepath.Dir(path),
|
|
"git add " + path,
|
|
"sed -i '' 's/a/b/' " + path,
|
|
"sed -ni 's/a/b/' " + path,
|
|
"sed -Ei 's/a/b/' " + path,
|
|
"echo hi && sed -i '' 's/a/b/' " + path,
|
|
"cat > " + path,
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameMixedWriteThenRead(t *testing.T) {
|
|
// A write segment earlier in the command must not suppress a real
|
|
// skill read in a later segment; each segment is classified on its
|
|
// own leading verb rather than rejecting the whole command.
|
|
path := writeTestSkill(t, "foo", "data-analytics:foo")
|
|
|
|
for _, cmd := range []string{
|
|
"mkdir -p out && cat " + path,
|
|
"git add -A && sed -n '1,40p' " + path,
|
|
"touch marker; grep name " + path,
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:foo", got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameIgnoresGlobDiscovery(t *testing.T) {
|
|
for _, cmd := range []string{
|
|
"rg --files -g '**/SKILL.md'",
|
|
"cat **/SKILL.md",
|
|
"sed -n '1,40p' skills/*/SKILL.md",
|
|
"grep name skills/?ndex/SKILL.md",
|
|
"head -40 skills/[ab]/SKILL.md",
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameBareSkillFileWithWorkdir(t *testing.T) {
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "cat SKILL.md")+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameBareSkillFileUsesFallbackCwd(t *testing.T) {
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
cwd := filepath.Dir(path)
|
|
|
|
got := inferCodexSkillNameWithBase(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "sed -n '1,40p' SKILL.md")+`}`,
|
|
cwd,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameBareSkillPatternWithoutFileIgnored(t *testing.T) {
|
|
// "grep SKILL.md notes.txt" uses SKILL.md as a search pattern, not
|
|
// a file. With a workdir that holds no SKILL.md the bare token must
|
|
// not be resolved to the workdir name and miscounted as usage.
|
|
workdir := t.TempDir()
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "grep SKILL.md notes.txt")+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameBareSkillSearchPatternIgnoredEvenWithFile(t *testing.T) {
|
|
// grep/rg take the search pattern as their first operand, so a bare
|
|
// "SKILL.md" there is the pattern, not a file to read. It must not be
|
|
// inferred even when the workdir holds a readable SKILL.md.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
for _, cmd := range []string{
|
|
"grep SKILL.md notes.txt",
|
|
"rg SKILL.md",
|
|
"rg SKILL.md notes.txt",
|
|
"rg -t md SKILL.md", // SKILL.md is the pattern; md is -t's value
|
|
"grep -A 3 SKILL.md notes.txt", // SKILL.md is the pattern; 3 is -A's value
|
|
"grep -e SKILL.md notes.txt", // SKILL.md is the -e pattern value
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameSearchCommandReadsFileOperand(t *testing.T) {
|
|
// When a pattern precedes SKILL.md (or -e/-f supplies the pattern),
|
|
// SKILL.md is a file operand and a workdir-local read is inferred.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
for _, cmd := range []string{
|
|
"grep name SKILL.md",
|
|
"rg pattern SKILL.md",
|
|
"cat SKILL.md | grep name",
|
|
"grep -e foo SKILL.md",
|
|
"grep -i name SKILL.md",
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameSearchQuotedPatternIgnored(t *testing.T) {
|
|
// A quoted multi-word search pattern containing SKILL.md must not be
|
|
// split into fields and read as a file, even with a workdir SKILL.md.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
for _, cmd := range []string{
|
|
`grep "foo SKILL.md" notes.txt`,
|
|
`rg 'see SKILL.md for details'`,
|
|
`grep -e "read the SKILL.md" notes.txt`,
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameIgnoresNonReadSegments(t *testing.T) {
|
|
// A read command elsewhere in the line must not let SKILL.md in an
|
|
// unrelated segment (echo, ls, ...) be inferred as a read.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
for _, cmd := range []string{
|
|
"grep foo notes.txt && echo SKILL.md",
|
|
"cat notes.txt && echo see SKILL.md",
|
|
"grep foo notes.txt; ls SKILL.md",
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestTokenizeCommand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cmd string
|
|
want []string
|
|
}{
|
|
{
|
|
// Backslashes are literal so Windows paths survive intact;
|
|
// a full shell lexer such as shlex would consume them.
|
|
name: "preserves backslash path",
|
|
cmd: `cat C:\Users\me\skills\foo\SKILL.md`,
|
|
want: []string{"cat", `C:\Users\me\skills\foo\SKILL.md`},
|
|
},
|
|
{
|
|
name: "double-quoted argument stays one token",
|
|
cmd: `grep "foo SKILL.md" notes.txt`,
|
|
want: []string{"grep", "foo SKILL.md", "notes.txt"},
|
|
},
|
|
{
|
|
name: "single-quoted argument stays one token",
|
|
cmd: `rg 'see SKILL.md for'`,
|
|
want: []string{"rg", "see SKILL.md for"},
|
|
},
|
|
{
|
|
name: "drops redirect target attached to operator",
|
|
cmd: "cat foo >skills/x/SKILL.md",
|
|
want: []string{"cat", "foo"},
|
|
},
|
|
{
|
|
name: "drops redirect operator attached to source",
|
|
cmd: "cat file> out",
|
|
want: []string{"cat", "file"},
|
|
},
|
|
{
|
|
name: "drops space-separated redirect target",
|
|
cmd: "cat a > b",
|
|
want: []string{"cat", "a"},
|
|
},
|
|
{
|
|
name: "keeps quoted redirect char as content",
|
|
cmd: `grep ">" f`,
|
|
want: []string{"grep", ">", "f"},
|
|
},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, tokenizeCommand(tt.cmd))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameIgnoresRedirectTargets(t *testing.T) {
|
|
// A redirect destination (>, >>, 2> ...) is written, not read, so a
|
|
// SKILL.md target must not be inferred even with a workdir file. The
|
|
// operator may be attached to the target (>SKILL.md) or to the source
|
|
// token (file>), so whitespace around it cannot be relied upon.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
workdir := filepath.Dir(path)
|
|
|
|
for _, cmd := range []string{
|
|
"cat foo > SKILL.md",
|
|
"grep name file > SKILL.md",
|
|
"cat foo >> SKILL.md",
|
|
"cat foo 2> SKILL.md",
|
|
"cat foo >SKILL.md",
|
|
"cat foo >>SKILL.md",
|
|
"cat file> SKILL.md",
|
|
"grep name file 2>SKILL.md",
|
|
"cat foo >skills/data-analytics/SKILL.md",
|
|
} {
|
|
t.Run(cmd, func(t *testing.T) {
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, cmd)+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
assert.Empty(t, got)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameReadsFileDespiteQuotedRedirectChar(t *testing.T) {
|
|
// A quoted ">" is a literal search pattern, not a redirect operator,
|
|
// so the SKILL.md file operand is still read.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, `grep ">" `+path)+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameReadsSourceDespiteRedirect(t *testing.T) {
|
|
// SKILL.md is the input being read; output is redirected elsewhere,
|
|
// so the read is still inferred.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "cat "+path+" > out.txt")+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameSearchCommandStillReadsPathOperand(t *testing.T) {
|
|
// A path-qualified SKILL.md operand to grep/rg is a real file read
|
|
// (the pattern is a separate token), so it is still inferred.
|
|
path := writeTestSkill(t, "data-analytics", "data-analytics:index")
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "grep name "+path)+`}`,
|
|
)
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestParseCodexSessionInfersSkillName(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON("skill-read", "/tmp", "user", tsEarly),
|
|
testjsonl.CodexMsgJSON("user", "use the dashboard skill", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallArgsJSON("exec_command", map[string]any{
|
|
"cmd": "sed -n '1,220p' '" + path + "'",
|
|
}, tsEarlyS5),
|
|
)
|
|
|
|
_, msgs := runCodexParserTest(t, "skill-read.jsonl", content, false)
|
|
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "data-analytics:index", msgs[1].ToolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestParseCodexSessionInfersSkillNameFromSessionCwd(t *testing.T) {
|
|
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
cwd := filepath.Dir(filepath.Dir(filepath.Dir(path)))
|
|
content := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON("skill-read", cwd, "user", tsEarly),
|
|
testjsonl.CodexMsgJSON("user", "use the dashboard skill", tsEarlyS1),
|
|
testjsonl.CodexFunctionCallArgsJSON("exec_command", map[string]any{
|
|
"cmd": "sed -n '1,220p' skills/index/SKILL.md",
|
|
}, tsEarlyS5),
|
|
)
|
|
|
|
_, msgs := runCodexParserTest(t, "skill-read.jsonl", content, false)
|
|
|
|
require.Len(t, msgs, 2)
|
|
require.Len(t, msgs[1].ToolCalls, 1)
|
|
assert.Equal(t, "data-analytics:index", msgs[1].ToolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestParseCodexSessionFromInfersSkillNameFromSeededCwd(t *testing.T) {
|
|
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
cwd := filepath.Dir(filepath.Dir(filepath.Dir(path)))
|
|
|
|
initial := testjsonl.JoinJSONL(
|
|
testjsonl.CodexSessionMetaJSON("inc-skill", cwd, "codex_cli_rs", tsEarly),
|
|
testjsonl.CodexMsgJSON("user", "use the dashboard skill", tsEarlyS1),
|
|
)
|
|
file := createTestFile(t, "incremental-skill.jsonl", initial)
|
|
_, msgs, err := parseCodexTestSession(t, file, "local", false)
|
|
require.NoError(t, err)
|
|
|
|
info, err := os.Stat(file)
|
|
require.NoError(t, err)
|
|
offset := info.Size()
|
|
|
|
appended := testjsonl.CodexFunctionCallArgsJSON(
|
|
"exec_command", map[string]any{
|
|
"cmd": "sed -n '1,220p' skills/index/SKILL.md",
|
|
}, tsLateS5)
|
|
f, err := os.OpenFile(file, os.O_APPEND|os.O_WRONLY, 0o644)
|
|
require.NoError(t, err)
|
|
_, err = f.WriteString(appended)
|
|
require.NoError(t, err)
|
|
require.NoError(t, f.Close())
|
|
|
|
newMsgs, _, _, err := parseCodexTestSessionFrom(t, file, offset, len(msgs), false)
|
|
require.NoError(t, err)
|
|
require.Len(t, newMsgs, 1)
|
|
require.Len(t, newMsgs[0].ToolCalls, 1)
|
|
assert.Equal(t, "data-analytics:index", newMsgs[0].ToolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestExtractTextContentInfersCursorJSONLSkillName(t *testing.T) {
|
|
path := writeTestSkill(t, "planning-and-task-breakdown", "planning-and-task-breakdown")
|
|
content := gjson.Parse(
|
|
`[{"type":"tool_use","id":"tu_read","name":"Read","input":{"path":` +
|
|
quoteJSON(t, path) + `}}]`,
|
|
)
|
|
|
|
_, _, _, _, toolCalls, _ := ExtractTextContent(content)
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "Read", toolCalls[0].ToolName)
|
|
assert.Equal(t, "planning-and-task-breakdown", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestExtractTextContentInfersCursorJSONLSkillNameFromFrontmatter(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
content := gjson.Parse(
|
|
`[{"type":"tool_use","id":"tu_read_file","name":"ReadFile","input":{"path":` +
|
|
quoteJSON(t, path) + `}}]`,
|
|
)
|
|
|
|
_, _, _, _, toolCalls, _ := ExtractTextContent(content)
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "ReadFile", toolCalls[0].ToolName)
|
|
assert.Equal(t, "data-analytics:index", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestExtractTextContentInfersSkillNameFromPathWithSpaces(t *testing.T) {
|
|
path := writeTestSkill(t, "my index", "data-analytics:index")
|
|
require.Contains(t, path, " ")
|
|
|
|
content := gjson.Parse(
|
|
`[{"type":"tool_use","id":"tu_read","name":"Read","input":{"path":` +
|
|
quoteJSON(t, path) + `}}]`,
|
|
)
|
|
|
|
_, _, _, _, toolCalls, _ := ExtractTextContent(content)
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "data-analytics:index", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestExtractTextContentInfersCursorJSONLSkillNameFromShellRead(t *testing.T) {
|
|
path := writeTestSkill(t, "qa", "qa")
|
|
content := gjson.Parse(
|
|
`[{"type":"tool_use","id":"tu_shell","name":"Shell","input":{"command":` +
|
|
quoteJSON(t, "cd /tmp && sed -n '1,120p' "+path) + `}}]`,
|
|
)
|
|
|
|
_, _, _, _, toolCalls, _ := ExtractTextContent(content)
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Equal(t, "Shell", toolCalls[0].ToolName)
|
|
assert.Equal(t, "qa", toolCalls[0].SkillName)
|
|
}
|
|
|
|
func TestExtractTextContentDoesNotInferCursorJSONLNonUsage(t *testing.T) {
|
|
path := writeTestSkill(t, "foo", "foo")
|
|
templatePath := filepath.Join(t.TempDir(), "SKILL.md.tmpl")
|
|
require.NoError(t, os.WriteFile(templatePath, []byte("template"), 0o644))
|
|
|
|
tests := []struct {
|
|
name string
|
|
toolName string
|
|
input string
|
|
}{
|
|
{
|
|
name: "glob discovery",
|
|
toolName: "Glob",
|
|
input: `{"glob_pattern":"**/SKILL.md"}`,
|
|
},
|
|
{
|
|
name: "write skill file",
|
|
toolName: "Write",
|
|
input: `{"path":` + quoteJSON(t, path) + `,"contents":"---"}`,
|
|
},
|
|
{
|
|
name: "str replace skill file",
|
|
toolName: "StrReplace",
|
|
input: `{"path":` + quoteJSON(t, path) + `,"old_string":"a","new_string":"b"}`,
|
|
},
|
|
{
|
|
name: "apply patch skill file",
|
|
toolName: "ApplyPatch",
|
|
input: `{"path":` + quoteJSON(t, path) + `,"patch":"*** Begin Patch"}`,
|
|
},
|
|
{
|
|
name: "template file",
|
|
toolName: "Read",
|
|
input: `{"path":` + quoteJSON(t, templatePath) + `}`,
|
|
},
|
|
{
|
|
name: "shell write command",
|
|
toolName: "Shell",
|
|
input: `{"command":` + quoteJSON(t, "cp "+path+" /tmp/SKILL.md") + `}`,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
content := gjson.Parse(
|
|
`[{"type":"tool_use","id":"tu","name":` +
|
|
quoteJSON(t, tt.toolName) + `,"input":` + tt.input + `}]`,
|
|
)
|
|
|
|
_, _, _, _, toolCalls, _ := ExtractTextContent(content)
|
|
|
|
require.Len(t, toolCalls, 1)
|
|
assert.Empty(t, toolCalls[0].SkillName)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestInferCodexSkillNameResolvesRelativePathAgainstWorkdir(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
workdir := filepath.Dir(filepath.Dir(filepath.Dir(path)))
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "sed -n '1,220p' skills/index/SKILL.md")+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
)
|
|
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameWorkdirOverridesFallbackBase(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
workdir := filepath.Dir(filepath.Dir(filepath.Dir(path)))
|
|
|
|
got := inferCodexSkillNameWithBase(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "cat skills/index/SKILL.md")+
|
|
`,"workdir":`+quoteJSON(t, workdir)+`}`,
|
|
"/nonexistent/fallback",
|
|
)
|
|
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameUsesFallbackBaseWhenNoWorkdir(t *testing.T) {
|
|
path := writeTestSkill(t, "index", "data-analytics:index")
|
|
fallback := filepath.Dir(filepath.Dir(filepath.Dir(path)))
|
|
|
|
got := inferCodexSkillNameWithBase(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "cat skills/index/SKILL.md")+`}`,
|
|
fallback,
|
|
)
|
|
|
|
assert.Equal(t, "data-analytics:index", got)
|
|
}
|
|
|
|
func TestInferCodexSkillNameRelativePathNoWorkdirUsesParentFallback(t *testing.T) {
|
|
// Frontmatter name ("data-analytics:index") differs from the
|
|
// parent dir ("index"). Without a workdir the relative path
|
|
// cannot be resolved, so the frontmatter read is skipped and
|
|
// the parent-dir fallback is used instead of reading an
|
|
// unrelated SKILL.md under the process cwd.
|
|
writeTestSkill(t, "index", "data-analytics:index")
|
|
|
|
got := inferCodexSkillName(
|
|
"exec_command",
|
|
`{"cmd":`+quoteJSON(t, "cat skills/index/SKILL.md")+`}`,
|
|
)
|
|
|
|
assert.Equal(t, "index", got)
|
|
}
|
|
|
|
func TestExpandSkillHome(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
|
|
tests := []struct {
|
|
name string
|
|
in string
|
|
want string
|
|
}{
|
|
{"bare tilde", "~", home},
|
|
{"tilde slash", "~/.claude/skills/foo/SKILL.md",
|
|
filepath.Join(home, ".claude/skills/foo/SKILL.md")},
|
|
{"absolute unchanged", "/abs/SKILL.md", "/abs/SKILL.md"},
|
|
{"relative unchanged", "skills/foo/SKILL.md", "skills/foo/SKILL.md"},
|
|
{"tilde user not expanded", "~bob/SKILL.md", "~bob/SKILL.md"},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
assert.Equal(t, tt.want, expandSkillHome(tt.in))
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveSkillPath(t *testing.T) {
|
|
home, err := os.UserHomeDir()
|
|
require.NoError(t, err)
|
|
absSkillPath := filepath.Join(t.TempDir(), "a", "b", "SKILL.md")
|
|
baseDir := filepath.Join(t.TempDir(), "repo")
|
|
relativeSkillPath := filepath.Join("s", "SKILL.md")
|
|
|
|
tests := []struct {
|
|
name string
|
|
path string
|
|
baseDir string
|
|
wantPath string
|
|
wantReadable bool
|
|
}{
|
|
{"absolute", absSkillPath, "", absSkillPath, true},
|
|
{"tilde expands", "~/s/SKILL.md", "",
|
|
filepath.Join(home, "s/SKILL.md"), true},
|
|
{"relative joined to base", relativeSkillPath, baseDir,
|
|
filepath.Join(baseDir, relativeSkillPath), true},
|
|
{"relative no base", relativeSkillPath, "", relativeSkillPath, false},
|
|
{"relative with relative base", relativeSkillPath, "rel",
|
|
relativeSkillPath, false},
|
|
{"tilde base expands", relativeSkillPath, "~/repo",
|
|
filepath.Join(home, "repo", relativeSkillPath), true},
|
|
}
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
gotPath, gotReadable := resolveSkillPath(tt.path, tt.baseDir)
|
|
assert.Equal(t, tt.wantPath, gotPath)
|
|
assert.Equal(t, tt.wantReadable, gotReadable)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSkillNameFromFrontmatterBoundsReadSize(t *testing.T) {
|
|
dir := filepath.Join(t.TempDir(), "huge")
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
path := filepath.Join(dir, "SKILL.md")
|
|
// Frontmatter terminator sits past the read cap, so the name
|
|
// cannot be parsed and resolution falls back to the parent dir.
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
sb.WriteString("description: ")
|
|
sb.WriteString(strings.Repeat("x", maxSkillFrontmatterSize))
|
|
sb.WriteString("\nname: too-far\n---\n")
|
|
require.NoError(t, os.WriteFile(path, []byte(sb.String()), 0o644))
|
|
|
|
assert.Equal(t, "huge", skillNameFromPath(path, ""))
|
|
}
|
|
|
|
func writeTestSkill(t *testing.T, folder, name string) string {
|
|
t.Helper()
|
|
dir := filepath.Join(t.TempDir(), "skills", folder)
|
|
require.NoError(t, os.MkdirAll(dir, 0o755))
|
|
path := filepath.Join(dir, "SKILL.md")
|
|
content := "---\nname: " + name + "\ndescription: Test skill\n---\n"
|
|
require.NoError(t, os.WriteFile(path, []byte(content), 0o644))
|
|
return path
|
|
}
|
|
|
|
func splitTestLines(s string) []string {
|
|
return strings.Split(s, "\n")
|
|
}
|
|
|
|
func quoteJSON(t *testing.T, s string) string {
|
|
t.Helper()
|
|
return strconv.Quote(s)
|
|
}
|