tools: add file_* and reasoning_think scripts; rename task tools
Add shell script implementations of file_read, file_write, file_edit, file_grep, file_glob, reasoning_think. Rename task_plan.sh/task_complete.py to task_plan/task_complete (shebang detection makes extensions unnecessary). Update submodules: core, 9p, skills. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
8b91f802b3
commit
4d9550e9cf
2
9p
2
9p
|
|
@ -1 +1 @@
|
||||||
Subproject commit f5c0b29522e1cc9e5eafac82fc0022b649bd3685
|
Subproject commit acaaef997179b6287b14a10d9df401b08a3be11f
|
||||||
2
core
2
core
|
|
@ -1 +1 @@
|
||||||
Subproject commit 9ba68ff35e2ef099ab2ec9a65cd45ff1b3d2ebd6
|
Subproject commit abe8ab76ccc4c66a58752fb43141b0b86bf1cdd6
|
||||||
2
skills
2
skills
|
|
@ -1 +1 @@
|
||||||
Subproject commit defc568841d72f316c640624bd8596d9027400df
|
Subproject commit afb0e64262300caeb4cf37b00fa1390a637cedc5
|
||||||
|
|
@ -0,0 +1,130 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# description: Replace text in a file; must read with file_read first
|
||||||
|
# Args: <file_path> <old_string> <new_string> [replace_all]
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import difflib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def marker_path(file_path):
|
||||||
|
h = hashlib.sha256(file_path.encode()).hexdigest()[:64]
|
||||||
|
sid = os.environ.get('OLLIE_SESSION_ID', '_')
|
||||||
|
return f"/tmp/ollie/{sid}/{h}"
|
||||||
|
|
||||||
|
def unified_diff(path, old, new):
|
||||||
|
old_lines = old.splitlines(keepends=True)
|
||||||
|
new_lines = new.splitlines(keepends=True)
|
||||||
|
return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
|
||||||
|
|
||||||
|
def find_exact(content, find):
|
||||||
|
return find if find in content else None
|
||||||
|
|
||||||
|
def find_ws_normalized(content, find):
|
||||||
|
nf = ' '.join(find.split())
|
||||||
|
find_lines = find.split('\n')
|
||||||
|
n = len(find_lines)
|
||||||
|
lines = content.split('\n')
|
||||||
|
for i in range(len(lines) - n + 1):
|
||||||
|
block = '\n'.join(lines[i:i+n])
|
||||||
|
if ' '.join(block.split()) == nf:
|
||||||
|
return block
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_indent_flexible(content, find):
|
||||||
|
def deindent(text):
|
||||||
|
ls = text.split('\n')
|
||||||
|
min_indent = float('inf')
|
||||||
|
for l in ls:
|
||||||
|
stripped = l.lstrip(' \t')
|
||||||
|
if stripped:
|
||||||
|
min_indent = min(min_indent, len(l) - len(stripped))
|
||||||
|
if min_indent == float('inf'):
|
||||||
|
return text
|
||||||
|
return '\n'.join(l[min_indent:] if len(l) > min_indent else l.lstrip(' \t') for l in ls)
|
||||||
|
nf = deindent(find)
|
||||||
|
find_lines = find.split('\n')
|
||||||
|
n = len(find_lines)
|
||||||
|
lines = content.split('\n')
|
||||||
|
for i in range(len(lines) - n + 1):
|
||||||
|
block = '\n'.join(lines[i:i+n])
|
||||||
|
if deindent(block) == nf:
|
||||||
|
return block
|
||||||
|
return None
|
||||||
|
|
||||||
|
def find_trimmed_boundary(content, find):
|
||||||
|
trimmed = find.strip()
|
||||||
|
if trimmed == find:
|
||||||
|
return None
|
||||||
|
if trimmed in content:
|
||||||
|
return trimmed
|
||||||
|
find_lines = find.split('\n')
|
||||||
|
n = len(find_lines)
|
||||||
|
lines = content.split('\n')
|
||||||
|
for i in range(len(lines) - n + 1):
|
||||||
|
block = '\n'.join(lines[i:i+n])
|
||||||
|
if block.strip() == trimmed:
|
||||||
|
return block
|
||||||
|
return None
|
||||||
|
|
||||||
|
def robust_replace(content, old_string, new_string, replace_all):
|
||||||
|
match = (find_exact(content, old_string) or
|
||||||
|
find_ws_normalized(content, old_string) or
|
||||||
|
find_indent_flexible(content, old_string) or
|
||||||
|
find_trimmed_boundary(content, old_string))
|
||||||
|
if match is None:
|
||||||
|
return None, "old_string not found in file"
|
||||||
|
if replace_all:
|
||||||
|
return content.replace(match, new_string), None
|
||||||
|
n = content.count(match)
|
||||||
|
if n > 1:
|
||||||
|
return None, f"old_string is not unique ({n} occurrences); use replace_all=true or provide more context"
|
||||||
|
return content.replace(match, new_string, 1), None
|
||||||
|
|
||||||
|
if len(sys.argv) < 4:
|
||||||
|
print("error: file_path, old_string, and new_string are required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
old_string = sys.argv[2]
|
||||||
|
new_string = sys.argv[3]
|
||||||
|
replace_all = len(sys.argv) > 4 and sys.argv[4].lower() in ('true', '1', 'yes')
|
||||||
|
|
||||||
|
if not os.path.isabs(file_path):
|
||||||
|
print(f"error: path must be absolute, got: {file_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
if old_string == new_string:
|
||||||
|
print("error: old_string and new_string must be different")
|
||||||
|
sys.exit(1)
|
||||||
|
if not os.path.exists(file_path):
|
||||||
|
print(f"error: file not found: {file_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
marker = marker_path(file_path)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
print(f"error: {file_path} must be read first with file_read")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(file_path, 'r', errors='replace') as f:
|
||||||
|
original = f.read()
|
||||||
|
|
||||||
|
new_content, err = robust_replace(original, old_string, new_string, replace_all)
|
||||||
|
if err:
|
||||||
|
print(f"error: {err}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tmp = tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(file_path) or '.', delete=False)
|
||||||
|
try:
|
||||||
|
tmp.write(new_content)
|
||||||
|
tmp.close()
|
||||||
|
os.replace(tmp.name, file_path)
|
||||||
|
except Exception as e:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
print(f"error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.unlink(marker)
|
||||||
|
|
||||||
|
diff = unified_diff(file_path, original, new_content)
|
||||||
|
print(diff if diff else "(no changes)", end='' if diff else '\n')
|
||||||
|
|
@ -0,0 +1,14 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# description: Find files by name/path pattern, sorted by modification time (newest first)
|
||||||
|
# Args: <pattern> [path]
|
||||||
|
|
||||||
|
pattern="$1"
|
||||||
|
search_path="${2:-$PWD}"
|
||||||
|
|
||||||
|
[ -z "$pattern" ] && echo "error: pattern is required" && exit 1
|
||||||
|
|
||||||
|
# collect matches and sort by mtime descending
|
||||||
|
rg --no-config --files -g "$pattern" "$search_path" 2>/dev/null \
|
||||||
|
| xargs -d '\n' stat --printf '%Y\t%n\n' 2>/dev/null \
|
||||||
|
| sort -rn \
|
||||||
|
| cut -f2-
|
||||||
|
|
@ -0,0 +1,88 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# description: Search file contents using ripgrep
|
||||||
|
# Args: <pattern> [--path=<dir>] [--mode=files_with_matches|content|count] [--glob=<pattern>] [--type=<type>] [-i] [-n] [-A <n>] [-B <n>] [-C <n>] [--multiline] [--head=<n>] [--offset=<n>]
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import subprocess
|
||||||
|
import argparse
|
||||||
|
|
||||||
|
parser = argparse.ArgumentParser(prog='file_grep', add_help=False)
|
||||||
|
parser.add_argument('pattern')
|
||||||
|
parser.add_argument('--path', default='')
|
||||||
|
parser.add_argument('--mode', default='files_with_matches',
|
||||||
|
choices=['files_with_matches', 'content', 'count'])
|
||||||
|
parser.add_argument('--glob', default='')
|
||||||
|
parser.add_argument('--type', default='')
|
||||||
|
parser.add_argument('-i', action='store_true')
|
||||||
|
parser.add_argument('-n', action='store_true', default=True)
|
||||||
|
parser.add_argument('-A', type=int, default=None)
|
||||||
|
parser.add_argument('-B', type=int, default=None)
|
||||||
|
parser.add_argument('-C', type=int, default=None)
|
||||||
|
parser.add_argument('--multiline', action='store_true')
|
||||||
|
parser.add_argument('--head', type=int, default=0)
|
||||||
|
parser.add_argument('--offset', type=int, default=0)
|
||||||
|
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
rg = 'rg'
|
||||||
|
search_path = args.path or os.getcwd()
|
||||||
|
|
||||||
|
cmd = ['rg', '--no-config', '--color=never']
|
||||||
|
|
||||||
|
if args.mode == 'files_with_matches':
|
||||||
|
cmd.append('-l')
|
||||||
|
elif args.mode == 'count':
|
||||||
|
cmd.append('--count')
|
||||||
|
elif args.mode == 'content':
|
||||||
|
if args.n:
|
||||||
|
cmd.append('-n')
|
||||||
|
|
||||||
|
if args.i:
|
||||||
|
cmd.append('-i')
|
||||||
|
if args.multiline:
|
||||||
|
cmd += ['-U', '--multiline-dotall']
|
||||||
|
if args.glob:
|
||||||
|
cmd += ['-g', args.glob]
|
||||||
|
if args.type:
|
||||||
|
cmd += ['-t', args.type]
|
||||||
|
if args.C is not None:
|
||||||
|
cmd += ['-C', str(args.C)]
|
||||||
|
if args.A is not None:
|
||||||
|
cmd += ['-A', str(args.A)]
|
||||||
|
if args.B is not None:
|
||||||
|
cmd += ['-B', str(args.B)]
|
||||||
|
|
||||||
|
cmd += ['-e', args.pattern, search_path]
|
||||||
|
|
||||||
|
result = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if result.returncode == 1:
|
||||||
|
sys.exit(0) # no matches
|
||||||
|
if result.returncode > 1:
|
||||||
|
print(result.stderr.strip() or f"rg exited with {result.returncode}", file=sys.stderr)
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
lines = result.stdout.splitlines()
|
||||||
|
|
||||||
|
if args.offset:
|
||||||
|
lines = lines[args.offset:]
|
||||||
|
if args.head:
|
||||||
|
lines = lines[:args.head]
|
||||||
|
|
||||||
|
if args.mode == 'content':
|
||||||
|
formatted = []
|
||||||
|
for line in lines:
|
||||||
|
if line == '--':
|
||||||
|
formatted.append(line)
|
||||||
|
continue
|
||||||
|
# file:lineno:content or file-lineno-content (context lines)
|
||||||
|
for sep in (':', '-'):
|
||||||
|
parts = line.split(sep, 2)
|
||||||
|
if len(parts) == 3 and parts[1].isdigit():
|
||||||
|
formatted.append(f"{parts[0]}:{parts[1]:>5}| {parts[2]}")
|
||||||
|
break
|
||||||
|
else:
|
||||||
|
formatted.append(line)
|
||||||
|
lines = formatted
|
||||||
|
|
||||||
|
print('\n'.join(lines))
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# description: Read a file from the local filesystem with optional offset and line limit
|
||||||
|
# Args: <file_path> [offset] [limit]
|
||||||
|
|
||||||
|
file_path="$1"
|
||||||
|
offset="${2:-1}"
|
||||||
|
limit="${3:-2000}"
|
||||||
|
max_line_len=2000
|
||||||
|
max_size=$((8 * 1024 * 1024))
|
||||||
|
|
||||||
|
[ -z "$file_path" ] && echo "error: file_path is required" && exit 1
|
||||||
|
[[ "$file_path" != /* ]] && echo "error: path must be absolute, got: $file_path" && exit 1
|
||||||
|
[ ! -e "$file_path" ] && echo "error: file not found: $file_path" && exit 1
|
||||||
|
[ ! -f "$file_path" ] && echo "error: not a regular file: $file_path" && exit 1
|
||||||
|
|
||||||
|
size=$(wc -c < "$file_path")
|
||||||
|
[ "$size" -gt "$max_size" ] && echo "error: file too large ($size bytes, max $max_size)" && exit 1
|
||||||
|
|
||||||
|
content=$(awk -v off="$offset" -v lim="$limit" -v maxlen="$max_line_len" '
|
||||||
|
NR >= off && NR < off+lim {
|
||||||
|
if (length($0) > maxlen) print substr($0, 1, maxlen) "..."
|
||||||
|
else print
|
||||||
|
}
|
||||||
|
NR >= off+lim { exit }
|
||||||
|
' "$file_path")
|
||||||
|
|
||||||
|
if [ -z "$content" ]; then
|
||||||
|
echo "(empty file)"
|
||||||
|
else
|
||||||
|
printf '%s\n' "$content"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# create isread marker
|
||||||
|
mkdir -p "/tmp/ollie/$OLLIE_SESSION_ID"
|
||||||
|
marker="/tmp/ollie/$OLLIE_SESSION_ID/$(printf '%s' "$file_path" | sha256sum | cut -c1-64)"
|
||||||
|
touch "$marker"
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# description: Write or overwrite a file; must read with file_read first
|
||||||
|
# Args: <file_path> <content>
|
||||||
|
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import hashlib
|
||||||
|
import difflib
|
||||||
|
import tempfile
|
||||||
|
|
||||||
|
def marker_path(file_path):
|
||||||
|
h = hashlib.sha256(file_path.encode()).hexdigest()[:64]
|
||||||
|
sid = os.environ.get('OLLIE_SESSION_ID', '_')
|
||||||
|
return f"/tmp/ollie/{sid}/{h}"
|
||||||
|
|
||||||
|
def unified_diff(path, old, new):
|
||||||
|
old_lines = old.splitlines(keepends=True)
|
||||||
|
new_lines = new.splitlines(keepends=True)
|
||||||
|
return ''.join(difflib.unified_diff(old_lines, new_lines, fromfile=path, tofile=path))
|
||||||
|
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("error: file_path and content are required")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
file_path = sys.argv[1]
|
||||||
|
content = sys.argv[2]
|
||||||
|
|
||||||
|
if not os.path.isabs(file_path):
|
||||||
|
print(f"error: path must be absolute, got: {file_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
marker = marker_path(file_path)
|
||||||
|
if not os.path.exists(marker):
|
||||||
|
print(f"error: {file_path} must be read first with file_read")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
exists = os.path.exists(file_path)
|
||||||
|
old_content = ""
|
||||||
|
if exists:
|
||||||
|
with open(file_path, 'r', errors='replace') as f:
|
||||||
|
old_content = f.read()
|
||||||
|
|
||||||
|
os.makedirs(os.path.dirname(file_path) or '.', exist_ok=True)
|
||||||
|
tmp = tempfile.NamedTemporaryFile(mode='w', dir=os.path.dirname(file_path) or '.', delete=False)
|
||||||
|
try:
|
||||||
|
tmp.write(content)
|
||||||
|
tmp.close()
|
||||||
|
os.replace(tmp.name, file_path)
|
||||||
|
except Exception as e:
|
||||||
|
os.unlink(tmp.name)
|
||||||
|
print(f"error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
os.unlink(marker)
|
||||||
|
|
||||||
|
if not exists:
|
||||||
|
for line in content.splitlines():
|
||||||
|
print(f"+{line}")
|
||||||
|
else:
|
||||||
|
diff = unified_diff(file_path, old_content, content)
|
||||||
|
print(diff if diff else "(no changes)", end='' if diff else '\n')
|
||||||
|
|
@ -0,0 +1,6 @@
|
||||||
|
#!/usr/bin/env bash
|
||||||
|
# description: Internal reasoning scratchpad; think through complex problems before acting
|
||||||
|
# Args: <thought>
|
||||||
|
|
||||||
|
[ -z "$*" ] && echo "error: thought is required" && exit 1
|
||||||
|
exit 0
|
||||||
Loading…
Reference in New Issue