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
538 lines
15 KiB
Go
538 lines
15 KiB
Go
package parser
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
"sync"
|
|
|
|
"github.com/tidwall/gjson"
|
|
)
|
|
|
|
// maxSkillFrontmatterSize bounds how much of a SKILL.md file is
|
|
// read when extracting the frontmatter name. Frontmatter sits at
|
|
// the top of the file and is tiny; the cap protects against a
|
|
// transcript pointing at a device or very large file.
|
|
const maxSkillFrontmatterSize = 64 << 10
|
|
|
|
var (
|
|
skillNameByPath sync.Map
|
|
skillPathRE = regexp.MustCompile(`(?:"([^"]*[/\\]SKILL\.md)"|'([^']*[/\\]SKILL\.md)'|(\S*[/\\]SKILL\.md)|(?:^|[\s"'])(SKILL\.md))`)
|
|
shellSegmentRE = regexp.MustCompile(`\s*(?:&&|\|\||;|&|\|)\s*`)
|
|
)
|
|
|
|
// searchValueFlags lists grep/rg flags (short and long) that consume
|
|
// the following token as their value, so it is neither the search
|
|
// pattern nor a file operand.
|
|
var searchValueFlags = map[string]bool{
|
|
"-A": true, "-B": true, "-C": true, "-m": true, "-d": true,
|
|
"-e": true, "-f": true, "-g": true, "-t": true, "-T": true,
|
|
"--after-context": true, "--before-context": true, "--context": true,
|
|
"--max-count": true, "--max-depth": true, "--regexp": true,
|
|
"--file": true, "--glob": true, "--type": true, "--type-not": true,
|
|
}
|
|
|
|
// inferToolSkillName infers a skill name for an assistant tool
|
|
// call by trying the read-file heuristic first, then the shell
|
|
// read-command heuristic. Used by both the Cursor JSONL and
|
|
// plain-text transcript paths so they stay in sync.
|
|
func inferToolSkillName(toolName, inputJSON string) string {
|
|
if name := inferCursorSkillName(toolName, inputJSON); name != "" {
|
|
return name
|
|
}
|
|
return inferCodexSkillName(toolName, inputJSON)
|
|
}
|
|
|
|
func inferCursorSkillName(toolName, inputJSON string) string {
|
|
if !isCursorSkillReadTool(toolName) {
|
|
return ""
|
|
}
|
|
return inferSkillNameFromJSONPaths(inputJSON)
|
|
}
|
|
|
|
func inferCodexSkillName(toolName, inputJSON string) string {
|
|
return inferCodexSkillNameWithBase(toolName, inputJSON, "")
|
|
}
|
|
|
|
// inferCodexSkillNameWithBase infers a Codex skill name, resolving
|
|
// relative SKILL.md paths against the tool call's own workdir/cwd
|
|
// hint when present, otherwise against fallbackBaseDir (typically
|
|
// the session working directory from session_meta).
|
|
func inferCodexSkillNameWithBase(toolName, inputJSON, fallbackBaseDir string) string {
|
|
if !isCodexShellTool(toolName) {
|
|
return ""
|
|
}
|
|
cmd := skillCommandFromInput(inputJSON)
|
|
if !strings.Contains(cmd, "SKILL.md") {
|
|
return ""
|
|
}
|
|
baseDir := skillBaseDirFromInput(inputJSON)
|
|
if baseDir == "" {
|
|
baseDir = fallbackBaseDir
|
|
}
|
|
for _, path := range skillPathsFromCommand(cmd) {
|
|
if name := skillNameFromPath(path, baseDir); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// skillPathsFromCommand extracts candidate SKILL.md paths from a shell
|
|
// command. Each control-operator-delimited segment is handled by its
|
|
// own leading verb: only read commands (cat/sed/... and grep/rg) yield
|
|
// paths, so SKILL.md in an unrelated segment (e.g. `echo SKILL.md`) is
|
|
// ignored, and a grep/rg search pattern is not mistaken for a file.
|
|
func skillPathsFromCommand(cmd string) []string {
|
|
var paths []string
|
|
for _, seg := range shellSegmentRE.Split(cmd, -1) {
|
|
paths = append(paths, skillPathsFromSegment(seg)...)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func skillPathsFromSegment(seg string) []string {
|
|
tokens := tokenizeCommand(seg)
|
|
if len(tokens) == 0 {
|
|
return nil
|
|
}
|
|
args := tokens[1:]
|
|
switch commandVerb(tokens[0]) {
|
|
case "grep", "rg":
|
|
return skillPathsFromSearchArgs(args)
|
|
case "sed":
|
|
// sed is a read verb, but -i / --in-place edits its operands in
|
|
// place, so a SKILL.md argument is written, not read.
|
|
if sedWritesInPlace(args) {
|
|
return nil
|
|
}
|
|
return skillFilePaths(args)
|
|
case "cat", "head", "tail", "less", "more":
|
|
return skillFilePaths(args)
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
// sedWritesInPlace reports whether a sed invocation edits its file
|
|
// operands in place, which makes a SKILL.md operand a write target. It
|
|
// matches --in-place[=suffix] and any single-dash short-flag cluster
|
|
// containing -i, whether alone (-i, -i.bak) or combined with other
|
|
// flags (-ni, -Ei); i is the only sed short option named i, so its
|
|
// presence in a short cluster always means in-place editing.
|
|
func sedWritesInPlace(args []string) bool {
|
|
for _, arg := range args {
|
|
switch {
|
|
case strings.HasPrefix(arg, "--in-place"):
|
|
return true
|
|
case strings.HasPrefix(arg, "--"):
|
|
// Some other long option (e.g. --posix); not in-place even
|
|
// though its name may contain the letter i.
|
|
case strings.HasPrefix(arg, "-") && strings.ContainsRune(arg, 'i'):
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
// tokenizeCommand splits a command segment into tokens on unquoted
|
|
// whitespace, honoring single and double quotes so a quoted argument
|
|
// stays one token (e.g. the multi-word pattern in `grep "a b" f`).
|
|
// Unlike a full shell lexer it treats backslash literally, preserving
|
|
// Windows paths such as C:\skills\foo\SKILL.md.
|
|
//
|
|
// An unquoted output redirection (>, >>, 2>, ...) is dropped together
|
|
// with its target token, because that target is written, not read.
|
|
// Doing this during tokenization, rather than over the token slice
|
|
// afterward, is what keeps a quoted ">" (a literal search pattern) from
|
|
// being mistaken for a redirect operator once the quotes are stripped.
|
|
func tokenizeCommand(seg string) []string {
|
|
var tokens []string
|
|
var cur strings.Builder
|
|
inToken := false
|
|
skipNext := false
|
|
var quote rune
|
|
flush := func() {
|
|
if !inToken {
|
|
return
|
|
}
|
|
if skipNext {
|
|
skipNext = false
|
|
} else {
|
|
tokens = append(tokens, cur.String())
|
|
}
|
|
cur.Reset()
|
|
inToken = false
|
|
}
|
|
for _, r := range seg {
|
|
switch {
|
|
case quote != 0:
|
|
if r == quote {
|
|
quote = 0
|
|
} else {
|
|
cur.WriteRune(r)
|
|
}
|
|
case r == '\'' || r == '"':
|
|
quote = r
|
|
inToken = true
|
|
case r == ' ' || r == '\t' || r == '\n' || r == '\r':
|
|
flush()
|
|
case r == '>':
|
|
// Unquoted '>' starts an output redirect: emit any preceding
|
|
// source token, then drop the redirect target that follows.
|
|
flush()
|
|
skipNext = true
|
|
default:
|
|
cur.WriteRune(r)
|
|
inToken = true
|
|
}
|
|
}
|
|
flush()
|
|
return tokens
|
|
}
|
|
|
|
// commandVerb returns the lowercased base name of a command token,
|
|
// dropping any leading path (e.g. "/usr/bin/grep" -> "grep").
|
|
func commandVerb(token string) string {
|
|
token = strings.ToLower(token)
|
|
if i := strings.LastIndexAny(token, `/\`); i >= 0 {
|
|
token = token[i+1:]
|
|
}
|
|
return token
|
|
}
|
|
|
|
// skillFilePaths returns the operand tokens that name a SKILL.md file.
|
|
// Each token is a whole shell operand, so a path is taken verbatim
|
|
// rather than re-scanned, preserving spaces inside quoted paths.
|
|
func skillFilePaths(args []string) []string {
|
|
var paths []string
|
|
for _, arg := range args {
|
|
if isSkillMarkdownPath(strings.TrimSpace(arg)) {
|
|
paths = append(paths, arg)
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
// skillPathsFromSearchArgs extracts SKILL.md file operands from grep/rg
|
|
// arguments. The search pattern (the value of -e/-f, or otherwise the
|
|
// first non-flag operand) is skipped so it is not read as a file;
|
|
// operands after it are files.
|
|
func skillPathsFromSearchArgs(args []string) []string {
|
|
var files []string
|
|
patternSeen := false
|
|
for i := 0; i < len(args); i++ {
|
|
arg := args[i]
|
|
if arg != "-" && strings.HasPrefix(arg, "-") {
|
|
flag, _, hasValue := strings.Cut(arg, "=")
|
|
if flag == "-e" || flag == "-f" ||
|
|
flag == "--regexp" || flag == "--file" {
|
|
patternSeen = true
|
|
}
|
|
if !hasValue && searchValueFlags[flag] {
|
|
i++
|
|
}
|
|
continue
|
|
}
|
|
if !patternSeen {
|
|
patternSeen = true
|
|
continue
|
|
}
|
|
files = append(files, arg)
|
|
}
|
|
return skillFilePaths(files)
|
|
}
|
|
|
|
func inferSkillNameFromJSONPaths(inputJSON string) string {
|
|
trimmed := strings.TrimSpace(inputJSON)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
baseDir := skillBaseDirFromInput(trimmed)
|
|
if !gjson.Valid(trimmed) {
|
|
for _, path := range skillPathsFromText(trimmed) {
|
|
if name := skillNameFromPath(path, baseDir); name != "" {
|
|
return name
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
var v any
|
|
if err := json.Unmarshal([]byte(trimmed), &v); err != nil {
|
|
return ""
|
|
}
|
|
var found string
|
|
var walk func(any)
|
|
walk = func(x any) {
|
|
if found != "" {
|
|
return
|
|
}
|
|
switch t := x.(type) {
|
|
case string:
|
|
// A JSON string value is already one unescaped path, so
|
|
// try it whole first — the free-text regex below splits
|
|
// on whitespace and would truncate paths with spaces.
|
|
if name := skillNameFromPath(t, baseDir); name != "" {
|
|
found = name
|
|
return
|
|
}
|
|
for _, path := range skillPathsFromText(t) {
|
|
if name := skillNameFromPath(path, baseDir); name != "" {
|
|
found = name
|
|
return
|
|
}
|
|
}
|
|
case []any:
|
|
for _, item := range t {
|
|
walk(item)
|
|
if found != "" {
|
|
return
|
|
}
|
|
}
|
|
case map[string]any:
|
|
for _, item := range t {
|
|
walk(item)
|
|
if found != "" {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}
|
|
walk(v)
|
|
return found
|
|
}
|
|
|
|
func isCursorSkillReadTool(toolName string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
|
case "read", "readfile", "read_file":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func isCodexShellTool(toolName string) bool {
|
|
switch strings.ToLower(strings.TrimSpace(toolName)) {
|
|
case "exec_command", "shell_command", "shell", "bash":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func skillCommandFromInput(inputJSON string) string {
|
|
trimmed := strings.TrimSpace(inputJSON)
|
|
if trimmed == "" {
|
|
return ""
|
|
}
|
|
if gjson.Valid(trimmed) {
|
|
g := gjson.Parse(trimmed)
|
|
for _, key := range []string{"cmd", "command", "script"} {
|
|
if s := strings.TrimSpace(g.Get(key).Str); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
}
|
|
return trimmed
|
|
}
|
|
|
|
func skillPathsFromText(text string) []string {
|
|
matches := skillPathRE.FindAllStringSubmatchIndex(text, -1)
|
|
if len(matches) == 0 {
|
|
return nil
|
|
}
|
|
paths := make([]string, 0, len(matches))
|
|
for _, m := range matches {
|
|
if !skillPathMatchHasBoundary(text, m[1]) {
|
|
continue
|
|
}
|
|
for i := 2; i < len(m); i += 2 {
|
|
if m[i] >= 0 && m[i+1] >= 0 {
|
|
paths = append(paths, text[m[i]:m[i+1]])
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return paths
|
|
}
|
|
|
|
func skillPathMatchHasBoundary(text string, end int) bool {
|
|
if end >= len(text) {
|
|
return true
|
|
}
|
|
switch text[end] {
|
|
case ' ', '\t', '\n', '\r', '"', '\'', ';', '&', '|', ')', '}', ']':
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// skillNameFromPath derives a skill name from a captured
|
|
// SKILL.md path. baseDir is the tool/session working directory
|
|
// (when known) used to resolve relative paths before reading
|
|
// frontmatter. A leading "~" is expanded to the user home dir.
|
|
// When the path stays relative because no base directory is
|
|
// available, the frontmatter read is skipped so an unrelated
|
|
// SKILL.md under the agentsview process cwd cannot be read by
|
|
// accident; the parent-directory name is used as a fallback.
|
|
// Paths carrying shell glob metacharacters are rejected: they come
|
|
// from discovery commands (e.g. "**/SKILL.md") rather than a
|
|
// concrete file, and would otherwise yield a bogus name like "**".
|
|
func skillNameFromPath(path, baseDir string) string {
|
|
path = strings.TrimSpace(path)
|
|
if path == "" || skillPathIsGlob(path) || !isSkillMarkdownPath(path) {
|
|
return ""
|
|
}
|
|
resolved, readable := resolveSkillPath(path, baseDir)
|
|
clean := filepath.Clean(resolved)
|
|
if cached, ok := skillNameByPath.Load(clean); ok {
|
|
return cached.(string)
|
|
}
|
|
|
|
var name string
|
|
if readable {
|
|
name = skillNameFromFrontmatter(clean)
|
|
}
|
|
if name == "" && !skillPathIsBare(path) {
|
|
// A path with a directory component names the skill via its
|
|
// folder even when the file is unreadable (e.g.
|
|
// "skills/foo/SKILL.md" -> "foo"). A bare "SKILL.md" carries
|
|
// no such signal, so it is only inferred from frontmatter of
|
|
// a resolvable file; this keeps a read command that merely
|
|
// references SKILL.md (e.g. `grep SKILL.md notes.txt`) from
|
|
// being miscounted as the working directory's name.
|
|
name = skillNameFromParentDir(clean)
|
|
}
|
|
skillNameByPath.Store(clean, name)
|
|
return name
|
|
}
|
|
|
|
// resolveSkillPath expands a leading "~" and joins relative
|
|
// paths against baseDir. It returns the path to use and whether
|
|
// that path is absolute (and therefore safe to read frontmatter
|
|
// from). A relative path with no usable base is returned as-is
|
|
// with readable=false.
|
|
func resolveSkillPath(path, baseDir string) (string, bool) {
|
|
path = expandSkillHome(path)
|
|
if filepath.IsAbs(path) {
|
|
return path, true
|
|
}
|
|
if baseDir = expandSkillHome(strings.TrimSpace(baseDir)); filepath.IsAbs(baseDir) {
|
|
return filepath.Join(baseDir, path), true
|
|
}
|
|
return path, false
|
|
}
|
|
|
|
// expandSkillHome replaces a leading "~" or "~/" with the
|
|
// current user's home directory. Other inputs are returned
|
|
// unchanged.
|
|
func expandSkillHome(path string) string {
|
|
if path != "~" && !strings.HasPrefix(path, "~/") {
|
|
return path
|
|
}
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return path
|
|
}
|
|
if path == "~" {
|
|
return home
|
|
}
|
|
return filepath.Join(home, path[len("~/"):])
|
|
}
|
|
|
|
// skillBaseDirFromInput extracts a working-directory hint from a
|
|
// tool call's input JSON (e.g. Codex exec_command "workdir").
|
|
func skillBaseDirFromInput(inputJSON string) string {
|
|
trimmed := strings.TrimSpace(inputJSON)
|
|
if trimmed == "" || !gjson.Valid(trimmed) {
|
|
return ""
|
|
}
|
|
g := gjson.Parse(trimmed)
|
|
for _, key := range []string{"workdir", "cwd", "working_directory"} {
|
|
if s := strings.TrimSpace(g.Get(key).Str); s != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func isSkillMarkdownPath(path string) bool {
|
|
normalized := strings.ReplaceAll(path, "\\", "/")
|
|
return strings.HasSuffix(normalized, "/SKILL.md") ||
|
|
normalized == "SKILL.md"
|
|
}
|
|
|
|
// skillPathIsGlob reports whether a captured path holds shell glob
|
|
// metacharacters, marking it as a discovery pattern rather than a
|
|
// concrete file to read.
|
|
func skillPathIsGlob(path string) bool {
|
|
return strings.ContainsAny(path, "*?[")
|
|
}
|
|
|
|
// skillPathIsBare reports whether a captured path is just "SKILL.md"
|
|
// with no directory component, so the surrounding folder name is not
|
|
// available as a skill-name fallback.
|
|
func skillPathIsBare(path string) bool {
|
|
return !strings.ContainsAny(path, `/\`)
|
|
}
|
|
|
|
func skillNameFromFrontmatter(path string) string {
|
|
// Lstat first so a symlink, FIFO, device, or directory captured
|
|
// from a transcript is rejected before open — opening a FIFO
|
|
// read-only would otherwise block waiting for a writer.
|
|
if li, err := os.Lstat(path); err != nil || !li.Mode().IsRegular() {
|
|
return ""
|
|
}
|
|
|
|
// Open with O_NOFOLLOW and re-check via Fstat to close the
|
|
// window between Lstat and open, then read only a bounded
|
|
// prefix so a crafted path cannot exhaust memory during sync.
|
|
f, err := openNoFollow(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
|
|
info, err := f.Stat()
|
|
if err != nil || !info.Mode().IsRegular() {
|
|
return ""
|
|
}
|
|
|
|
b, err := io.ReadAll(io.LimitReader(f, maxSkillFrontmatterSize))
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
text := strings.TrimPrefix(string(b), "\ufeff")
|
|
if !strings.HasPrefix(text, "---") {
|
|
return ""
|
|
}
|
|
lines := strings.Split(text, "\n")
|
|
for i := 1; i < len(lines); i++ {
|
|
line := strings.TrimSpace(lines[i])
|
|
if line == "---" || line == "..." {
|
|
return ""
|
|
}
|
|
key, value, ok := strings.Cut(line, ":")
|
|
if !ok || strings.TrimSpace(key) != "name" {
|
|
continue
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
value = strings.Trim(value, `"'`)
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func skillNameFromParentDir(path string) string {
|
|
dir := filepath.Base(filepath.Dir(path))
|
|
if dir == "." || dir == string(filepath.Separator) {
|
|
return ""
|
|
}
|
|
return dir
|
|
}
|