mirror of
https://github.com/instructkr/claw-code.git
synced 2026-04-05 16:39:04 +08:00
Compare commits
83 Commits
02ed5bc333
...
rcc/cost
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ba60be514 | ||
|
|
d6341d54c1 | ||
|
|
863958b94c | ||
|
|
9455280f24 | ||
|
|
c92403994d | ||
|
|
8d4a739c05 | ||
|
|
e2f061fd08 | ||
|
|
c139fe9bee | ||
|
|
6a7cea810e | ||
|
|
842abcfe85 | ||
|
|
807e29c8a1 | ||
|
|
e84133527e | ||
|
|
32e89df631 | ||
|
|
1f8cfbce38 | ||
|
|
1e5002b521 | ||
|
|
d5d99af2d0 | ||
|
|
5180cc5658 | ||
|
|
964cc25821 | ||
|
|
8ab16276bf | ||
|
|
b8dadbfbf5 | ||
|
|
46581fe442 | ||
|
|
92f33c75c0 | ||
|
|
5f46fec5ad | ||
|
|
771f716625 | ||
|
|
d3e41be7f1 | ||
|
|
691ea57832 | ||
|
|
4d65f5c1a2 | ||
|
|
8b6bf4cee7 | ||
|
|
647b407444 | ||
|
|
5eeb7be4cc | ||
|
|
f8bc5cf264 | ||
|
|
346ea0b91b | ||
|
|
6076041f19 | ||
|
|
9f3be03463 | ||
|
|
c30bb8aa59 | ||
|
|
88cd2e31df | ||
|
|
1adf11d572 | ||
|
|
9b0c9b5739 | ||
|
|
cf8d5a8389 | ||
|
|
cba31c4f95 | ||
|
|
fa30059790 | ||
|
|
d9c5f60598 | ||
|
|
9b7fe16edb | ||
|
|
c8f95cd72b | ||
|
|
66dde1b74a | ||
|
|
99b78d6ea4 | ||
|
|
3db3dfa60d | ||
|
|
0ac188caad | ||
|
|
0794e76f07 | ||
|
|
b510387045 | ||
|
|
6e378185e9 | ||
|
|
019e9900ed | ||
|
|
67423d005a | ||
|
|
4db21e9595 | ||
|
|
daf98cc750 | ||
|
|
2ad2ec087f | ||
|
|
0346b7dd3a | ||
|
|
a8f5da6427 | ||
|
|
c996eb7b1b | ||
|
|
14757e0780 | ||
|
|
188c35f8a6 | ||
|
|
2de0b0e2af | ||
|
|
c024d8b21f | ||
|
|
a66c301fa3 | ||
|
|
321a1a681a | ||
|
|
2d1cade31b | ||
|
|
6fe404329d | ||
|
|
add5513ac5 | ||
|
|
8465b6923b | ||
|
|
32981ffa28 | ||
|
|
cb24430c56 | ||
|
|
071045f556 | ||
|
|
a96bb6c60f | ||
|
|
d6a814258c | ||
|
|
4bae5ee132 | ||
|
|
619ae71866 | ||
|
|
6037aaeff1 | ||
|
|
5b106b840d | ||
|
|
4586764a0e | ||
|
|
3faf8dd365 | ||
|
|
450556559a | ||
|
|
44e4758078 | ||
|
|
01bf54ad15 |
1
.claude/sessions/session-1774998936453.json
Normal file
1
.claude/sessions/session-1774998936453.json
Normal file
@@ -0,0 +1 @@
|
||||
{"messages":[],"version":1}
|
||||
1
.claude/sessions/session-1774998994373.json
Normal file
1
.claude/sessions/session-1774998994373.json
Normal file
@@ -0,0 +1 @@
|
||||
{"messages":[{"blocks":[{"text":"Say hello in one sentence","type":"text"}],"role":"user"},{"blocks":[{"text":"Hello! I'm Claude, an AI assistant ready to help you with software engineering tasks, code analysis, debugging, or any other programming challenges you might have.","type":"text"}],"role":"assistant","usage":{"cache_creation_input_tokens":0,"cache_read_input_tokens":0,"input_tokens":11,"output_tokens":32}}],"version":1}
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +1,4 @@
|
||||
__pycache__/
|
||||
archive/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "00083e63395f4f3bb24fd6e7ed26439d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "053db140f7694d1abfb52c96e62fdede",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "05a9bc9a33c24f80b7e6540d4306d59d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "08dcdbf3d76b4ed7898c27c1a45dde73",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "0ca1cdd3176041a1916729d76effe10d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "11fb4a2aa69b460ba53031ea6643969f",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "156dd69bbb3142e687d1a56cb97633da",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "1a9711e7305f42a8bc30d8ade03a221d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "1b2fe9ba9b804b2896e33ddc7c7d91ae",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "1d07428c6df44c859e6056ef13aa422d",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "1e068ad0e9234d8b9e85735556ad436c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "2763324a1d7e4832a12f14e7108ce552",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "27d5203bdda3480681732c1f94291599",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "28510de9575e40919ddb607d82e95cc1",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "2e574cba82bb43eda5aafe7ae364210b",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "317de978222e4e6c93f7e5430d44d130",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "338275156d1d405f8c627be621b3191e",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "3b93d8e47dff4a0d90782d19e33a81b6",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "3c60859387044f7b8111557a4b252745",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "439e9f7d2bfa41cbbfed6edf44074311",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "4c75b6e3a46b4f599e32eaf147d92c4d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "4e4aae9acdf945839798f37e01c04f1b",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "5230052e7d2749be9cf318de519d9c42",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "575c96ef2c1f45cf9deb85b06c552bde",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "577b74a297fd4fed956a78b5debd8025",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "59d15a4390b648b9b19c6ebae1c5d2d8",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "5cb2ee43a1c547f79ffbc74cb311247c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "609a20c55c6d47c282ddc00d0c3b6dfb",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "663f870db32944b3a0d2b863c19a5708",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "6d0759c31bd942b6b86f3969ba8bbf28",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "6dd0b48373c64449b756fd53425b4031",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "6f8b4a9d5a0e47f79bd732f61a6eb543",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "6f9f4e28691f46dab19d99d78127c94e",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "72b9dcbd91054fd78d7cfdcf09de3c0b",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "76411759fe1e42e5bc5ac4279a68f700",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "776aeb73811d4868ad20b6562eba6502",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "7afd286cb9d14d62b56ba7ff86bcc08b",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "7e96b6c76ef64983a4ba3be162bd7cdd",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "800a5672a570499883260b597ed174bd",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "834e4208013d4505a6957901a0ff151c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "884dc57a7bba40d59a1bcc1dd80cb28b",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "8d517af648b840928016d4cc613f1133",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "8d84e1795c9d414190bc1a9c719bc63e",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "90d94293814443edb376ba3b68568c46",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "9155bcf5da0348dbac0c2374ef60c9e5",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "91d1f071ab8148b28b7e29513cc1804f",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "933e1cd8e24042d1968959aa2c67f3d6",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "94d871d03ed6410a81a8339ecba4f3dd",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "994767f1199c44d8b2052e758b4d4d3c",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "9bd50e453f24444d970dedddf3c85027",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "9d8eecbecd7d452697d2e8b5be415bd4",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "9f87d3fe67bb4aebbe0effd034382685",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "a0e9fbca75ab44e7b89022156f18f60a",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "a1d0491f8c92424fb2a2c3e2333f39d0",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "a277471dc4cf4d7f86ff065fd77ccb88",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "a3b47cec34694898919020369ab6af13",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "a6e9e4b6daac4f5a82e366cb12313eee",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "ad7f38d318df458bb1f0f0492fa71f02",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "b3b5eb702163461bb0f0dd43a82528d8",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "b4829661195449c1af0c37e8c87ce0a5",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "b4f0e18929d54d948ad29bd1dd43a85d",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "bd2f9891b77d4ab29b022f6d830c6234",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "cbe249de5950459da16440988e2ddc54",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "db0aea45e8984d57898e91803bdcdc7c",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "dbd0d1c8f734444a8ed02f77f3d9fc1a",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "e8fff9feb2ae4d8ca94575ed8fa5a03f",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "e9433328bba545d8ac94e2bbac488aec",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "f074c37ae3b84a959da394aec7ec3c68",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "f2efb0de41f949859b57854e58efad70",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "f48004afd90f41d081a6129e96ad7651",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "f5def6f008f64a918bb0f5b0d6ec3db6",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "f5efe1bbcb124e3191e4417becbe1f81",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "f689195c4cf94eb998a461da808075a3",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
{
|
||||
"session_id": "f77644ede86245eba5198322de05e4a4",
|
||||
"messages": [
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 3,
|
||||
"output_tokens": 13
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"session_id": "ffbf6be4ed074ead98bcf18e7710e6a1",
|
||||
"messages": [
|
||||
"review MCP tool",
|
||||
"review MCP tool"
|
||||
],
|
||||
"input_tokens": 6,
|
||||
"output_tokens": 32
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
# Is legal the same as legitimate: AI reimplementation and the erosion of copyleft
|
||||
|
||||
- **Date:** March 9, 2026
|
||||
- **Author:** Hong Minhee
|
||||
- **Source context:** _Hong Minhee on Things_ (English / 日本語 / 朝鮮語 (國漢文) / 한국어 (한글))
|
||||
- **Archive note:** This copy was normalized from user-provided text for this repository's research/archive context. Site navigation/footer language links were converted into metadata.
|
||||
|
||||
Last week, Dan Blanchard, the maintainer of chardet—a Python library for detecting text encodings used by roughly 130 million projects a month—released a new version. Version 7.0 is 48 times faster than its predecessor, supports multiple cores, and was redesigned from the ground up. Anthropic's Claude is listed as a contributor. The license changed from LGPL to MIT.
|
||||
|
||||
Blanchard's account is that he never looked at the existing source code directly. He fed only the API and the test suite to Claude and asked it to reimplement the library from scratch. The resulting code shares less than 1.3% similarity with any prior version, as measured by JPlag. His conclusion: this is an independent new work, and he is under no obligation to carry forward the LGPL. Mark Pilgrim, the library's original author, opened a GitHub issue to object. The LGPL requires that modifications be distributed under the same license, and a reimplementation produced with ample exposure to the original codebase cannot, in Pilgrim's view, pass as a clean-room effort.
|
||||
|
||||
The dispute drew responses from two prominent figures in the open source world. Armin Ronacher, the creator of Flask, welcomed the relicensing. Salvatore Sanfilippo (antirez), the creator of Redis, published a broader defense of AI reimplementation, grounding it in copyright law and the history of the GNU project. Both conclude, by different routes, that what Blanchard did is legitimate. I respect both writers, and I think both are wrong—or more precisely, both are evading the question that actually matters.
|
||||
|
||||
That question is this: does legal mean legitimate? Neither piece answers it. Both move from “this is legally permissible” to “this is therefore fine,” without pausing at the gap between those two claims. Law sets a floor; clearing it does not mean the conduct is right. That gap is where this essay begins.
|
||||
|
||||
## The analogy points the wrong way
|
||||
|
||||
Antirez builds his case on history. When the GNU project reimplemented the UNIX userspace, it was lawful. So was Linux. Copyright law prohibits copying “protected expressions”—the actual code, its structure, its specific mechanisms—but it does not protect ideas or behavior. AI-assisted reimplementation occupies the same legal ground. Therefore, it is lawful.
|
||||
|
||||
The legal analysis is largely correct, and I am not disputing it. The problem lies in what antirez does next: he presents the legal conclusion as if it were also a social one, and uses a historical analogy that, examined more carefully, argues against his own position.
|
||||
|
||||
When GNU reimplemented the UNIX userspace, the vector ran from proprietary to free. Stallman was using the limits of copyright law to turn proprietary software into free software. The ethical force of that project did not come from its legal permissibility—it came from the direction it was moving, from the fact that it was expanding the commons. That is why people cheered.
|
||||
|
||||
The vector in the chardet case runs the other way. Software protected by a copyleft license—one that guarantees users the right to study, modify, and redistribute derivative works under the same terms—has been reimplemented under a permissive license that carries no such guarantee. This is not a reimplementation that expands the commons. It is one that removes the fencing that protected the commons. Derivative works built on chardet 7.0 are under no obligation to share their source code. That obligation, which applied to a library downloaded 130 million times a month, is now gone.
|
||||
|
||||
Antirez does not address this directional difference. He invokes the GNU precedent, but that precedent is a counterexample to his conclusion, not a supporting one.
|
||||
|
||||
## Does the GPL work against sharing?
|
||||
|
||||
Ronacher's argument is different. He discloses upfront that he has a stake in the outcome: “I personally have a horse in the race here because I too wanted chardet to be under a non-GPL license for many years. So consider me a very biased person in that regard.” He goes on to write that he considers “the GPL to run against that spirit by restricting what can be done with it”—the spirit being that society is better off when we share.
|
||||
|
||||
This claim rests on a fundamental misreading of what the GPL does.
|
||||
|
||||
Start with what the GPL actually prohibits. It does not prohibit keeping source code private. It imposes no constraint on privately modifying GPL software and using it yourself. The GPL's conditions are triggered only by distribution. If you distribute modified code, or offer it as a networked service, you must make the source available under the same terms. This is not a restriction on sharing. It is a condition placed on sharing: if you share, you must share in kind.
|
||||
|
||||
The requirement that improvements be returned to the commons is not a mechanism that suppresses sharing. It is a mechanism that makes sharing recursive and self-reinforcing. The claim that imposing contribution obligations on users of a commons undermines sharing culture does not hold together logically.
|
||||
|
||||
The contrast with the MIT license clarifies the point. Under MIT, anyone may take code, improve it, and close it off into a proprietary product. You can receive from the commons without giving back. If Ronacher calls this structure “more share-friendly,” he is using a concept of sharing with a specific directionality built in: sharing flows toward whoever has more capital and more engineers to take advantage of it.
|
||||
|
||||
The historical record bears this out. In the 1990s, companies routinely absorbed GPL code into proprietary products—not because they had chosen permissive licenses, but because copyleft enforcement was slack. The strengthening of copyleft mechanisms closed that gap. For individual developers and small projects without the resources to compete on anything but reciprocity, copyleft was what made the exchange approximately fair.
|
||||
|
||||
The creator of Flask knows this distinction. If he elides it anyway, the argument is not naïve—it is convenient.
|
||||
|
||||
## A self-refuting example
|
||||
|
||||
The most interesting moment in Ronacher's piece is not the argument but a detail he mentions in passing: Vercel reimplemented GNU Bash using AI and published it, then got visibly upset when Cloudflare reimplemented Next.js the same way.
|
||||
|
||||
Ronacher notes this as an irony and moves on. But the irony cuts deeper than he lets on. Next.js is MIT licensed. Cloudflare's vinext did not violate any license—it did exactly what Ronacher calls a contribution to the culture of openness, applied to a permissively licensed codebase. Vercel's reaction had nothing to do with license infringement; it was purely competitive and territorial. The implicit position is: reimplementing GPL software as MIT is a victory for sharing, but having our own MIT software reimplemented by a competitor is cause for outrage. This is what the claim that permissive licensing is “more share-friendly” than copyleft looks like in practice. The spirit of sharing, it turns out, runs in one direction only: outward from oneself.
|
||||
|
||||
Ronacher registers the contradiction and does not stop. “This development plays into my worldview,” he writes. When you present evidence that cuts against your own position, acknowledge it, and then proceed to your original conclusion unchanged, that is a signal that the conclusion preceded the argument.
|
||||
|
||||
## Legality and social legitimacy are different registers
|
||||
|
||||
Back to the question posed at the start. Is legal the same as legitimate?
|
||||
|
||||
Antirez closes his careful legal analysis as though it settles the matter. Ronacher acknowledges that “there is an obvious moral question here, but that isn't necessarily what I'm interested in.” Both pieces treat legal permissibility as a proxy for social legitimacy. But law only says what conduct it will not prevent—it does not certify that conduct as right. Aggressive tax minimization that never crosses into illegality may still be widely regarded as antisocial. A pharmaceutical company that legally acquires a patent on a long-generic drug and raises the price a hundredfold has done something legal, but that does not make it fine. Legality is a necessary condition; it is not a sufficient one.
|
||||
|
||||
In the chardet case, the distinction is sharper still. What the LGPL protected was not Blanchard's labor alone. It was a social compact agreed to by everyone who contributed to the library over twelve years. The terms of that compact were: if you take this and build on it, you share back under the same terms. This compact operated as a legal instrument, yes, but it was also the foundation of trust that made contribution rational. The fact that a reimplementation may qualify legally as a new work, and the fact that it breaks faith with the original contributors, are separate questions. If a court eventually rules in Blanchard's favor, that ruling will tell us what the law permits. It will not tell us that the act was right.
|
||||
|
||||
Zoë Kooyman, executive director of the FSF, put it plainly: “Refusing to grant others the rights you yourself received as a user is highly antisocial, no matter what method you use.”
|
||||
|
||||
## Whose perspective is the default?
|
||||
|
||||
Reading this debate, I keep returning to a question about position. From where are these two writers looking at the situation?
|
||||
|
||||
Antirez created Redis. Ronacher created Flask. Both are figures at the center of the open source ecosystem, with large audiences and well-established reputations. For them, falling costs of AI reimplementation means something specific: it is easier to reimplement things they want in a different form. Ronacher says explicitly that he had begun reimplementing GNU Readline precisely because of its copyleft terms.
|
||||
|
||||
For the people who have spent years contributing to a library like chardet, the same shift in costs means something else entirely: the copyleft protection around their contributions can be removed. The two writers are speaking from the former position to people in the latter, telling them that this was always lawful, that historical precedent supports it, and that the appropriate response is adaptation.
|
||||
|
||||
When positional asymmetry of this kind is ignored, and the argument is presented as universal analysis, what you get is not analysis but rationalization. Both writers arrive at conclusions that align precisely with their own interests. Readers should hold that fact in mind.
|
||||
|
||||
## What this fight points toward
|
||||
|
||||
Bruce Perens, who wrote the original Open Source Definition, told The Register: “The entire economics of software development are dead, gone, over, kaput!” He meant it as an alarm. Antirez, from a similar assessment of the situation, draws the conclusion: adapt. Ronacher says he finds the direction exciting.
|
||||
|
||||
None of the three responses addresses the central question. When copyleft becomes technically easier to circumvent, does that make it less necessary, or more?
|
||||
|
||||
I think more. What the GPL protected was not the scarcity of code but the freedom of users. The fact that producing code has become cheaper does not make it acceptable to use that code as a vehicle for eroding freedom. If anything, as the friction of reimplementation disappears, so does the friction of stripping copyleft from anything left exposed. The erosion of enforcement capacity is a legal problem. It does not touch the underlying normative judgment.
|
||||
|
||||
That judgment is this: those who take from the commons owe something back to the commons. The principle does not change depending on whether a reimplementation takes five years or five days. No court ruling on AI-generated code will alter its social weight.
|
||||
|
||||
This is where law and community norms diverge. Law is made slowly, after the fact, reflecting existing power arrangements. The norms that open source communities built over decades did not wait for court approval. People chose the GPL when the law offered them no guarantee of its enforcement, because it expressed the values of the communities they wanted to belong to. Those values do not expire when the law changes.
|
||||
|
||||
In previous writing, I argued for a training copyleft (TGPL) as the next step in this line of development. The chardet situation suggests the argument has to go further: to a specification copyleft covering the layer below source code. If source code can now be generated from a specification, the specification is where the essential intellectual content of a GPL project resides. Blanchard's own claim—that he worked only from the test suite and API without reading the source—is, paradoxically, an argument for protecting that test suite and API specification under copyleft terms.
|
||||
|
||||
The history of the GPL is the history of licensing tools evolving in response to new forms of exploitation: GPLv2 to GPLv3, then AGPL. What drove each evolution was not a court ruling but a community reaching a value judgment first and then seeking legal instruments to express it. The same sequence is available now. Whatever courts eventually decide about AI reimplementation, the question we need to answer first is not a legal one. It is a social one. Do those who take from the commons owe something back? I think they do. That judgment does not require a verdict.
|
||||
|
||||
What makes the pieces by antirez and Ronacher worth reading is not that they are right. It is that they make visible, with unusual clarity, what they are choosing not to see. When legality is used as a substitute for a value judgment, the question that actually matters gets buried in the footnotes of a law it has already outgrown.
|
||||
370
README.md
370
README.md
@@ -1,261 +1,191 @@
|
||||
# Claude Code — Leaked Source (2026-03-31)
|
||||
# Rewriting Project Claw Code
|
||||
|
||||
> **On March 31, 2026, the full source code of Anthropic's Claude Code CLI was leaked** via a `.map` file exposed in their npm registry.
|
||||
<p align="center">
|
||||
<strong>⭐ The fastest repo in history to surpass 50K stars, reaching the milestone in just 2 hours after publication ⭐</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://star-history.com/#instructkr/claw-code&Date">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date&theme=dark" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=instructkr/claw-code&type=Date" width="600" />
|
||||
</picture>
|
||||
</a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="assets/clawd-hero.jpeg" alt="Claw" width="300" />
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>Better Harness Tools, not merely storing the archive of leaked Claude Code</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/instructkr"><img src="https://img.shields.io/badge/Sponsor-%E2%9D%A4-pink?logo=github&style=for-the-badge" alt="Sponsor on GitHub" /></a>
|
||||
</p>
|
||||
|
||||
> [!IMPORTANT]
|
||||
> **Rust port is now in progress** on the [`dev/rust`](https://github.com/instructkr/claw-code/tree/dev/rust) branch and is expected to be merged into main today. The Rust implementation aims to deliver a faster, memory-safe harness runtime. Stay tuned — this will be the definitive version of the project.
|
||||
|
||||
> If you find this work useful, consider [sponsoring @instructkr on GitHub](https://github.com/sponsors/instructkr) to support continued open-source harness engineering research.
|
||||
|
||||
---
|
||||
|
||||
## How It Leaked
|
||||
## Backstory
|
||||
|
||||
[Chaofan Shou (@Fried_rice)](https://x.com/Fried_rice) discovered the leak and posted it publicly:
|
||||
At 4 AM on March 31, 2026, I woke up to my phone blowing up with notifications. The Claude Code source had been exposed, and the entire dev community was in a frenzy. My girlfriend in Korea was genuinely worried I might face legal action from Anthropic just for having the code on my machine — so I did what any engineer would do under pressure: I sat down, ported the core features to Python from scratch, and pushed it before the sun came up.
|
||||
|
||||
> **"Claude code source code has been leaked via a map file in their npm registry!"**
|
||||
The whole thing was orchestrated end-to-end using [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex) by [@bellman_ych](https://x.com/bellman_ych) — a workflow layer built on top of OpenAI's Codex ([@OpenAIDevs](https://x.com/OpenAIDevs)). I used `$team` mode for parallel code review and `$ralph` mode for persistent execution loops with architect-level verification. The entire porting session — from reading the original harness structure to producing a working Python tree with tests — was driven through OmX orchestration.
|
||||
|
||||
The result is a clean-room Python rewrite that captures the architectural patterns of Claude Code's agent harness without copying any proprietary source. I'm now actively collaborating with [@bellman_ych](https://x.com/bellman_ych) — the creator of OmX himself — to push this further. The basic Python foundation is already in place and functional, but we're just getting started. **Stay tuned — a much more capable version is on the way.**
|
||||
|
||||
https://github.com/instructkr/claw-code
|
||||
|
||||

|
||||
|
||||
## The Creators Featured in Wall Street Journal For Avid Claude Code Fans
|
||||
|
||||
I've been deeply interested in **harness engineering** — studying how agent systems wire tools, orchestrate tasks, and manage runtime context. This isn't a sudden thing. The Wall Street Journal featured my work earlier this month, documenting how I've been one of the most active power users exploring these systems:
|
||||
|
||||
> AI startup worker Sigrid Jin, who attended the Seoul dinner, single-handedly used 25 billion of Claude Code tokens last year. At the time, usage limits were looser, allowing early enthusiasts to reach tens of billions of tokens at a very low cost.
|
||||
>
|
||||
> — [@Fried_rice, March 31, 2026](https://x.com/Fried_rice/status/2038894956459290963)
|
||||
> Despite his countless hours with Claude Code, Jin isn't faithful to any one AI lab. The tools available have different strengths and weaknesses, he said. Codex is better at reasoning, while Claude Code generates cleaner, more shareable code.
|
||||
>
|
||||
> Jin flew to San Francisco in February for Claude Code's first birthday party, where attendees waited in line to compare notes with Cherny. The crowd included a practicing cardiologist from Belgium who had built an app to help patients navigate care, and a California lawyer who made a tool for automating building permit approvals using Claude Code.
|
||||
>
|
||||
> "It was basically like a sharing party," Jin said. "There were lawyers, there were doctors, there were dentists. They did not have software engineering backgrounds."
|
||||
>
|
||||
> — *The Wall Street Journal*, March 21, 2026, [*"The Trillion Dollar Race to Automate Our Entire Lives"*](https://lnkd.in/gs9td3qd)
|
||||
|
||||
The source map file in the published npm package contained a reference to the full, unobfuscated TypeScript source, which was downloadable as a zip archive from Anthropic's R2 storage bucket.
|
||||

|
||||
|
||||
---
|
||||
|
||||
## Overview
|
||||
## Porting Status
|
||||
|
||||
Claude Code is Anthropic's official CLI tool that lets you interact with Claude directly from the terminal to perform software engineering tasks — editing files, running commands, searching codebases, managing git workflows, and more.
|
||||
The main source tree is now Python-first.
|
||||
|
||||
This repository contains the leaked `src/` directory.
|
||||
- `src/` contains the active Python porting workspace
|
||||
- `tests/` verifies the current Python workspace
|
||||
- the exposed snapshot is no longer part of the tracked repository state
|
||||
|
||||
- **Leaked on**: 2026-03-31
|
||||
- **Language**: TypeScript
|
||||
- **Runtime**: Bun
|
||||
- **Terminal UI**: React + [Ink](https://github.com/vadimdemedes/ink) (React for CLI)
|
||||
- **Scale**: ~1,900 files, 512,000+ lines of code
|
||||
The current Python workspace is not yet a complete one-to-one replacement for the original system, but the primary implementation surface is now Python.
|
||||
|
||||
---
|
||||
## Why this rewrite exists
|
||||
|
||||
## Rust port foundation
|
||||
I originally studied the exposed codebase to understand its harness, tool wiring, and agent workflow. After spending more time with the legal and ethical questions—and after reading the essay linked below—I did not want the exposed snapshot itself to remain the main tracked source tree.
|
||||
|
||||
A compatibility-first Rust port workspace now lives in [`rust/`](rust/). Start with [`rust/README.md`](rust/README.md) for the current milestone scope, workspace layout, and verification commands.
|
||||
This repository now focuses on Python porting work instead.
|
||||
|
||||
## Directory Structure
|
||||
## Repository Layout
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.tsx # Entrypoint (Commander.js-based CLI parser)
|
||||
├── commands.ts # Command registry
|
||||
├── tools.ts # Tool registry
|
||||
├── Tool.ts # Tool type definitions
|
||||
├── QueryEngine.ts # LLM query engine (core Anthropic API caller)
|
||||
├── context.ts # System/user context collection
|
||||
├── cost-tracker.ts # Token cost tracking
|
||||
│
|
||||
├── commands/ # Slash command implementations (~50)
|
||||
├── tools/ # Agent tool implementations (~40)
|
||||
├── components/ # Ink UI components (~140)
|
||||
├── hooks/ # React hooks
|
||||
├── services/ # External service integrations
|
||||
├── screens/ # Full-screen UIs (Doctor, REPL, Resume)
|
||||
├── types/ # TypeScript type definitions
|
||||
├── utils/ # Utility functions
|
||||
│
|
||||
├── bridge/ # IDE integration bridge (VS Code, JetBrains)
|
||||
├── coordinator/ # Multi-agent coordinator
|
||||
├── plugins/ # Plugin system
|
||||
├── skills/ # Skill system
|
||||
├── keybindings/ # Keybinding configuration
|
||||
├── vim/ # Vim mode
|
||||
├── voice/ # Voice input
|
||||
├── remote/ # Remote sessions
|
||||
├── server/ # Server mode
|
||||
├── memdir/ # Memory directory (persistent memory)
|
||||
├── tasks/ # Task management
|
||||
├── state/ # State management
|
||||
├── migrations/ # Config migrations
|
||||
├── schemas/ # Config schemas (Zod)
|
||||
├── entrypoints/ # Initialization logic
|
||||
├── ink/ # Ink renderer wrapper
|
||||
├── buddy/ # Companion sprite (Easter egg)
|
||||
├── native-ts/ # Native TypeScript utils
|
||||
├── outputStyles/ # Output styling
|
||||
├── query/ # Query pipeline
|
||||
└── upstreamproxy/ # Proxy configuration
|
||||
```text
|
||||
.
|
||||
├── src/ # Python porting workspace
|
||||
│ ├── __init__.py
|
||||
│ ├── commands.py
|
||||
│ ├── main.py
|
||||
│ ├── models.py
|
||||
│ ├── port_manifest.py
|
||||
│ ├── query_engine.py
|
||||
│ ├── task.py
|
||||
│ └── tools.py
|
||||
├── tests/ # Python verification
|
||||
├── assets/omx/ # OmX workflow screenshots
|
||||
├── 2026-03-09-is-legal-the-same-as-legitimate-ai-reimplementation-and-the-erosion-of-copyleft.md
|
||||
└── README.md
|
||||
```
|
||||
|
||||
---
|
||||
## Python Workspace Overview
|
||||
|
||||
## Core Architecture
|
||||
The new Python `src/` tree currently provides:
|
||||
|
||||
### 1. Tool System (`src/tools/`)
|
||||
- **`port_manifest.py`** — summarizes the current Python workspace structure
|
||||
- **`models.py`** — dataclasses for subsystems, modules, and backlog state
|
||||
- **`commands.py`** — Python-side command port metadata
|
||||
- **`tools.py`** — Python-side tool port metadata
|
||||
- **`query_engine.py`** — renders a Python porting summary from the active workspace
|
||||
- **`main.py`** — a CLI entrypoint for manifest and summary output
|
||||
|
||||
Every tool Claude Code can invoke is implemented as a self-contained module. Each tool defines its input schema, permission model, and execution logic.
|
||||
## Quickstart
|
||||
|
||||
| Tool | Description |
|
||||
|---|---|
|
||||
| `BashTool` | Shell command execution |
|
||||
| `FileReadTool` | File reading (images, PDFs, notebooks) |
|
||||
| `FileWriteTool` | File creation / overwrite |
|
||||
| `FileEditTool` | Partial file modification (string replacement) |
|
||||
| `GlobTool` | File pattern matching search |
|
||||
| `GrepTool` | ripgrep-based content search |
|
||||
| `WebFetchTool` | Fetch URL content |
|
||||
| `WebSearchTool` | Web search |
|
||||
| `AgentTool` | Sub-agent spawning |
|
||||
| `SkillTool` | Skill execution |
|
||||
| `MCPTool` | MCP server tool invocation |
|
||||
| `LSPTool` | Language Server Protocol integration |
|
||||
| `NotebookEditTool` | Jupyter notebook editing |
|
||||
| `TaskCreateTool` / `TaskUpdateTool` | Task creation and management |
|
||||
| `SendMessageTool` | Inter-agent messaging |
|
||||
| `TeamCreateTool` / `TeamDeleteTool` | Team agent management |
|
||||
| `EnterPlanModeTool` / `ExitPlanModeTool` | Plan mode toggle |
|
||||
| `EnterWorktreeTool` / `ExitWorktreeTool` | Git worktree isolation |
|
||||
| `ToolSearchTool` | Deferred tool discovery |
|
||||
| `CronCreateTool` | Scheduled trigger creation |
|
||||
| `RemoteTriggerTool` | Remote trigger |
|
||||
| `SleepTool` | Proactive mode wait |
|
||||
| `SyntheticOutputTool` | Structured output generation |
|
||||
Render the Python porting summary:
|
||||
|
||||
### 2. Command System (`src/commands/`)
|
||||
|
||||
User-facing slash commands invoked with `/` prefix.
|
||||
|
||||
| Command | Description |
|
||||
|---|---|
|
||||
| `/commit` | Create a git commit |
|
||||
| `/review` | Code review |
|
||||
| `/compact` | Context compression |
|
||||
| `/mcp` | MCP server management |
|
||||
| `/config` | Settings management |
|
||||
| `/doctor` | Environment diagnostics |
|
||||
| `/login` / `/logout` | Authentication |
|
||||
| `/memory` | Persistent memory management |
|
||||
| `/skills` | Skill management |
|
||||
| `/tasks` | Task management |
|
||||
| `/vim` | Vim mode toggle |
|
||||
| `/diff` | View changes |
|
||||
| `/cost` | Check usage cost |
|
||||
| `/theme` | Change theme |
|
||||
| `/context` | Context visualization |
|
||||
| `/pr_comments` | View PR comments |
|
||||
| `/resume` | Restore previous session |
|
||||
| `/share` | Share session |
|
||||
| `/desktop` | Desktop app handoff |
|
||||
| `/mobile` | Mobile app handoff |
|
||||
|
||||
### 3. Service Layer (`src/services/`)
|
||||
|
||||
| Service | Description |
|
||||
|---|---|
|
||||
| `api/` | Anthropic API client, file API, bootstrap |
|
||||
| `mcp/` | Model Context Protocol server connection and management |
|
||||
| `oauth/` | OAuth 2.0 authentication flow |
|
||||
| `lsp/` | Language Server Protocol manager |
|
||||
| `analytics/` | GrowthBook-based feature flags and analytics |
|
||||
| `plugins/` | Plugin loader |
|
||||
| `compact/` | Conversation context compression |
|
||||
| `policyLimits/` | Organization policy limits |
|
||||
| `remoteManagedSettings/` | Remote managed settings |
|
||||
| `extractMemories/` | Automatic memory extraction |
|
||||
| `tokenEstimation.ts` | Token count estimation |
|
||||
| `teamMemorySync/` | Team memory synchronization |
|
||||
|
||||
### 4. Bridge System (`src/bridge/`)
|
||||
|
||||
A bidirectional communication layer connecting IDE extensions (VS Code, JetBrains) with the Claude Code CLI.
|
||||
|
||||
- `bridgeMain.ts` — Bridge main loop
|
||||
- `bridgeMessaging.ts` — Message protocol
|
||||
- `bridgePermissionCallbacks.ts` — Permission callbacks
|
||||
- `replBridge.ts` — REPL session bridge
|
||||
- `jwtUtils.ts` — JWT-based authentication
|
||||
- `sessionRunner.ts` — Session execution management
|
||||
|
||||
### 5. Permission System (`src/hooks/toolPermission/`)
|
||||
|
||||
Checks permissions on every tool invocation. Either prompts the user for approval/denial or automatically resolves based on the configured permission mode (`default`, `plan`, `bypassPermissions`, `auto`, etc.).
|
||||
|
||||
### 6. Feature Flags
|
||||
|
||||
Dead code elimination via Bun's `bun:bundle` feature flags:
|
||||
|
||||
```typescript
|
||||
import { feature } from 'bun:bundle'
|
||||
|
||||
// Inactive code is completely stripped at build time
|
||||
const voiceCommand = feature('VOICE_MODE')
|
||||
? require('./commands/voice/index.js').default
|
||||
: null
|
||||
```bash
|
||||
python3 -m src.main summary
|
||||
```
|
||||
|
||||
Notable flags: `PROACTIVE`, `KAIROS`, `BRIDGE_MODE`, `DAEMON`, `VOICE_MODE`, `AGENT_TRIGGERS`, `MONITOR_TOOL`
|
||||
Print the current Python workspace manifest:
|
||||
|
||||
---
|
||||
|
||||
## Key Files in Detail
|
||||
|
||||
### `QueryEngine.ts` (~46K lines)
|
||||
|
||||
The core engine for LLM API calls. Handles streaming responses, tool-call loops, thinking mode, retry logic, and token counting.
|
||||
|
||||
### `Tool.ts` (~29K lines)
|
||||
|
||||
Defines base types and interfaces for all tools — input schemas, permission models, and progress state types.
|
||||
|
||||
### `commands.ts` (~25K lines)
|
||||
|
||||
Manages registration and execution of all slash commands. Uses conditional imports to load different command sets per environment.
|
||||
|
||||
### `main.tsx`
|
||||
|
||||
Commander.js-based CLI parser + React/Ink renderer initialization. At startup, parallelizes MDM settings, keychain prefetch, and GrowthBook initialization for faster boot.
|
||||
|
||||
---
|
||||
|
||||
## Tech Stack
|
||||
|
||||
| Category | Technology |
|
||||
|---|---|
|
||||
| Runtime | [Bun](https://bun.sh) |
|
||||
| Language | TypeScript (strict) |
|
||||
| Terminal UI | [React](https://react.dev) + [Ink](https://github.com/vadimdemedes/ink) |
|
||||
| CLI Parsing | [Commander.js](https://github.com/tj/commander.js) (extra-typings) |
|
||||
| Schema Validation | [Zod v4](https://zod.dev) |
|
||||
| Code Search | [ripgrep](https://github.com/BurntSushi/ripgrep) (via GrepTool) |
|
||||
| Protocols | [MCP SDK](https://modelcontextprotocol.io), LSP |
|
||||
| API | [Anthropic SDK](https://docs.anthropic.com) |
|
||||
| Telemetry | OpenTelemetry + gRPC |
|
||||
| Feature Flags | GrowthBook |
|
||||
| Auth | OAuth 2.0, JWT, macOS Keychain |
|
||||
|
||||
---
|
||||
|
||||
## Notable Design Patterns
|
||||
|
||||
### Parallel Prefetch
|
||||
|
||||
Startup time is optimized by prefetching MDM settings, keychain reads, and API preconnect in parallel — before heavy module evaluation begins.
|
||||
|
||||
```typescript
|
||||
// main.tsx — fired as side-effects before other imports
|
||||
startMdmRawRead()
|
||||
startKeychainPrefetch()
|
||||
```bash
|
||||
python3 -m src.main manifest
|
||||
```
|
||||
|
||||
### Lazy Loading
|
||||
List the current Python modules:
|
||||
|
||||
Heavy modules (OpenTelemetry ~400KB, gRPC ~700KB) are deferred via dynamic `import()` until actually needed.
|
||||
```bash
|
||||
python3 -m src.main subsystems --limit 16
|
||||
```
|
||||
|
||||
### Agent Swarms
|
||||
Run verification:
|
||||
|
||||
Sub-agents are spawned via `AgentTool`, with `coordinator/` handling multi-agent orchestration. `TeamCreateTool` enables team-level parallel work.
|
||||
```bash
|
||||
python3 -m unittest discover -s tests -v
|
||||
```
|
||||
|
||||
### Skill System
|
||||
Run the parity audit against the local ignored archive (when present):
|
||||
|
||||
Reusable workflows defined in `skills/` and executed through `SkillTool`. Users can add custom skills.
|
||||
```bash
|
||||
python3 -m src.main parity-audit
|
||||
```
|
||||
|
||||
### Plugin Architecture
|
||||
Inspect mirrored command/tool inventories:
|
||||
|
||||
Built-in and third-party plugins are loaded through the `plugins/` subsystem.
|
||||
```bash
|
||||
python3 -m src.main commands --limit 10
|
||||
python3 -m src.main tools --limit 10
|
||||
```
|
||||
|
||||
---
|
||||
## Current Parity Checkpoint
|
||||
|
||||
## Disclaimer
|
||||
The port now mirrors the archived root-entry file surface, top-level subsystem names, and command/tool inventories much more closely than before. However, it is **not yet** a full runtime-equivalent replacement for the original TypeScript system; the Python tree still contains fewer executable runtime slices than the archived source.
|
||||
|
||||
This repository archives source code that was leaked from Anthropic's npm registry on **2026-03-31**. All original source code is the property of [Anthropic](https://www.anthropic.com).
|
||||
|
||||
## Built with `oh-my-codex`
|
||||
|
||||
The restructuring and documentation work on this repository was AI-assisted and orchestrated with Yeachan Heo's [oh-my-codex (OmX)](https://github.com/Yeachan-Heo/oh-my-codex), layered on top of Codex.
|
||||
|
||||
- **`$team` mode:** used for coordinated parallel review and architectural feedback
|
||||
- **`$ralph` mode:** used for persistent execution, verification, and completion discipline
|
||||
- **Codex-driven workflow:** used to turn the main `src/` tree into a Python-first porting workspace
|
||||
|
||||
### OmX workflow screenshots
|
||||
|
||||

|
||||
|
||||
*Ralph/team orchestration view while the README and essay context were being reviewed in terminal panes.*
|
||||
|
||||

|
||||
|
||||
*Split-pane review and verification flow during the final README wording pass.*
|
||||
|
||||
## Community
|
||||
|
||||
<p align="center">
|
||||
<a href="https://instruct.kr/"><img src="assets/instructkr.png" alt="instructkr" width="400" /></a>
|
||||
</p>
|
||||
|
||||
Join the [**instructkr Discord**](https://instruct.kr/) — the best Korean language model community. Come chat about LLMs, harness engineering, agent workflows, and everything in between.
|
||||
|
||||
[](https://instruct.kr/)
|
||||
|
||||
## Star History
|
||||
|
||||
See the chart at the top of this README.
|
||||
|
||||
## Ownership / Affiliation Disclaimer
|
||||
|
||||
- This repository does **not** claim ownership of the original Claude Code source material.
|
||||
- This repository is **not affiliated with, endorsed by, or maintained by Anthropic**.
|
||||
|
||||
2
rust/.gitignore
vendored
2
rust/.gitignore
vendored
@@ -1 +1,3 @@
|
||||
target/
|
||||
.omx/
|
||||
.clawd-agents/
|
||||
|
||||
1770
rust/Cargo.lock
generated
1770
rust/Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
227
rust/README.md
227
rust/README.md
@@ -1,54 +1,211 @@
|
||||
# Rust port foundation
|
||||
# Rusty Claude CLI
|
||||
|
||||
This directory contains the first compatibility-first Rust foundation for a drop-in Claude Code CLI replacement.
|
||||
|
||||
## Current milestone
|
||||
|
||||
This initial milestone focuses on **harness-first scaffolding**, not full feature parity:
|
||||
|
||||
- a Cargo workspace aligned to major upstream seams
|
||||
- a placeholder CLI crate (`rusty-claude-cli`)
|
||||
- runtime, command, and tool registry skeleton crates
|
||||
- a `compat-harness` crate that reads the upstream TypeScript sources in `../src/`
|
||||
- tests that prove upstream manifests/bootstrap hints can be extracted from the leaked TypeScript codebase
|
||||
`rust/` contains the Rust workspace for the integrated `rusty-claude-cli` deliverable.
|
||||
It is intended to be something you can clone, build, and run directly.
|
||||
|
||||
## Workspace layout
|
||||
|
||||
```text
|
||||
rust/
|
||||
├── Cargo.toml
|
||||
├── Cargo.lock
|
||||
├── README.md
|
||||
├── crates/
|
||||
│ ├── rusty-claude-cli/
|
||||
│ ├── runtime/
|
||||
│ ├── commands/
|
||||
│ ├── tools/
|
||||
│ └── compat-harness/
|
||||
└── tests/
|
||||
└── crates/
|
||||
├── api/ # Anthropic API client + SSE streaming support
|
||||
├── commands/ # Shared slash-command metadata/help surfaces
|
||||
├── compat-harness/ # Upstream TS manifest extraction harness
|
||||
├── runtime/ # Session/runtime/config/prompt orchestration
|
||||
├── rusty-claude-cli/ # Main CLI binary
|
||||
└── tools/ # Built-in tool implementations
|
||||
```
|
||||
|
||||
## How to use
|
||||
## Prerequisites
|
||||
|
||||
From this directory:
|
||||
- Rust toolchain installed (`rustup`, stable toolchain)
|
||||
- Network access and Anthropic credentials for live prompt/REPL usage
|
||||
|
||||
## Build
|
||||
|
||||
From the repository root:
|
||||
|
||||
```bash
|
||||
cargo fmt --all
|
||||
cargo check --workspace
|
||||
cargo test --workspace
|
||||
cargo run -p rusty-claude-cli -- --help
|
||||
cargo run -p rusty-claude-cli -- dump-manifests
|
||||
cargo run -p rusty-claude-cli -- bootstrap-plan
|
||||
cd rust
|
||||
cargo build --release -p rusty-claude-cli
|
||||
```
|
||||
|
||||
## Design notes
|
||||
The optimized binary will be written to:
|
||||
|
||||
The shape follows the PRD's harness-first recommendation:
|
||||
```bash
|
||||
./target/release/rusty-claude-cli
|
||||
```
|
||||
|
||||
1. Extract observable upstream command/tool/bootstrap facts first.
|
||||
2. Keep Rust module boundaries recognizable.
|
||||
3. Grow runtime compatibility behind proof artifacts.
|
||||
4. Document explicit gaps instead of implying drop-in parity too early.
|
||||
## Test
|
||||
|
||||
## Relationship to the root README
|
||||
Run the verified workspace test suite used for release-readiness:
|
||||
|
||||
The repository root README explains the leaked TypeScript codebase. This document tracks the Rust replacement effort that lives in `rust/`.
|
||||
```bash
|
||||
cd rust
|
||||
cargo test --workspace --exclude compat-harness
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Show help
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --help
|
||||
```
|
||||
|
||||
### Print version
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --version
|
||||
```
|
||||
|
||||
### Login with OAuth
|
||||
|
||||
Configure `settings.json` with an `oauth` block containing `clientId`, `authorizeUrl`, `tokenUrl`, optional `callbackPort`, and optional `scopes`, then run:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- login
|
||||
```
|
||||
|
||||
This opens the browser, listens on the configured localhost callback, exchanges the auth code for tokens, and stores OAuth credentials in `~/.claude/credentials.json` (or `$CLAUDE_CONFIG_HOME/credentials.json`).
|
||||
|
||||
### Logout
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- logout
|
||||
```
|
||||
|
||||
This removes only the stored OAuth credentials and preserves unrelated JSON fields in `credentials.json`.
|
||||
|
||||
## Usage examples
|
||||
|
||||
### 1) Prompt mode
|
||||
|
||||
Send one prompt, stream the answer, then exit:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- prompt "Summarize the architecture of this repository"
|
||||
```
|
||||
|
||||
Use a specific model:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --model claude-sonnet-4-20250514 prompt "List the key crates in this workspace"
|
||||
```
|
||||
|
||||
Restrict enabled tools in an interactive session:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --allowedTools read,glob
|
||||
```
|
||||
|
||||
### 2) REPL mode
|
||||
|
||||
Start the interactive shell:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli --
|
||||
```
|
||||
|
||||
Inside the REPL, useful commands include:
|
||||
|
||||
```text
|
||||
/help
|
||||
/status
|
||||
/model claude-sonnet-4-20250514
|
||||
/permissions workspace-write
|
||||
/cost
|
||||
/compact
|
||||
/memory
|
||||
/config
|
||||
/init
|
||||
/diff
|
||||
/version
|
||||
/export notes.txt
|
||||
/session list
|
||||
/exit
|
||||
```
|
||||
|
||||
### 3) Resume an existing session
|
||||
|
||||
Inspect or maintain a saved session file without entering the REPL:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --resume session.json /status /compact /cost
|
||||
```
|
||||
|
||||
You can also inspect memory/config state for a restored session:
|
||||
|
||||
```bash
|
||||
cd rust
|
||||
cargo run -p rusty-claude-cli -- --resume session.json /memory /config
|
||||
```
|
||||
|
||||
## Available commands
|
||||
|
||||
### Top-level CLI commands
|
||||
|
||||
- `prompt <text...>` — run one prompt non-interactively
|
||||
- `--resume <session.json> [/commands...]` — inspect or maintain a saved session
|
||||
- `dump-manifests` — print extracted upstream manifest counts
|
||||
- `bootstrap-plan` — print the current bootstrap skeleton
|
||||
- `system-prompt [--cwd PATH] [--date YYYY-MM-DD]` — render the synthesized system prompt
|
||||
- `--help` / `-h` — show CLI help
|
||||
- `--version` / `-V` — print the CLI version and build info locally (no API call)
|
||||
- `--output-format text|json` — choose non-interactive prompt output rendering
|
||||
- `--allowedTools <tool[,tool...]>` — restrict enabled tools for interactive sessions and prompt-mode tool use
|
||||
|
||||
### Interactive slash commands
|
||||
|
||||
- `/help` — show command help
|
||||
- `/status` — show current session status
|
||||
- `/compact` — compact local session history
|
||||
- `/model [model]` — inspect or switch the active model
|
||||
- `/permissions [read-only|workspace-write|danger-full-access]` — inspect or switch permissions
|
||||
- `/clear [--confirm]` — clear the current local session
|
||||
- `/cost` — show token usage totals
|
||||
- `/resume <session-path>` — load a saved session into the REPL
|
||||
- `/config [env|hooks|model]` — inspect discovered Claude config
|
||||
- `/memory` — inspect loaded instruction memory files
|
||||
- `/init` — create a starter `CLAUDE.md`
|
||||
- `/diff` — show the current git diff for the workspace
|
||||
- `/version` — print version and build metadata locally
|
||||
- `/export [file]` — export the current conversation transcript
|
||||
- `/session [list|switch <session-id>]` — inspect or switch managed local sessions
|
||||
- `/exit` — leave the REPL
|
||||
|
||||
## Environment variables
|
||||
|
||||
### Anthropic/API
|
||||
|
||||
- `ANTHROPIC_API_KEY` — highest-precedence API credential
|
||||
- `ANTHROPIC_AUTH_TOKEN` — bearer-token override used when no API key is set
|
||||
- Persisted OAuth credentials in `~/.claude/credentials.json` — used when neither env var is set
|
||||
- `ANTHROPIC_BASE_URL` — override the Anthropic API base URL
|
||||
- `ANTHROPIC_MODEL` — default model used by selected live integration tests
|
||||
|
||||
### CLI/runtime
|
||||
|
||||
- `RUSTY_CLAUDE_PERMISSION_MODE` — default REPL permission mode (`read-only`, `workspace-write`, or `danger-full-access`)
|
||||
- `CLAUDE_CONFIG_HOME` — override Claude config discovery root
|
||||
- `CLAUDE_CODE_REMOTE` — enable remote-session bootstrap handling when supported
|
||||
- `CLAUDE_CODE_REMOTE_SESSION_ID` — remote session identifier when using remote mode
|
||||
- `CLAUDE_CODE_UPSTREAM` — override the upstream TS source path for compat-harness extraction
|
||||
- `CLAWD_WEB_SEARCH_BASE_URL` — override the built-in web search service endpoint used by tooling
|
||||
|
||||
## Notes
|
||||
|
||||
- `compat-harness` exists to compare the Rust port against the upstream TypeScript codebase and is intentionally excluded from the requested release test run.
|
||||
- The CLI currently focuses on a practical integrated workflow: prompt execution, REPL operation, session inspection/resume, config discovery, and tool/runtime plumbing.
|
||||
|
||||
16
rust/crates/api/Cargo.toml
Normal file
16
rust/crates/api/Cargo.toml
Normal file
@@ -0,0 +1,16 @@
|
||||
[package]
|
||||
name = "api"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
runtime = { path = "../runtime" }
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "net", "rt-multi-thread", "time"] }
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
1006
rust/crates/api/src/client.rs
Normal file
1006
rust/crates/api/src/client.rs
Normal file
File diff suppressed because it is too large
Load Diff
134
rust/crates/api/src/error.rs
Normal file
134
rust/crates/api/src/error.rs
Normal file
@@ -0,0 +1,134 @@
|
||||
use std::env::VarError;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::time::Duration;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ApiError {
|
||||
MissingApiKey,
|
||||
ExpiredOAuthToken,
|
||||
Auth(String),
|
||||
InvalidApiKeyEnv(VarError),
|
||||
Http(reqwest::Error),
|
||||
Io(std::io::Error),
|
||||
Json(serde_json::Error),
|
||||
Api {
|
||||
status: reqwest::StatusCode,
|
||||
error_type: Option<String>,
|
||||
message: Option<String>,
|
||||
body: String,
|
||||
retryable: bool,
|
||||
},
|
||||
RetriesExhausted {
|
||||
attempts: u32,
|
||||
last_error: Box<ApiError>,
|
||||
},
|
||||
InvalidSseFrame(&'static str),
|
||||
BackoffOverflow {
|
||||
attempt: u32,
|
||||
base_delay: Duration,
|
||||
},
|
||||
}
|
||||
|
||||
impl ApiError {
|
||||
#[must_use]
|
||||
pub fn is_retryable(&self) -> bool {
|
||||
match self {
|
||||
Self::Http(error) => error.is_connect() || error.is_timeout() || error.is_request(),
|
||||
Self::Api { retryable, .. } => *retryable,
|
||||
Self::RetriesExhausted { last_error, .. } => last_error.is_retryable(),
|
||||
Self::MissingApiKey
|
||||
| Self::ExpiredOAuthToken
|
||||
| Self::Auth(_)
|
||||
| Self::InvalidApiKeyEnv(_)
|
||||
| Self::Io(_)
|
||||
| Self::Json(_)
|
||||
| Self::InvalidSseFrame(_)
|
||||
| Self::BackoffOverflow { .. } => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ApiError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::MissingApiKey => {
|
||||
write!(
|
||||
f,
|
||||
"ANTHROPIC_AUTH_TOKEN or ANTHROPIC_API_KEY is not set; export one before calling the Anthropic API"
|
||||
)
|
||||
}
|
||||
Self::ExpiredOAuthToken => {
|
||||
write!(
|
||||
f,
|
||||
"saved OAuth token is expired and no refresh token is available"
|
||||
)
|
||||
}
|
||||
Self::Auth(message) => write!(f, "auth error: {message}"),
|
||||
Self::InvalidApiKeyEnv(error) => {
|
||||
write!(
|
||||
f,
|
||||
"failed to read ANTHROPIC_AUTH_TOKEN / ANTHROPIC_API_KEY: {error}"
|
||||
)
|
||||
}
|
||||
Self::Http(error) => write!(f, "http error: {error}"),
|
||||
Self::Io(error) => write!(f, "io error: {error}"),
|
||||
Self::Json(error) => write!(f, "json error: {error}"),
|
||||
Self::Api {
|
||||
status,
|
||||
error_type,
|
||||
message,
|
||||
body,
|
||||
..
|
||||
} => match (error_type, message) {
|
||||
(Some(error_type), Some(message)) => {
|
||||
write!(
|
||||
f,
|
||||
"anthropic api returned {status} ({error_type}): {message}"
|
||||
)
|
||||
}
|
||||
_ => write!(f, "anthropic api returned {status}: {body}"),
|
||||
},
|
||||
Self::RetriesExhausted {
|
||||
attempts,
|
||||
last_error,
|
||||
} => write!(
|
||||
f,
|
||||
"anthropic api failed after {attempts} attempts: {last_error}"
|
||||
),
|
||||
Self::InvalidSseFrame(message) => write!(f, "invalid sse frame: {message}"),
|
||||
Self::BackoffOverflow {
|
||||
attempt,
|
||||
base_delay,
|
||||
} => write!(
|
||||
f,
|
||||
"retry backoff overflowed on attempt {attempt} with base delay {base_delay:?}"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ApiError {}
|
||||
|
||||
impl From<reqwest::Error> for ApiError {
|
||||
fn from(value: reqwest::Error) -> Self {
|
||||
Self::Http(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<std::io::Error> for ApiError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<serde_json::Error> for ApiError {
|
||||
fn from(value: serde_json::Error) -> Self {
|
||||
Self::Json(value)
|
||||
}
|
||||
}
|
||||
|
||||
impl From<VarError> for ApiError {
|
||||
fn from(value: VarError) -> Self {
|
||||
Self::InvalidApiKeyEnv(value)
|
||||
}
|
||||
}
|
||||
17
rust/crates/api/src/lib.rs
Normal file
17
rust/crates/api/src/lib.rs
Normal file
@@ -0,0 +1,17 @@
|
||||
mod client;
|
||||
mod error;
|
||||
mod sse;
|
||||
mod types;
|
||||
|
||||
pub use client::{
|
||||
oauth_token_is_expired, resolve_saved_oauth_token, resolve_startup_auth_source,
|
||||
AnthropicClient, AuthSource, MessageStream, OAuthTokenSet,
|
||||
};
|
||||
pub use error::ApiError;
|
||||
pub use sse::{parse_frame, SseParser};
|
||||
pub use types::{
|
||||
ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent, ContentBlockStopEvent,
|
||||
InputContentBlock, InputMessage, MessageDelta, MessageDeltaEvent, MessageRequest,
|
||||
MessageResponse, MessageStartEvent, MessageStopEvent, OutputContentBlock, StreamEvent,
|
||||
ToolChoice, ToolDefinition, ToolResultContentBlock, Usage,
|
||||
};
|
||||
219
rust/crates/api/src/sse.rs
Normal file
219
rust/crates/api/src/sse.rs
Normal file
@@ -0,0 +1,219 @@
|
||||
use crate::error::ApiError;
|
||||
use crate::types::StreamEvent;
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SseParser {
|
||||
buffer: Vec<u8>,
|
||||
}
|
||||
|
||||
impl SseParser {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn push(&mut self, chunk: &[u8]) -> Result<Vec<StreamEvent>, ApiError> {
|
||||
self.buffer.extend_from_slice(chunk);
|
||||
let mut events = Vec::new();
|
||||
|
||||
while let Some(frame) = self.next_frame() {
|
||||
if let Some(event) = parse_frame(&frame)? {
|
||||
events.push(event);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
pub fn finish(&mut self) -> Result<Vec<StreamEvent>, ApiError> {
|
||||
if self.buffer.is_empty() {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
|
||||
let trailing = std::mem::take(&mut self.buffer);
|
||||
match parse_frame(&String::from_utf8_lossy(&trailing))? {
|
||||
Some(event) => Ok(vec![event]),
|
||||
None => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
|
||||
fn next_frame(&mut self) -> Option<String> {
|
||||
let separator = self
|
||||
.buffer
|
||||
.windows(2)
|
||||
.position(|window| window == b"\n\n")
|
||||
.map(|position| (position, 2))
|
||||
.or_else(|| {
|
||||
self.buffer
|
||||
.windows(4)
|
||||
.position(|window| window == b"\r\n\r\n")
|
||||
.map(|position| (position, 4))
|
||||
})?;
|
||||
|
||||
let (position, separator_len) = separator;
|
||||
let frame = self
|
||||
.buffer
|
||||
.drain(..position + separator_len)
|
||||
.collect::<Vec<_>>();
|
||||
let frame_len = frame.len().saturating_sub(separator_len);
|
||||
Some(String::from_utf8_lossy(&frame[..frame_len]).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
pub fn parse_frame(frame: &str) -> Result<Option<StreamEvent>, ApiError> {
|
||||
let trimmed = frame.trim();
|
||||
if trimmed.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let mut data_lines = Vec::new();
|
||||
let mut event_name: Option<&str> = None;
|
||||
|
||||
for line in trimmed.lines() {
|
||||
if line.starts_with(':') {
|
||||
continue;
|
||||
}
|
||||
if let Some(name) = line.strip_prefix("event:") {
|
||||
event_name = Some(name.trim());
|
||||
continue;
|
||||
}
|
||||
if let Some(data) = line.strip_prefix("data:") {
|
||||
data_lines.push(data.trim_start());
|
||||
}
|
||||
}
|
||||
|
||||
if matches!(event_name, Some("ping")) {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if data_lines.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
let payload = data_lines.join("\n");
|
||||
if payload == "[DONE]" {
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
serde_json::from_str::<StreamEvent>(&payload)
|
||||
.map(Some)
|
||||
.map_err(ApiError::from)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{parse_frame, SseParser};
|
||||
use crate::types::{ContentBlockDelta, MessageDelta, OutputContentBlock, StreamEvent, Usage};
|
||||
|
||||
#[test]
|
||||
fn parses_single_frame() {
|
||||
let frame = concat!(
|
||||
"event: content_block_start\n",
|
||||
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"text\",\"text\":\"Hi\"}}\n\n"
|
||||
);
|
||||
|
||||
let event = parse_frame(frame).expect("frame should parse");
|
||||
assert_eq!(
|
||||
event,
|
||||
Some(StreamEvent::ContentBlockStart(
|
||||
crate::types::ContentBlockStartEvent {
|
||||
index: 0,
|
||||
content_block: OutputContentBlock::Text {
|
||||
text: "Hi".to_string(),
|
||||
},
|
||||
},
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_chunked_stream() {
|
||||
let mut parser = SseParser::new();
|
||||
let first = b"event: content_block_delta\ndata: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"text_delta\",\"text\":\"Hel";
|
||||
let second = b"lo\"}}\n\n";
|
||||
|
||||
assert!(parser
|
||||
.push(first)
|
||||
.expect("first chunk should buffer")
|
||||
.is_empty());
|
||||
let events = parser.push(second).expect("second chunk should parse");
|
||||
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![StreamEvent::ContentBlockDelta(
|
||||
crate::types::ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::TextDelta {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
}
|
||||
)]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_ping_and_done() {
|
||||
let mut parser = SseParser::new();
|
||||
let payload = concat!(
|
||||
": keepalive\n",
|
||||
"event: ping\n",
|
||||
"data: {\"type\":\"ping\"}\n\n",
|
||||
"event: message_delta\n",
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":1,\"output_tokens\":2}}\n\n",
|
||||
"event: message_stop\n",
|
||||
"data: {\"type\":\"message_stop\"}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
|
||||
let events = parser
|
||||
.push(payload.as_bytes())
|
||||
.expect("parser should succeed");
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
StreamEvent::MessageDelta(crate::types::MessageDeltaEvent {
|
||||
delta: MessageDelta {
|
||||
stop_reason: Some("tool_use".to_string()),
|
||||
stop_sequence: None,
|
||||
},
|
||||
usage: Usage {
|
||||
input_tokens: 1,
|
||||
cache_creation_input_tokens: 0,
|
||||
cache_read_input_tokens: 0,
|
||||
output_tokens: 2,
|
||||
},
|
||||
}),
|
||||
StreamEvent::MessageStop(crate::types::MessageStopEvent {}),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_data_less_event_frames() {
|
||||
let frame = "event: ping\n\n";
|
||||
let event = parse_frame(frame).expect("frame without data should be ignored");
|
||||
assert_eq!(event, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_split_json_across_data_lines() {
|
||||
let frame = concat!(
|
||||
"event: content_block_delta\n",
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\n",
|
||||
"data: \"delta\":{\"type\":\"text_delta\",\"text\":\"Hello\"}}\n\n"
|
||||
);
|
||||
|
||||
let event = parse_frame(frame).expect("frame should parse");
|
||||
assert_eq!(
|
||||
event,
|
||||
Some(StreamEvent::ContentBlockDelta(
|
||||
crate::types::ContentBlockDeltaEvent {
|
||||
index: 0,
|
||||
delta: ContentBlockDelta::TextDelta {
|
||||
text: "Hello".to_string(),
|
||||
},
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
}
|
||||
212
rust/crates/api/src/types.rs
Normal file
212
rust/crates/api/src/types.rs
Normal file
@@ -0,0 +1,212 @@
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::Value;
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessageRequest {
|
||||
pub model: String,
|
||||
pub max_tokens: u32,
|
||||
pub messages: Vec<InputMessage>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub system: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tools: Option<Vec<ToolDefinition>>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub tool_choice: Option<ToolChoice>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
pub stream: bool,
|
||||
}
|
||||
|
||||
impl MessageRequest {
|
||||
#[must_use]
|
||||
pub fn with_streaming(mut self) -> Self {
|
||||
self.stream = true;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct InputMessage {
|
||||
pub role: String,
|
||||
pub content: Vec<InputContentBlock>,
|
||||
}
|
||||
|
||||
impl InputMessage {
|
||||
#[must_use]
|
||||
pub fn user_text(text: impl Into<String>) -> Self {
|
||||
Self {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::Text { text: text.into() }],
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn user_tool_result(
|
||||
tool_use_id: impl Into<String>,
|
||||
content: impl Into<String>,
|
||||
is_error: bool,
|
||||
) -> Self {
|
||||
Self {
|
||||
role: "user".to_string(),
|
||||
content: vec![InputContentBlock::ToolResult {
|
||||
tool_use_id: tool_use_id.into(),
|
||||
content: vec![ToolResultContentBlock::Text {
|
||||
text: content.into(),
|
||||
}],
|
||||
is_error,
|
||||
}],
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum InputContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
ToolResult {
|
||||
tool_use_id: String,
|
||||
content: Vec<ToolResultContentBlock>,
|
||||
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
|
||||
is_error: bool,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolResultContentBlock {
|
||||
Text { text: String },
|
||||
Json { value: Value },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ToolDefinition {
|
||||
pub name: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub description: Option<String>,
|
||||
pub input_schema: Value,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ToolChoice {
|
||||
Auto,
|
||||
Any,
|
||||
Tool { name: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessageResponse {
|
||||
pub id: String,
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub role: String,
|
||||
pub content: Vec<OutputContentBlock>,
|
||||
pub model: String,
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_sequence: Option<String>,
|
||||
pub usage: Usage,
|
||||
#[serde(default)]
|
||||
pub request_id: Option<String>,
|
||||
}
|
||||
|
||||
impl MessageResponse {
|
||||
#[must_use]
|
||||
pub fn total_tokens(&self) -> u32 {
|
||||
self.usage.total_tokens()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum OutputContentBlock {
|
||||
Text {
|
||||
text: String,
|
||||
},
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: Value,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Usage {
|
||||
pub input_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub cache_creation_input_tokens: u32,
|
||||
#[serde(default)]
|
||||
pub cache_read_input_tokens: u32,
|
||||
pub output_tokens: u32,
|
||||
}
|
||||
|
||||
impl Usage {
|
||||
#[must_use]
|
||||
pub const fn total_tokens(&self) -> u32 {
|
||||
self.input_tokens + self.output_tokens
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessageStartEvent {
|
||||
pub message: MessageResponse,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct MessageDeltaEvent {
|
||||
pub delta: MessageDelta,
|
||||
pub usage: Usage,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MessageDelta {
|
||||
#[serde(default)]
|
||||
pub stop_reason: Option<String>,
|
||||
#[serde(default)]
|
||||
pub stop_sequence: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContentBlockStartEvent {
|
||||
pub index: u32,
|
||||
pub content_block: OutputContentBlock,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
pub struct ContentBlockDeltaEvent {
|
||||
pub index: u32,
|
||||
pub delta: ContentBlockDelta,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum ContentBlockDelta {
|
||||
TextDelta { text: String },
|
||||
InputJsonDelta { partial_json: String },
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ContentBlockStopEvent {
|
||||
pub index: u32,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct MessageStopEvent {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum StreamEvent {
|
||||
MessageStart(MessageStartEvent),
|
||||
MessageDelta(MessageDeltaEvent),
|
||||
ContentBlockStart(ContentBlockStartEvent),
|
||||
ContentBlockDelta(ContentBlockDeltaEvent),
|
||||
ContentBlockStop(ContentBlockStopEvent),
|
||||
MessageStop(MessageStopEvent),
|
||||
}
|
||||
443
rust/crates/api/tests/client_integration.rs
Normal file
443
rust/crates/api/tests/client_integration.rs
Normal file
@@ -0,0 +1,443 @@
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use api::{
|
||||
AnthropicClient, ApiError, ContentBlockDelta, ContentBlockDeltaEvent, ContentBlockStartEvent,
|
||||
InputContentBlock, InputMessage, MessageDeltaEvent, MessageRequest, OutputContentBlock,
|
||||
StreamEvent, ToolChoice, ToolDefinition,
|
||||
};
|
||||
use serde_json::json;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpListener;
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
#[tokio::test]
|
||||
async fn send_message_posts_json_and_parses_response() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let body = concat!(
|
||||
"{",
|
||||
"\"id\":\"msg_test\",",
|
||||
"\"type\":\"message\",",
|
||||
"\"role\":\"assistant\",",
|
||||
"\"content\":[{\"type\":\"text\",\"text\":\"Hello from Claude\"}],",
|
||||
"\"model\":\"claude-3-7-sonnet-latest\",",
|
||||
"\"stop_reason\":\"end_turn\",",
|
||||
"\"stop_sequence\":null,",
|
||||
"\"usage\":{\"input_tokens\":12,\"output_tokens\":4},",
|
||||
"\"request_id\":\"req_body_123\"",
|
||||
"}"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response("200 OK", "application/json", body)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = AnthropicClient::new("test-key")
|
||||
.with_auth_token(Some("proxy-token".to_string()))
|
||||
.with_base_url(server.base_url());
|
||||
let response = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect("request should succeed");
|
||||
|
||||
assert_eq!(response.id, "msg_test");
|
||||
assert_eq!(response.total_tokens(), 16);
|
||||
assert_eq!(response.request_id.as_deref(), Some("req_body_123"));
|
||||
assert_eq!(
|
||||
response.content,
|
||||
vec![OutputContentBlock::Text {
|
||||
text: "Hello from Claude".to_string(),
|
||||
}]
|
||||
);
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
assert_eq!(request.method, "POST");
|
||||
assert_eq!(request.path, "/v1/messages");
|
||||
assert_eq!(
|
||||
request.headers.get("x-api-key").map(String::as_str),
|
||||
Some("test-key")
|
||||
);
|
||||
assert_eq!(
|
||||
request.headers.get("authorization").map(String::as_str),
|
||||
Some("Bearer proxy-token")
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(&request.body).expect("request body should be json");
|
||||
assert_eq!(
|
||||
body.get("model").and_then(serde_json::Value::as_str),
|
||||
Some("claude-3-7-sonnet-latest")
|
||||
);
|
||||
assert!(body.get("stream").is_none());
|
||||
assert_eq!(body["tools"][0]["name"], json!("get_weather"));
|
||||
assert_eq!(body["tool_choice"]["type"], json!("auto"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn stream_message_parses_sse_events_with_tool_use() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let sse = concat!(
|
||||
"event: message_start\n",
|
||||
"data: {\"type\":\"message_start\",\"message\":{\"id\":\"msg_stream\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":null,\"stop_sequence\":null,\"usage\":{\"input_tokens\":8,\"output_tokens\":0}}}\n\n",
|
||||
"event: content_block_start\n",
|
||||
"data: {\"type\":\"content_block_start\",\"index\":0,\"content_block\":{\"type\":\"tool_use\",\"id\":\"toolu_123\",\"name\":\"get_weather\",\"input\":{}}}\n\n",
|
||||
"event: content_block_delta\n",
|
||||
"data: {\"type\":\"content_block_delta\",\"index\":0,\"delta\":{\"type\":\"input_json_delta\",\"partial_json\":\"{\\\"city\\\":\\\"Paris\\\"}\"}}\n\n",
|
||||
"event: content_block_stop\n",
|
||||
"data: {\"type\":\"content_block_stop\",\"index\":0}\n\n",
|
||||
"event: message_delta\n",
|
||||
"data: {\"type\":\"message_delta\",\"delta\":{\"stop_reason\":\"tool_use\",\"stop_sequence\":null},\"usage\":{\"input_tokens\":8,\"output_tokens\":1}}\n\n",
|
||||
"event: message_stop\n",
|
||||
"data: {\"type\":\"message_stop\"}\n\n",
|
||||
"data: [DONE]\n\n"
|
||||
);
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![http_response_with_headers(
|
||||
"200 OK",
|
||||
"text/event-stream",
|
||||
sse,
|
||||
&[("request-id", "req_stream_456")],
|
||||
)],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = AnthropicClient::new("test-key")
|
||||
.with_auth_token(Some("proxy-token".to_string()))
|
||||
.with_base_url(server.base_url());
|
||||
let mut stream = client
|
||||
.stream_message(&sample_request(false))
|
||||
.await
|
||||
.expect("stream should start");
|
||||
|
||||
assert_eq!(stream.request_id(), Some("req_stream_456"));
|
||||
|
||||
let mut events = Vec::new();
|
||||
while let Some(event) = stream
|
||||
.next_event()
|
||||
.await
|
||||
.expect("stream event should parse")
|
||||
{
|
||||
events.push(event);
|
||||
}
|
||||
|
||||
assert_eq!(events.len(), 6);
|
||||
assert!(matches!(events[0], StreamEvent::MessageStart(_)));
|
||||
assert!(matches!(
|
||||
events[1],
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
content_block: OutputContentBlock::ToolUse { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(
|
||||
events[2],
|
||||
StreamEvent::ContentBlockDelta(ContentBlockDeltaEvent {
|
||||
delta: ContentBlockDelta::InputJsonDelta { .. },
|
||||
..
|
||||
})
|
||||
));
|
||||
assert!(matches!(events[3], StreamEvent::ContentBlockStop(_)));
|
||||
assert!(matches!(
|
||||
events[4],
|
||||
StreamEvent::MessageDelta(MessageDeltaEvent { .. })
|
||||
));
|
||||
assert!(matches!(events[5], StreamEvent::MessageStop(_)));
|
||||
|
||||
match &events[1] {
|
||||
StreamEvent::ContentBlockStart(ContentBlockStartEvent {
|
||||
content_block: OutputContentBlock::ToolUse { name, input, .. },
|
||||
..
|
||||
}) => {
|
||||
assert_eq!(name, "get_weather");
|
||||
assert_eq!(input, &json!({}));
|
||||
}
|
||||
other => panic!("expected tool_use block, got {other:?}"),
|
||||
}
|
||||
|
||||
let captured = state.lock().await;
|
||||
let request = captured.first().expect("server should capture request");
|
||||
assert!(request.body.contains("\"stream\":true"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn retries_retryable_failures_before_succeeding() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![
|
||||
http_response(
|
||||
"429 Too Many Requests",
|
||||
"application/json",
|
||||
"{\"type\":\"error\",\"error\":{\"type\":\"rate_limit_error\",\"message\":\"slow down\"}}",
|
||||
),
|
||||
http_response(
|
||||
"200 OK",
|
||||
"application/json",
|
||||
"{\"id\":\"msg_retry\",\"type\":\"message\",\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"Recovered\"}],\"model\":\"claude-3-7-sonnet-latest\",\"stop_reason\":\"end_turn\",\"stop_sequence\":null,\"usage\":{\"input_tokens\":3,\"output_tokens\":2}}",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = AnthropicClient::new("test-key")
|
||||
.with_base_url(server.base_url())
|
||||
.with_retry_policy(2, Duration::from_millis(1), Duration::from_millis(2));
|
||||
|
||||
let response = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect("retry should eventually succeed");
|
||||
|
||||
assert_eq!(response.total_tokens(), 5);
|
||||
assert_eq!(state.lock().await.len(), 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn surfaces_retry_exhaustion_for_persistent_retryable_errors() {
|
||||
let state = Arc::new(Mutex::new(Vec::<CapturedRequest>::new()));
|
||||
let server = spawn_server(
|
||||
state.clone(),
|
||||
vec![
|
||||
http_response(
|
||||
"503 Service Unavailable",
|
||||
"application/json",
|
||||
"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"busy\"}}",
|
||||
),
|
||||
http_response(
|
||||
"503 Service Unavailable",
|
||||
"application/json",
|
||||
"{\"type\":\"error\",\"error\":{\"type\":\"overloaded_error\",\"message\":\"still busy\"}}",
|
||||
),
|
||||
],
|
||||
)
|
||||
.await;
|
||||
|
||||
let client = AnthropicClient::new("test-key")
|
||||
.with_base_url(server.base_url())
|
||||
.with_retry_policy(1, Duration::from_millis(1), Duration::from_millis(2));
|
||||
|
||||
let error = client
|
||||
.send_message(&sample_request(false))
|
||||
.await
|
||||
.expect_err("persistent 503 should fail");
|
||||
|
||||
match error {
|
||||
ApiError::RetriesExhausted {
|
||||
attempts,
|
||||
last_error,
|
||||
} => {
|
||||
assert_eq!(attempts, 2);
|
||||
assert!(matches!(
|
||||
*last_error,
|
||||
ApiError::Api {
|
||||
status: reqwest::StatusCode::SERVICE_UNAVAILABLE,
|
||||
retryable: true,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
other => panic!("expected retries exhausted, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "requires ANTHROPIC_API_KEY and network access"]
|
||||
async fn live_stream_smoke_test() {
|
||||
let client = AnthropicClient::from_env().expect("ANTHROPIC_API_KEY must be set");
|
||||
let mut stream = client
|
||||
.stream_message(&MessageRequest {
|
||||
model: std::env::var("ANTHROPIC_MODEL")
|
||||
.unwrap_or_else(|_| "claude-3-7-sonnet-latest".to_string()),
|
||||
max_tokens: 32,
|
||||
messages: vec![InputMessage::user_text(
|
||||
"Reply with exactly: hello from rust",
|
||||
)],
|
||||
system: None,
|
||||
tools: None,
|
||||
tool_choice: None,
|
||||
stream: false,
|
||||
})
|
||||
.await
|
||||
.expect("live stream should start");
|
||||
|
||||
while let Some(_event) = stream
|
||||
.next_event()
|
||||
.await
|
||||
.expect("live stream should yield events")
|
||||
{}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
struct CapturedRequest {
|
||||
method: String,
|
||||
path: String,
|
||||
headers: HashMap<String, String>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
struct TestServer {
|
||||
base_url: String,
|
||||
join_handle: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl TestServer {
|
||||
fn base_url(&self) -> String {
|
||||
self.base_url.clone()
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestServer {
|
||||
fn drop(&mut self) {
|
||||
self.join_handle.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn spawn_server(
|
||||
state: Arc<Mutex<Vec<CapturedRequest>>>,
|
||||
responses: Vec<String>,
|
||||
) -> TestServer {
|
||||
let listener = TcpListener::bind("127.0.0.1:0")
|
||||
.await
|
||||
.expect("listener should bind");
|
||||
let address = listener
|
||||
.local_addr()
|
||||
.expect("listener should have local addr");
|
||||
let join_handle = tokio::spawn(async move {
|
||||
for response in responses {
|
||||
let (mut socket, _) = listener.accept().await.expect("server should accept");
|
||||
let mut buffer = Vec::new();
|
||||
let mut header_end = None;
|
||||
|
||||
loop {
|
||||
let mut chunk = [0_u8; 1024];
|
||||
let read = socket
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.expect("request read should succeed");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
buffer.extend_from_slice(&chunk[..read]);
|
||||
if let Some(position) = find_header_end(&buffer) {
|
||||
header_end = Some(position);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let header_end = header_end.expect("request should include headers");
|
||||
let (header_bytes, remaining) = buffer.split_at(header_end);
|
||||
let header_text =
|
||||
String::from_utf8(header_bytes.to_vec()).expect("headers should be utf8");
|
||||
let mut lines = header_text.split("\r\n");
|
||||
let request_line = lines.next().expect("request line should exist");
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let method = parts.next().expect("method should exist").to_string();
|
||||
let path = parts.next().expect("path should exist").to_string();
|
||||
let mut headers = HashMap::new();
|
||||
let mut content_length = 0_usize;
|
||||
for line in lines {
|
||||
if line.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (name, value) = line.split_once(':').expect("header should have colon");
|
||||
let value = value.trim().to_string();
|
||||
if name.eq_ignore_ascii_case("content-length") {
|
||||
content_length = value.parse().expect("content length should parse");
|
||||
}
|
||||
headers.insert(name.to_ascii_lowercase(), value);
|
||||
}
|
||||
|
||||
let mut body = remaining[4..].to_vec();
|
||||
while body.len() < content_length {
|
||||
let mut chunk = vec![0_u8; content_length - body.len()];
|
||||
let read = socket
|
||||
.read(&mut chunk)
|
||||
.await
|
||||
.expect("body read should succeed");
|
||||
if read == 0 {
|
||||
break;
|
||||
}
|
||||
body.extend_from_slice(&chunk[..read]);
|
||||
}
|
||||
|
||||
state.lock().await.push(CapturedRequest {
|
||||
method,
|
||||
path,
|
||||
headers,
|
||||
body: String::from_utf8(body).expect("body should be utf8"),
|
||||
});
|
||||
|
||||
socket
|
||||
.write_all(response.as_bytes())
|
||||
.await
|
||||
.expect("response write should succeed");
|
||||
}
|
||||
});
|
||||
|
||||
TestServer {
|
||||
base_url: format!("http://{address}"),
|
||||
join_handle,
|
||||
}
|
||||
}
|
||||
|
||||
fn find_header_end(bytes: &[u8]) -> Option<usize> {
|
||||
bytes.windows(4).position(|window| window == b"\r\n\r\n")
|
||||
}
|
||||
|
||||
fn http_response(status: &str, content_type: &str, body: &str) -> String {
|
||||
http_response_with_headers(status, content_type, body, &[])
|
||||
}
|
||||
|
||||
fn http_response_with_headers(
|
||||
status: &str,
|
||||
content_type: &str,
|
||||
body: &str,
|
||||
headers: &[(&str, &str)],
|
||||
) -> String {
|
||||
let mut extra_headers = String::new();
|
||||
for (name, value) in headers {
|
||||
use std::fmt::Write as _;
|
||||
write!(&mut extra_headers, "{name}: {value}\r\n").expect("header write should succeed");
|
||||
}
|
||||
format!(
|
||||
"HTTP/1.1 {status}\r\ncontent-type: {content_type}\r\n{extra_headers}content-length: {}\r\nconnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
)
|
||||
}
|
||||
|
||||
fn sample_request(stream: bool) -> MessageRequest {
|
||||
MessageRequest {
|
||||
model: "claude-3-7-sonnet-latest".to_string(),
|
||||
max_tokens: 64,
|
||||
messages: vec![InputMessage {
|
||||
role: "user".to_string(),
|
||||
content: vec![
|
||||
InputContentBlock::Text {
|
||||
text: "Say hello".to_string(),
|
||||
},
|
||||
InputContentBlock::ToolResult {
|
||||
tool_use_id: "toolu_prev".to_string(),
|
||||
content: vec![api::ToolResultContentBlock::Json {
|
||||
value: json!({"forecast": "sunny"}),
|
||||
}],
|
||||
is_error: false,
|
||||
},
|
||||
],
|
||||
}],
|
||||
system: Some("Use tools when needed".to_string()),
|
||||
tools: Some(vec![ToolDefinition {
|
||||
name: "get_weather".to_string(),
|
||||
description: Some("Fetches the weather".to_string()),
|
||||
input_schema: json!({
|
||||
"type": "object",
|
||||
"properties": {"city": {"type": "string"}},
|
||||
"required": ["city"]
|
||||
}),
|
||||
}]),
|
||||
tool_choice: Some(ToolChoice::Auto),
|
||||
stream,
|
||||
}
|
||||
}
|
||||
@@ -7,3 +7,6 @@ publish.workspace = true
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dependencies]
|
||||
runtime = { path = "../runtime" }
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
use runtime::{compact_session, CompactionConfig, Session};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CommandManifestEntry {
|
||||
pub name: String,
|
||||
@@ -27,3 +29,444 @@ impl CommandRegistry {
|
||||
&self.entries
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct SlashCommandSpec {
|
||||
pub name: &'static str,
|
||||
pub summary: &'static str,
|
||||
pub argument_hint: Option<&'static str>,
|
||||
pub resume_supported: bool,
|
||||
}
|
||||
|
||||
const SLASH_COMMAND_SPECS: &[SlashCommandSpec] = &[
|
||||
SlashCommandSpec {
|
||||
name: "help",
|
||||
summary: "Show available slash commands",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "status",
|
||||
summary: "Show current session status",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "compact",
|
||||
summary: "Compact local session history",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "model",
|
||||
summary: "Show or switch the active model",
|
||||
argument_hint: Some("[model]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "permissions",
|
||||
summary: "Show or switch the active permission mode",
|
||||
argument_hint: Some("[read-only|workspace-write|danger-full-access]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "clear",
|
||||
summary: "Start a fresh local session",
|
||||
argument_hint: Some("[--confirm]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "cost",
|
||||
summary: "Show cumulative token usage for this session",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "resume",
|
||||
summary: "Load a saved session into the REPL",
|
||||
argument_hint: Some("<session-path>"),
|
||||
resume_supported: false,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "config",
|
||||
summary: "Inspect Claude config files or merged sections",
|
||||
argument_hint: Some("[env|hooks|model]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "memory",
|
||||
summary: "Inspect loaded Claude instruction memory files",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "init",
|
||||
summary: "Create a starter CLAUDE.md for this repo",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "diff",
|
||||
summary: "Show git diff for current workspace changes",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "version",
|
||||
summary: "Show CLI version and build information",
|
||||
argument_hint: None,
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "export",
|
||||
summary: "Export the current conversation to a file",
|
||||
argument_hint: Some("[file]"),
|
||||
resume_supported: true,
|
||||
},
|
||||
SlashCommandSpec {
|
||||
name: "session",
|
||||
summary: "List or switch managed local sessions",
|
||||
argument_hint: Some("[list|switch <session-id>]"),
|
||||
resume_supported: false,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SlashCommand {
|
||||
Help,
|
||||
Status,
|
||||
Compact,
|
||||
Model {
|
||||
model: Option<String>,
|
||||
},
|
||||
Permissions {
|
||||
mode: Option<String>,
|
||||
},
|
||||
Clear {
|
||||
confirm: bool,
|
||||
},
|
||||
Cost,
|
||||
Resume {
|
||||
session_path: Option<String>,
|
||||
},
|
||||
Config {
|
||||
section: Option<String>,
|
||||
},
|
||||
Memory,
|
||||
Init,
|
||||
Diff,
|
||||
Version,
|
||||
Export {
|
||||
path: Option<String>,
|
||||
},
|
||||
Session {
|
||||
action: Option<String>,
|
||||
target: Option<String>,
|
||||
},
|
||||
Unknown(String),
|
||||
}
|
||||
|
||||
impl SlashCommand {
|
||||
#[must_use]
|
||||
pub fn parse(input: &str) -> Option<Self> {
|
||||
let trimmed = input.trim();
|
||||
if !trimmed.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
|
||||
let mut parts = trimmed.trim_start_matches('/').split_whitespace();
|
||||
let command = parts.next().unwrap_or_default();
|
||||
Some(match command {
|
||||
"help" => Self::Help,
|
||||
"status" => Self::Status,
|
||||
"compact" => Self::Compact,
|
||||
"model" => Self::Model {
|
||||
model: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"permissions" => Self::Permissions {
|
||||
mode: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"clear" => Self::Clear {
|
||||
confirm: parts.next() == Some("--confirm"),
|
||||
},
|
||||
"cost" => Self::Cost,
|
||||
"resume" => Self::Resume {
|
||||
session_path: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"config" => Self::Config {
|
||||
section: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"memory" => Self::Memory,
|
||||
"init" => Self::Init,
|
||||
"diff" => Self::Diff,
|
||||
"version" => Self::Version,
|
||||
"export" => Self::Export {
|
||||
path: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
"session" => Self::Session {
|
||||
action: parts.next().map(ToOwned::to_owned),
|
||||
target: parts.next().map(ToOwned::to_owned),
|
||||
},
|
||||
other => Self::Unknown(other.to_string()),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn slash_command_specs() -> &'static [SlashCommandSpec] {
|
||||
SLASH_COMMAND_SPECS
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn resume_supported_slash_commands() -> Vec<&'static SlashCommandSpec> {
|
||||
slash_command_specs()
|
||||
.iter()
|
||||
.filter(|spec| spec.resume_supported)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn render_slash_command_help() -> String {
|
||||
let mut lines = vec![
|
||||
"Slash commands".to_string(),
|
||||
" [resume] means the command also works with --resume SESSION.json".to_string(),
|
||||
];
|
||||
for spec in slash_command_specs() {
|
||||
let name = match spec.argument_hint {
|
||||
Some(argument_hint) => format!("/{} {}", spec.name, argument_hint),
|
||||
None => format!("/{}", spec.name),
|
||||
};
|
||||
let resume = if spec.resume_supported {
|
||||
" [resume]"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
lines.push(format!(" {name:<20} {}{}", spec.summary, resume));
|
||||
}
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct SlashCommandResult {
|
||||
pub message: String,
|
||||
pub session: Session,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn handle_slash_command(
|
||||
input: &str,
|
||||
session: &Session,
|
||||
compaction: CompactionConfig,
|
||||
) -> Option<SlashCommandResult> {
|
||||
match SlashCommand::parse(input)? {
|
||||
SlashCommand::Compact => {
|
||||
let result = compact_session(session, compaction);
|
||||
let message = if result.removed_message_count == 0 {
|
||||
"Compaction skipped: session is below the compaction threshold.".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"Compacted {} messages into a resumable system summary.",
|
||||
result.removed_message_count
|
||||
)
|
||||
};
|
||||
Some(SlashCommandResult {
|
||||
message,
|
||||
session: result.compacted_session,
|
||||
})
|
||||
}
|
||||
SlashCommand::Help => Some(SlashCommandResult {
|
||||
message: render_slash_command_help(),
|
||||
session: session.clone(),
|
||||
}),
|
||||
SlashCommand::Status
|
||||
| SlashCommand::Model { .. }
|
||||
| SlashCommand::Permissions { .. }
|
||||
| SlashCommand::Clear { .. }
|
||||
| SlashCommand::Cost
|
||||
| SlashCommand::Resume { .. }
|
||||
| SlashCommand::Config { .. }
|
||||
| SlashCommand::Memory
|
||||
| SlashCommand::Init
|
||||
| SlashCommand::Diff
|
||||
| SlashCommand::Version
|
||||
| SlashCommand::Export { .. }
|
||||
| SlashCommand::Session { .. }
|
||||
| SlashCommand::Unknown(_) => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
handle_slash_command, render_slash_command_help, resume_supported_slash_commands,
|
||||
slash_command_specs, SlashCommand,
|
||||
};
|
||||
use runtime::{CompactionConfig, ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn parses_supported_slash_commands() {
|
||||
assert_eq!(SlashCommand::parse("/help"), Some(SlashCommand::Help));
|
||||
assert_eq!(SlashCommand::parse(" /status "), Some(SlashCommand::Status));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/model claude-opus"),
|
||||
Some(SlashCommand::Model {
|
||||
model: Some("claude-opus".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/model"),
|
||||
Some(SlashCommand::Model { model: None })
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/permissions read-only"),
|
||||
Some(SlashCommand::Permissions {
|
||||
mode: Some("read-only".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/clear"),
|
||||
Some(SlashCommand::Clear { confirm: false })
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/clear --confirm"),
|
||||
Some(SlashCommand::Clear { confirm: true })
|
||||
);
|
||||
assert_eq!(SlashCommand::parse("/cost"), Some(SlashCommand::Cost));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/resume session.json"),
|
||||
Some(SlashCommand::Resume {
|
||||
session_path: Some("session.json".to_string()),
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/config"),
|
||||
Some(SlashCommand::Config { section: None })
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/config env"),
|
||||
Some(SlashCommand::Config {
|
||||
section: Some("env".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(SlashCommand::parse("/memory"), Some(SlashCommand::Memory));
|
||||
assert_eq!(SlashCommand::parse("/init"), Some(SlashCommand::Init));
|
||||
assert_eq!(SlashCommand::parse("/diff"), Some(SlashCommand::Diff));
|
||||
assert_eq!(SlashCommand::parse("/version"), Some(SlashCommand::Version));
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/export notes.txt"),
|
||||
Some(SlashCommand::Export {
|
||||
path: Some("notes.txt".to_string())
|
||||
})
|
||||
);
|
||||
assert_eq!(
|
||||
SlashCommand::parse("/session switch abc123"),
|
||||
Some(SlashCommand::Session {
|
||||
action: Some("switch".to_string()),
|
||||
target: Some("abc123".to_string())
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_help_from_shared_specs() {
|
||||
let help = render_slash_command_help();
|
||||
assert!(help.contains("works with --resume SESSION.json"));
|
||||
assert!(help.contains("/help"));
|
||||
assert!(help.contains("/status"));
|
||||
assert!(help.contains("/compact"));
|
||||
assert!(help.contains("/model [model]"));
|
||||
assert!(help.contains("/permissions [read-only|workspace-write|danger-full-access]"));
|
||||
assert!(help.contains("/clear [--confirm]"));
|
||||
assert!(help.contains("/cost"));
|
||||
assert!(help.contains("/resume <session-path>"));
|
||||
assert!(help.contains("/config [env|hooks|model]"));
|
||||
assert!(help.contains("/memory"));
|
||||
assert!(help.contains("/init"));
|
||||
assert!(help.contains("/diff"));
|
||||
assert!(help.contains("/version"));
|
||||
assert!(help.contains("/export [file]"));
|
||||
assert!(help.contains("/session [list|switch <session-id>]"));
|
||||
assert_eq!(slash_command_specs().len(), 15);
|
||||
assert_eq!(resume_supported_slash_commands().len(), 11);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_sessions_via_slash_command() {
|
||||
let session = Session {
|
||||
version: 1,
|
||||
messages: vec![
|
||||
ConversationMessage::user_text("a ".repeat(200)),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "b ".repeat(200),
|
||||
}]),
|
||||
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "recent".to_string(),
|
||||
}]),
|
||||
],
|
||||
};
|
||||
|
||||
let result = handle_slash_command(
|
||||
"/compact",
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
},
|
||||
)
|
||||
.expect("slash command should be handled");
|
||||
|
||||
assert!(result.message.contains("Compacted 2 messages"));
|
||||
assert_eq!(result.session.messages[0].role, MessageRole::System);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn help_command_is_non_mutating() {
|
||||
let session = Session::new();
|
||||
let result = handle_slash_command("/help", &session, CompactionConfig::default())
|
||||
.expect("help command should be handled");
|
||||
assert_eq!(result.session, session);
|
||||
assert!(result.message.contains("Slash commands"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_unknown_or_runtime_bound_slash_commands() {
|
||||
let session = Session::new();
|
||||
assert!(handle_slash_command("/unknown", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/status", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/model claude", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
assert!(handle_slash_command(
|
||||
"/permissions read-only",
|
||||
&session,
|
||||
CompactionConfig::default()
|
||||
)
|
||||
.is_none());
|
||||
assert!(handle_slash_command("/clear", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/clear --confirm", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/cost", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command(
|
||||
"/resume session.json",
|
||||
&session,
|
||||
CompactionConfig::default()
|
||||
)
|
||||
.is_none());
|
||||
assert!(handle_slash_command("/config", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/config env", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
assert!(handle_slash_command("/diff", &session, CompactionConfig::default()).is_none());
|
||||
assert!(handle_slash_command("/version", &session, CompactionConfig::default()).is_none());
|
||||
assert!(
|
||||
handle_slash_command("/export note.txt", &session, CompactionConfig::default())
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
handle_slash_command("/session list", &session, CompactionConfig::default()).is_none()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -24,9 +24,10 @@ impl UpstreamPaths {
|
||||
.as_ref()
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| workspace_dir.as_ref().to_path_buf());
|
||||
let repo_root = workspace_dir
|
||||
let primary_repo_root = workspace_dir
|
||||
.parent()
|
||||
.map_or_else(|| PathBuf::from(".."), Path::to_path_buf);
|
||||
let repo_root = resolve_upstream_repo_root(&primary_repo_root);
|
||||
Self { repo_root }
|
||||
}
|
||||
|
||||
@@ -53,6 +54,42 @@ pub struct ExtractedManifest {
|
||||
pub bootstrap: BootstrapPlan,
|
||||
}
|
||||
|
||||
fn resolve_upstream_repo_root(primary_repo_root: &Path) -> PathBuf {
|
||||
let candidates = upstream_repo_candidates(primary_repo_root);
|
||||
candidates
|
||||
.into_iter()
|
||||
.find(|candidate| candidate.join("src/commands.ts").is_file())
|
||||
.unwrap_or_else(|| primary_repo_root.to_path_buf())
|
||||
}
|
||||
|
||||
fn upstream_repo_candidates(primary_repo_root: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = vec![primary_repo_root.to_path_buf()];
|
||||
|
||||
if let Some(explicit) = std::env::var_os("CLAUDE_CODE_UPSTREAM") {
|
||||
candidates.push(PathBuf::from(explicit));
|
||||
}
|
||||
|
||||
for ancestor in primary_repo_root.ancestors().take(4) {
|
||||
candidates.push(ancestor.join("claude-code"));
|
||||
candidates.push(ancestor.join("clawd-code"));
|
||||
}
|
||||
|
||||
candidates.push(
|
||||
primary_repo_root
|
||||
.join("reference-source")
|
||||
.join("claude-code"),
|
||||
);
|
||||
candidates.push(primary_repo_root.join("vendor").join("claude-code"));
|
||||
|
||||
let mut deduped = Vec::new();
|
||||
for candidate in candidates {
|
||||
if !deduped.iter().any(|seen: &PathBuf| seen == &candidate) {
|
||||
deduped.push(candidate);
|
||||
}
|
||||
}
|
||||
deduped
|
||||
}
|
||||
|
||||
pub fn extract_manifest(paths: &UpstreamPaths) -> std::io::Result<ExtractedManifest> {
|
||||
let commands_source = fs::read_to_string(paths.commands_path())?;
|
||||
let tools_source = fs::read_to_string(paths.tools_path())?;
|
||||
@@ -270,9 +307,19 @@ mod tests {
|
||||
UpstreamPaths::from_workspace_dir(workspace_dir)
|
||||
}
|
||||
|
||||
fn has_upstream_fixture(paths: &UpstreamPaths) -> bool {
|
||||
paths.commands_path().is_file()
|
||||
&& paths.tools_path().is_file()
|
||||
&& paths.cli_path().is_file()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_non_empty_manifests_from_upstream_repo() {
|
||||
let manifest = extract_manifest(&fixture_paths()).expect("manifest should load");
|
||||
let paths = fixture_paths();
|
||||
if !has_upstream_fixture(&paths) {
|
||||
return;
|
||||
}
|
||||
let manifest = extract_manifest(&paths).expect("manifest should load");
|
||||
assert!(!manifest.commands.entries().is_empty());
|
||||
assert!(!manifest.tools.entries().is_empty());
|
||||
assert!(!manifest.bootstrap.phases().is_empty());
|
||||
@@ -280,9 +327,12 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_known_upstream_command_symbols() {
|
||||
let commands = extract_commands(
|
||||
&fs::read_to_string(fixture_paths().commands_path()).expect("commands.ts"),
|
||||
);
|
||||
let paths = fixture_paths();
|
||||
if !paths.commands_path().is_file() {
|
||||
return;
|
||||
}
|
||||
let commands =
|
||||
extract_commands(&fs::read_to_string(paths.commands_path()).expect("commands.ts"));
|
||||
let names: Vec<_> = commands
|
||||
.entries()
|
||||
.iter()
|
||||
@@ -295,8 +345,11 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn detects_known_upstream_tool_symbols() {
|
||||
let tools =
|
||||
extract_tools(&fs::read_to_string(fixture_paths().tools_path()).expect("tools.ts"));
|
||||
let paths = fixture_paths();
|
||||
if !paths.tools_path().is_file() {
|
||||
return;
|
||||
}
|
||||
let tools = extract_tools(&fs::read_to_string(paths.tools_path()).expect("tools.ts"));
|
||||
let names: Vec<_> = tools
|
||||
.entries()
|
||||
.iter()
|
||||
|
||||
@@ -5,5 +5,14 @@ edition.workspace = true
|
||||
license.workspace = true
|
||||
publish.workspace = true
|
||||
|
||||
[dependencies]
|
||||
sha2 = "0.10"
|
||||
glob = "0.3"
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["io-util", "macros", "process", "rt", "rt-multi-thread", "time"] }
|
||||
walkdir = "2"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
160
rust/crates/runtime/src/bash.rs
Normal file
160
rust/crates/runtime/src/bash.rs
Normal file
@@ -0,0 +1,160 @@
|
||||
use std::io;
|
||||
use std::process::{Command, Stdio};
|
||||
use std::time::Duration;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::process::Command as TokioCommand;
|
||||
use tokio::runtime::Builder;
|
||||
use tokio::time::timeout;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct BashCommandInput {
|
||||
pub command: String,
|
||||
pub timeout: Option<u64>,
|
||||
pub description: Option<String>,
|
||||
#[serde(rename = "run_in_background")]
|
||||
pub run_in_background: Option<bool>,
|
||||
#[serde(rename = "dangerouslyDisableSandbox")]
|
||||
pub dangerously_disable_sandbox: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct BashCommandOutput {
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
#[serde(rename = "rawOutputPath")]
|
||||
pub raw_output_path: Option<String>,
|
||||
pub interrupted: bool,
|
||||
#[serde(rename = "isImage")]
|
||||
pub is_image: Option<bool>,
|
||||
#[serde(rename = "backgroundTaskId")]
|
||||
pub background_task_id: Option<String>,
|
||||
#[serde(rename = "backgroundedByUser")]
|
||||
pub backgrounded_by_user: Option<bool>,
|
||||
#[serde(rename = "assistantAutoBackgrounded")]
|
||||
pub assistant_auto_backgrounded: Option<bool>,
|
||||
#[serde(rename = "dangerouslyDisableSandbox")]
|
||||
pub dangerously_disable_sandbox: Option<bool>,
|
||||
#[serde(rename = "returnCodeInterpretation")]
|
||||
pub return_code_interpretation: Option<String>,
|
||||
#[serde(rename = "noOutputExpected")]
|
||||
pub no_output_expected: Option<bool>,
|
||||
#[serde(rename = "structuredContent")]
|
||||
pub structured_content: Option<Vec<serde_json::Value>>,
|
||||
#[serde(rename = "persistedOutputPath")]
|
||||
pub persisted_output_path: Option<String>,
|
||||
#[serde(rename = "persistedOutputSize")]
|
||||
pub persisted_output_size: Option<u64>,
|
||||
}
|
||||
|
||||
pub fn execute_bash(input: BashCommandInput) -> io::Result<BashCommandOutput> {
|
||||
if input.run_in_background.unwrap_or(false) {
|
||||
let child = Command::new("sh")
|
||||
.arg("-lc")
|
||||
.arg(&input.command)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()?;
|
||||
|
||||
return Ok(BashCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
raw_output_path: None,
|
||||
interrupted: false,
|
||||
is_image: None,
|
||||
background_task_id: Some(child.id().to_string()),
|
||||
backgrounded_by_user: Some(false),
|
||||
assistant_auto_backgrounded: Some(false),
|
||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||
return_code_interpretation: None,
|
||||
no_output_expected: Some(true),
|
||||
structured_content: None,
|
||||
persisted_output_path: None,
|
||||
persisted_output_size: None,
|
||||
});
|
||||
}
|
||||
|
||||
let runtime = Builder::new_current_thread().enable_all().build()?;
|
||||
runtime.block_on(execute_bash_async(input))
|
||||
}
|
||||
|
||||
async fn execute_bash_async(input: BashCommandInput) -> io::Result<BashCommandOutput> {
|
||||
let mut command = TokioCommand::new("sh");
|
||||
command.arg("-lc").arg(&input.command);
|
||||
|
||||
let output_result = if let Some(timeout_ms) = input.timeout {
|
||||
match timeout(Duration::from_millis(timeout_ms), command.output()).await {
|
||||
Ok(result) => (result?, false),
|
||||
Err(_) => {
|
||||
return Ok(BashCommandOutput {
|
||||
stdout: String::new(),
|
||||
stderr: format!("Command exceeded timeout of {timeout_ms} ms"),
|
||||
raw_output_path: None,
|
||||
interrupted: true,
|
||||
is_image: None,
|
||||
background_task_id: None,
|
||||
backgrounded_by_user: None,
|
||||
assistant_auto_backgrounded: None,
|
||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||
return_code_interpretation: Some(String::from("timeout")),
|
||||
no_output_expected: Some(true),
|
||||
structured_content: None,
|
||||
persisted_output_path: None,
|
||||
persisted_output_size: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
} else {
|
||||
(command.output().await?, false)
|
||||
};
|
||||
|
||||
let (output, interrupted) = output_result;
|
||||
let stdout = String::from_utf8_lossy(&output.stdout).into_owned();
|
||||
let stderr = String::from_utf8_lossy(&output.stderr).into_owned();
|
||||
let no_output_expected = Some(stdout.trim().is_empty() && stderr.trim().is_empty());
|
||||
let return_code_interpretation = output.status.code().and_then(|code| {
|
||||
if code == 0 {
|
||||
None
|
||||
} else {
|
||||
Some(format!("exit_code:{code}"))
|
||||
}
|
||||
});
|
||||
|
||||
Ok(BashCommandOutput {
|
||||
stdout,
|
||||
stderr,
|
||||
raw_output_path: None,
|
||||
interrupted,
|
||||
is_image: None,
|
||||
background_task_id: None,
|
||||
backgrounded_by_user: None,
|
||||
assistant_auto_backgrounded: None,
|
||||
dangerously_disable_sandbox: input.dangerously_disable_sandbox,
|
||||
return_code_interpretation,
|
||||
no_output_expected,
|
||||
structured_content: None,
|
||||
persisted_output_path: None,
|
||||
persisted_output_size: None,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{execute_bash, BashCommandInput};
|
||||
|
||||
#[test]
|
||||
fn executes_simple_command() {
|
||||
let output = execute_bash(BashCommandInput {
|
||||
command: String::from("printf 'hello'"),
|
||||
timeout: Some(1_000),
|
||||
description: None,
|
||||
run_in_background: Some(false),
|
||||
dangerously_disable_sandbox: Some(false),
|
||||
})
|
||||
.expect("bash command should execute");
|
||||
|
||||
assert_eq!(output.stdout, "hello");
|
||||
assert!(!output.interrupted);
|
||||
}
|
||||
}
|
||||
56
rust/crates/runtime/src/bootstrap.rs
Normal file
56
rust/crates/runtime/src/bootstrap.rs
Normal file
@@ -0,0 +1,56 @@
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum BootstrapPhase {
|
||||
CliEntry,
|
||||
FastPathVersion,
|
||||
StartupProfiler,
|
||||
SystemPromptFastPath,
|
||||
ChromeMcpFastPath,
|
||||
DaemonWorkerFastPath,
|
||||
BridgeFastPath,
|
||||
DaemonFastPath,
|
||||
BackgroundSessionFastPath,
|
||||
TemplateFastPath,
|
||||
EnvironmentRunnerFastPath,
|
||||
MainRuntime,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct BootstrapPlan {
|
||||
phases: Vec<BootstrapPhase>,
|
||||
}
|
||||
|
||||
impl BootstrapPlan {
|
||||
#[must_use]
|
||||
pub fn claude_code_default() -> Self {
|
||||
Self::from_phases(vec![
|
||||
BootstrapPhase::CliEntry,
|
||||
BootstrapPhase::FastPathVersion,
|
||||
BootstrapPhase::StartupProfiler,
|
||||
BootstrapPhase::SystemPromptFastPath,
|
||||
BootstrapPhase::ChromeMcpFastPath,
|
||||
BootstrapPhase::DaemonWorkerFastPath,
|
||||
BootstrapPhase::BridgeFastPath,
|
||||
BootstrapPhase::DaemonFastPath,
|
||||
BootstrapPhase::BackgroundSessionFastPath,
|
||||
BootstrapPhase::TemplateFastPath,
|
||||
BootstrapPhase::EnvironmentRunnerFastPath,
|
||||
BootstrapPhase::MainRuntime,
|
||||
])
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn from_phases(phases: Vec<BootstrapPhase>) -> Self {
|
||||
let mut deduped = Vec::new();
|
||||
for phase in phases {
|
||||
if !deduped.contains(&phase) {
|
||||
deduped.push(phase);
|
||||
}
|
||||
}
|
||||
Self { phases: deduped }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn phases(&self) -> &[BootstrapPhase] {
|
||||
&self.phases
|
||||
}
|
||||
}
|
||||
485
rust/crates/runtime/src/compact.rs
Normal file
485
rust/crates/runtime/src/compact.rs
Normal file
@@ -0,0 +1,485 @@
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct CompactionConfig {
|
||||
pub preserve_recent_messages: usize,
|
||||
pub max_estimated_tokens: usize,
|
||||
}
|
||||
|
||||
impl Default for CompactionConfig {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
preserve_recent_messages: 4,
|
||||
max_estimated_tokens: 10_000,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct CompactionResult {
|
||||
pub summary: String,
|
||||
pub formatted_summary: String,
|
||||
pub compacted_session: Session,
|
||||
pub removed_message_count: usize,
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimate_session_tokens(session: &Session) -> usize {
|
||||
session.messages.iter().map(estimate_message_tokens).sum()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn should_compact(session: &Session, config: CompactionConfig) -> bool {
|
||||
session.messages.len() > config.preserve_recent_messages
|
||||
&& estimate_session_tokens(session) >= config.max_estimated_tokens
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn format_compact_summary(summary: &str) -> String {
|
||||
let without_analysis = strip_tag_block(summary, "analysis");
|
||||
let formatted = if let Some(content) = extract_tag_block(&without_analysis, "summary") {
|
||||
without_analysis.replace(
|
||||
&format!("<summary>{content}</summary>"),
|
||||
&format!("Summary:\n{}", content.trim()),
|
||||
)
|
||||
} else {
|
||||
without_analysis
|
||||
};
|
||||
|
||||
collapse_blank_lines(&formatted).trim().to_string()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get_compact_continuation_message(
|
||||
summary: &str,
|
||||
suppress_follow_up_questions: bool,
|
||||
recent_messages_preserved: bool,
|
||||
) -> String {
|
||||
let mut base = format!(
|
||||
"This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\n{}",
|
||||
format_compact_summary(summary)
|
||||
);
|
||||
|
||||
if recent_messages_preserved {
|
||||
base.push_str("\n\nRecent messages are preserved verbatim.");
|
||||
}
|
||||
|
||||
if suppress_follow_up_questions {
|
||||
base.push_str("\nContinue the conversation from where it left off without asking the user any further questions. Resume directly — do not acknowledge the summary, do not recap what was happening, and do not preface with continuation text.");
|
||||
}
|
||||
|
||||
base
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn compact_session(session: &Session, config: CompactionConfig) -> CompactionResult {
|
||||
if !should_compact(session, config) {
|
||||
return CompactionResult {
|
||||
summary: String::new(),
|
||||
formatted_summary: String::new(),
|
||||
compacted_session: session.clone(),
|
||||
removed_message_count: 0,
|
||||
};
|
||||
}
|
||||
|
||||
let keep_from = session
|
||||
.messages
|
||||
.len()
|
||||
.saturating_sub(config.preserve_recent_messages);
|
||||
let removed = &session.messages[..keep_from];
|
||||
let preserved = session.messages[keep_from..].to_vec();
|
||||
let summary = summarize_messages(removed);
|
||||
let formatted_summary = format_compact_summary(&summary);
|
||||
let continuation = get_compact_continuation_message(&summary, true, !preserved.is_empty());
|
||||
|
||||
let mut compacted_messages = vec![ConversationMessage {
|
||||
role: MessageRole::System,
|
||||
blocks: vec![ContentBlock::Text { text: continuation }],
|
||||
usage: None,
|
||||
}];
|
||||
compacted_messages.extend(preserved);
|
||||
|
||||
CompactionResult {
|
||||
summary,
|
||||
formatted_summary,
|
||||
compacted_session: Session {
|
||||
version: session.version,
|
||||
messages: compacted_messages,
|
||||
},
|
||||
removed_message_count: removed.len(),
|
||||
}
|
||||
}
|
||||
|
||||
fn summarize_messages(messages: &[ConversationMessage]) -> String {
|
||||
let user_messages = messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::User)
|
||||
.count();
|
||||
let assistant_messages = messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::Assistant)
|
||||
.count();
|
||||
let tool_messages = messages
|
||||
.iter()
|
||||
.filter(|message| message.role == MessageRole::Tool)
|
||||
.count();
|
||||
|
||||
let mut tool_names = messages
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { name, .. } => Some(name.as_str()),
|
||||
ContentBlock::ToolResult { tool_name, .. } => Some(tool_name.as_str()),
|
||||
ContentBlock::Text { .. } => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
tool_names.sort_unstable();
|
||||
tool_names.dedup();
|
||||
|
||||
let mut lines = vec![
|
||||
"<summary>".to_string(),
|
||||
"Conversation summary:".to_string(),
|
||||
format!(
|
||||
"- Scope: {} earlier messages compacted (user={}, assistant={}, tool={}).",
|
||||
messages.len(),
|
||||
user_messages,
|
||||
assistant_messages,
|
||||
tool_messages
|
||||
),
|
||||
];
|
||||
|
||||
if !tool_names.is_empty() {
|
||||
lines.push(format!("- Tools mentioned: {}.", tool_names.join(", ")));
|
||||
}
|
||||
|
||||
let recent_user_requests = collect_recent_role_summaries(messages, MessageRole::User, 3);
|
||||
if !recent_user_requests.is_empty() {
|
||||
lines.push("- Recent user requests:".to_string());
|
||||
lines.extend(
|
||||
recent_user_requests
|
||||
.into_iter()
|
||||
.map(|request| format!(" - {request}")),
|
||||
);
|
||||
}
|
||||
|
||||
let pending_work = infer_pending_work(messages);
|
||||
if !pending_work.is_empty() {
|
||||
lines.push("- Pending work:".to_string());
|
||||
lines.extend(pending_work.into_iter().map(|item| format!(" - {item}")));
|
||||
}
|
||||
|
||||
let key_files = collect_key_files(messages);
|
||||
if !key_files.is_empty() {
|
||||
lines.push(format!("- Key files referenced: {}.", key_files.join(", ")));
|
||||
}
|
||||
|
||||
if let Some(current_work) = infer_current_work(messages) {
|
||||
lines.push(format!("- Current work: {current_work}"));
|
||||
}
|
||||
|
||||
lines.push("- Key timeline:".to_string());
|
||||
for message in messages {
|
||||
let role = match message.role {
|
||||
MessageRole::System => "system",
|
||||
MessageRole::User => "user",
|
||||
MessageRole::Assistant => "assistant",
|
||||
MessageRole::Tool => "tool",
|
||||
};
|
||||
let content = message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(summarize_block)
|
||||
.collect::<Vec<_>>()
|
||||
.join(" | ");
|
||||
lines.push(format!(" - {role}: {content}"));
|
||||
}
|
||||
lines.push("</summary>".to_string());
|
||||
lines.join("\n")
|
||||
}
|
||||
|
||||
fn summarize_block(block: &ContentBlock) -> String {
|
||||
let raw = match block {
|
||||
ContentBlock::Text { text } => text.clone(),
|
||||
ContentBlock::ToolUse { name, input, .. } => format!("tool_use {name}({input})"),
|
||||
ContentBlock::ToolResult {
|
||||
tool_name,
|
||||
output,
|
||||
is_error,
|
||||
..
|
||||
} => format!(
|
||||
"tool_result {tool_name}: {}{output}",
|
||||
if *is_error { "error " } else { "" }
|
||||
),
|
||||
};
|
||||
truncate_summary(&raw, 160)
|
||||
}
|
||||
|
||||
fn collect_recent_role_summaries(
|
||||
messages: &[ConversationMessage],
|
||||
role: MessageRole,
|
||||
limit: usize,
|
||||
) -> Vec<String> {
|
||||
messages
|
||||
.iter()
|
||||
.filter(|message| message.role == role)
|
||||
.rev()
|
||||
.filter_map(|message| first_text_block(message))
|
||||
.take(limit)
|
||||
.map(|text| truncate_summary(text, 160))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn infer_pending_work(messages: &[ConversationMessage]) -> Vec<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(first_text_block)
|
||||
.filter(|text| {
|
||||
let lowered = text.to_ascii_lowercase();
|
||||
lowered.contains("todo")
|
||||
|| lowered.contains("next")
|
||||
|| lowered.contains("pending")
|
||||
|| lowered.contains("follow up")
|
||||
|| lowered.contains("remaining")
|
||||
})
|
||||
.take(3)
|
||||
.map(|text| truncate_summary(text, 160))
|
||||
.collect::<Vec<_>>()
|
||||
.into_iter()
|
||||
.rev()
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn collect_key_files(messages: &[ConversationMessage]) -> Vec<String> {
|
||||
let mut files = messages
|
||||
.iter()
|
||||
.flat_map(|message| message.blocks.iter())
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => text.as_str(),
|
||||
ContentBlock::ToolUse { input, .. } => input.as_str(),
|
||||
ContentBlock::ToolResult { output, .. } => output.as_str(),
|
||||
})
|
||||
.flat_map(extract_file_candidates)
|
||||
.collect::<Vec<_>>();
|
||||
files.sort();
|
||||
files.dedup();
|
||||
files.into_iter().take(8).collect()
|
||||
}
|
||||
|
||||
fn infer_current_work(messages: &[ConversationMessage]) -> Option<String> {
|
||||
messages
|
||||
.iter()
|
||||
.rev()
|
||||
.filter_map(first_text_block)
|
||||
.find(|text| !text.trim().is_empty())
|
||||
.map(|text| truncate_summary(text, 200))
|
||||
}
|
||||
|
||||
fn first_text_block(message: &ConversationMessage) -> Option<&str> {
|
||||
message.blocks.iter().find_map(|block| match block {
|
||||
ContentBlock::Text { text } if !text.trim().is_empty() => Some(text.as_str()),
|
||||
ContentBlock::ToolUse { .. }
|
||||
| ContentBlock::ToolResult { .. }
|
||||
| ContentBlock::Text { .. } => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn has_interesting_extension(candidate: &str) -> bool {
|
||||
std::path::Path::new(candidate)
|
||||
.extension()
|
||||
.and_then(|extension| extension.to_str())
|
||||
.is_some_and(|extension| {
|
||||
["rs", "ts", "tsx", "js", "json", "md"]
|
||||
.iter()
|
||||
.any(|expected| extension.eq_ignore_ascii_case(expected))
|
||||
})
|
||||
}
|
||||
|
||||
fn extract_file_candidates(content: &str) -> Vec<String> {
|
||||
content
|
||||
.split_whitespace()
|
||||
.filter_map(|token| {
|
||||
let candidate = token.trim_matches(|char: char| {
|
||||
matches!(char, ',' | '.' | ':' | ';' | ')' | '(' | '"' | '\'' | '`')
|
||||
});
|
||||
if candidate.contains('/') && has_interesting_extension(candidate) {
|
||||
Some(candidate.to_string())
|
||||
} else {
|
||||
None
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn truncate_summary(content: &str, max_chars: usize) -> String {
|
||||
if content.chars().count() <= max_chars {
|
||||
return content.to_string();
|
||||
}
|
||||
let mut truncated = content.chars().take(max_chars).collect::<String>();
|
||||
truncated.push('…');
|
||||
truncated
|
||||
}
|
||||
|
||||
fn estimate_message_tokens(message: &ConversationMessage) -> usize {
|
||||
message
|
||||
.blocks
|
||||
.iter()
|
||||
.map(|block| match block {
|
||||
ContentBlock::Text { text } => text.len() / 4 + 1,
|
||||
ContentBlock::ToolUse { name, input, .. } => (name.len() + input.len()) / 4 + 1,
|
||||
ContentBlock::ToolResult {
|
||||
tool_name, output, ..
|
||||
} => (tool_name.len() + output.len()) / 4 + 1,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn extract_tag_block(content: &str, tag: &str) -> Option<String> {
|
||||
let start = format!("<{tag}>");
|
||||
let end = format!("</{tag}>");
|
||||
let start_index = content.find(&start)? + start.len();
|
||||
let end_index = content[start_index..].find(&end)? + start_index;
|
||||
Some(content[start_index..end_index].to_string())
|
||||
}
|
||||
|
||||
fn strip_tag_block(content: &str, tag: &str) -> String {
|
||||
let start = format!("<{tag}>");
|
||||
let end = format!("</{tag}>");
|
||||
if let (Some(start_index), Some(end_index_rel)) = (content.find(&start), content.find(&end)) {
|
||||
let end_index = end_index_rel + end.len();
|
||||
let mut stripped = String::new();
|
||||
stripped.push_str(&content[..start_index]);
|
||||
stripped.push_str(&content[end_index..]);
|
||||
stripped
|
||||
} else {
|
||||
content.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn collapse_blank_lines(content: &str) -> String {
|
||||
let mut result = String::new();
|
||||
let mut last_blank = false;
|
||||
for line in content.lines() {
|
||||
let is_blank = line.trim().is_empty();
|
||||
if is_blank && last_blank {
|
||||
continue;
|
||||
}
|
||||
result.push_str(line);
|
||||
result.push('\n');
|
||||
last_blank = is_blank;
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
collect_key_files, compact_session, estimate_session_tokens, format_compact_summary,
|
||||
infer_pending_work, should_compact, CompactionConfig,
|
||||
};
|
||||
use crate::session::{ContentBlock, ConversationMessage, MessageRole, Session};
|
||||
|
||||
#[test]
|
||||
fn formats_compact_summary_like_upstream() {
|
||||
let summary = "<analysis>scratch</analysis>\n<summary>Kept work</summary>";
|
||||
assert_eq!(format_compact_summary(summary), "Summary:\nKept work");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leaves_small_sessions_unchanged() {
|
||||
let session = Session {
|
||||
version: 1,
|
||||
messages: vec![ConversationMessage::user_text("hello")],
|
||||
};
|
||||
|
||||
let result = compact_session(&session, CompactionConfig::default());
|
||||
assert_eq!(result.removed_message_count, 0);
|
||||
assert_eq!(result.compacted_session, session);
|
||||
assert!(result.summary.is_empty());
|
||||
assert!(result.formatted_summary.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_older_messages_into_a_system_summary() {
|
||||
let session = Session {
|
||||
version: 1,
|
||||
messages: vec![
|
||||
ConversationMessage::user_text("one ".repeat(200)),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "two ".repeat(200),
|
||||
}]),
|
||||
ConversationMessage::tool_result("1", "bash", "ok ".repeat(200), false),
|
||||
ConversationMessage {
|
||||
role: MessageRole::Assistant,
|
||||
blocks: vec![ContentBlock::Text {
|
||||
text: "recent".to_string(),
|
||||
}],
|
||||
usage: None,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
let result = compact_session(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
},
|
||||
);
|
||||
|
||||
assert_eq!(result.removed_message_count, 2);
|
||||
assert_eq!(
|
||||
result.compacted_session.messages[0].role,
|
||||
MessageRole::System
|
||||
);
|
||||
assert!(matches!(
|
||||
&result.compacted_session.messages[0].blocks[0],
|
||||
ContentBlock::Text { text } if text.contains("Summary:")
|
||||
));
|
||||
assert!(result.formatted_summary.contains("Scope:"));
|
||||
assert!(result.formatted_summary.contains("Key timeline:"));
|
||||
assert!(should_compact(
|
||||
&session,
|
||||
CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
}
|
||||
));
|
||||
assert!(
|
||||
estimate_session_tokens(&result.compacted_session) < estimate_session_tokens(&session)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncates_long_blocks_in_summary() {
|
||||
let summary = super::summarize_block(&ContentBlock::Text {
|
||||
text: "x".repeat(400),
|
||||
});
|
||||
assert!(summary.ends_with('…'));
|
||||
assert!(summary.chars().count() <= 161);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn extracts_key_files_from_message_content() {
|
||||
let files = collect_key_files(&[ConversationMessage::user_text(
|
||||
"Update rust/crates/runtime/src/compact.rs and rust/crates/rusty-claude-cli/src/main.rs next.",
|
||||
)]);
|
||||
assert!(files.contains(&"rust/crates/runtime/src/compact.rs".to_string()));
|
||||
assert!(files.contains(&"rust/crates/rusty-claude-cli/src/main.rs".to_string()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn infers_pending_work_from_recent_messages() {
|
||||
let pending = infer_pending_work(&[
|
||||
ConversationMessage::user_text("done"),
|
||||
ConversationMessage::assistant(vec![ContentBlock::Text {
|
||||
text: "Next: update tests and follow up on remaining CLI polish.".to_string(),
|
||||
}]),
|
||||
]);
|
||||
assert_eq!(pending.len(), 1);
|
||||
assert!(pending[0].contains("Next: update tests"));
|
||||
}
|
||||
}
|
||||
908
rust/crates/runtime/src/config.rs
Normal file
908
rust/crates/runtime/src/config.rs
Normal file
@@ -0,0 +1,908 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use crate::json::JsonValue;
|
||||
|
||||
pub const CLAUDE_CODE_SETTINGS_SCHEMA_NAME: &str = "SettingsSchema";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
|
||||
pub enum ConfigSource {
|
||||
User,
|
||||
Project,
|
||||
Local,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResolvedPermissionMode {
|
||||
ReadOnly,
|
||||
WorkspaceWrite,
|
||||
DangerFullAccess,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigEntry {
|
||||
pub source: ConfigSource,
|
||||
pub path: PathBuf,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeConfig {
|
||||
merged: BTreeMap<String, JsonValue>,
|
||||
loaded_entries: Vec<ConfigEntry>,
|
||||
feature_config: RuntimeFeatureConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection,
|
||||
oauth: Option<OAuthConfig>,
|
||||
model: Option<String>,
|
||||
permission_mode: Option<ResolvedPermissionMode>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Default)]
|
||||
pub struct McpConfigCollection {
|
||||
servers: BTreeMap<String, ScopedMcpServerConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ScopedMcpServerConfig {
|
||||
pub scope: ConfigSource,
|
||||
pub config: McpServerConfig,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum McpTransport {
|
||||
Stdio,
|
||||
Sse,
|
||||
Http,
|
||||
Ws,
|
||||
Sdk,
|
||||
ClaudeAiProxy,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum McpServerConfig {
|
||||
Stdio(McpStdioServerConfig),
|
||||
Sse(McpRemoteServerConfig),
|
||||
Http(McpRemoteServerConfig),
|
||||
Ws(McpWebSocketServerConfig),
|
||||
Sdk(McpSdkServerConfig),
|
||||
ClaudeAiProxy(McpClaudeAiProxyServerConfig),
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpStdioServerConfig {
|
||||
pub command: String,
|
||||
pub args: Vec<String>,
|
||||
pub env: BTreeMap<String, String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpRemoteServerConfig {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub headers_helper: Option<String>,
|
||||
pub oauth: Option<McpOAuthConfig>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpWebSocketServerConfig {
|
||||
pub url: String,
|
||||
pub headers: BTreeMap<String, String>,
|
||||
pub headers_helper: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpSdkServerConfig {
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpClaudeAiProxyServerConfig {
|
||||
pub url: String,
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct McpOAuthConfig {
|
||||
pub client_id: Option<String>,
|
||||
pub callback_port: Option<u16>,
|
||||
pub auth_server_metadata_url: Option<String>,
|
||||
pub xaa: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct OAuthConfig {
|
||||
pub client_id: String,
|
||||
pub authorize_url: String,
|
||||
pub token_url: String,
|
||||
pub callback_port: Option<u16>,
|
||||
pub manual_redirect_url: Option<String>,
|
||||
pub scopes: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ConfigError {
|
||||
Io(std::io::Error),
|
||||
Parse(String),
|
||||
}
|
||||
|
||||
impl Display for ConfigError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Self::Io(error) => write!(f, "{error}"),
|
||||
Self::Parse(error) => write!(f, "{error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ConfigError {}
|
||||
|
||||
impl From<std::io::Error> for ConfigError {
|
||||
fn from(value: std::io::Error) -> Self {
|
||||
Self::Io(value)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ConfigLoader {
|
||||
cwd: PathBuf,
|
||||
config_home: PathBuf,
|
||||
}
|
||||
|
||||
impl ConfigLoader {
|
||||
#[must_use]
|
||||
pub fn new(cwd: impl Into<PathBuf>, config_home: impl Into<PathBuf>) -> Self {
|
||||
Self {
|
||||
cwd: cwd.into(),
|
||||
config_home: config_home.into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn default_for(cwd: impl Into<PathBuf>) -> Self {
|
||||
let cwd = cwd.into();
|
||||
let config_home = std::env::var_os("CLAUDE_CONFIG_HOME")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".claude")))
|
||||
.unwrap_or_else(|| PathBuf::from(".claude"));
|
||||
Self { cwd, config_home }
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn discover(&self) -> Vec<ConfigEntry> {
|
||||
let user_legacy_path = self.config_home.parent().map_or_else(
|
||||
|| PathBuf::from(".claude.json"),
|
||||
|parent| parent.join(".claude.json"),
|
||||
);
|
||||
vec![
|
||||
ConfigEntry {
|
||||
source: ConfigSource::User,
|
||||
path: user_legacy_path,
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::User,
|
||||
path: self.config_home.join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Project,
|
||||
path: self.cwd.join(".claude").join("settings.json"),
|
||||
},
|
||||
ConfigEntry {
|
||||
source: ConfigSource::Local,
|
||||
path: self.cwd.join(".claude").join("settings.local.json"),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
pub fn load(&self) -> Result<RuntimeConfig, ConfigError> {
|
||||
let mut merged = BTreeMap::new();
|
||||
let mut loaded_entries = Vec::new();
|
||||
let mut mcp_servers = BTreeMap::new();
|
||||
|
||||
for entry in self.discover() {
|
||||
let Some(value) = read_optional_json_object(&entry.path)? else {
|
||||
continue;
|
||||
};
|
||||
merge_mcp_servers(&mut mcp_servers, entry.source, &value, &entry.path)?;
|
||||
deep_merge_objects(&mut merged, &value);
|
||||
loaded_entries.push(entry);
|
||||
}
|
||||
|
||||
let merged_value = JsonValue::Object(merged.clone());
|
||||
|
||||
let feature_config = RuntimeFeatureConfig {
|
||||
mcp: McpConfigCollection {
|
||||
servers: mcp_servers,
|
||||
},
|
||||
oauth: parse_optional_oauth_config(&merged_value, "merged settings.oauth")?,
|
||||
model: parse_optional_model(&merged_value),
|
||||
permission_mode: parse_optional_permission_mode(&merged_value)?,
|
||||
};
|
||||
|
||||
Ok(RuntimeConfig {
|
||||
merged,
|
||||
loaded_entries,
|
||||
feature_config,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeConfig {
|
||||
#[must_use]
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
merged: BTreeMap::new(),
|
||||
loaded_entries: Vec::new(),
|
||||
feature_config: RuntimeFeatureConfig::default(),
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn merged(&self) -> &BTreeMap<String, JsonValue> {
|
||||
&self.merged
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn loaded_entries(&self) -> &[ConfigEntry] {
|
||||
&self.loaded_entries
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, key: &str) -> Option<&JsonValue> {
|
||||
self.merged.get(key)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn as_json(&self) -> JsonValue {
|
||||
JsonValue::Object(self.merged.clone())
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn feature_config(&self) -> &RuntimeFeatureConfig {
|
||||
&self.feature_config
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn mcp(&self) -> &McpConfigCollection {
|
||||
&self.feature_config.mcp
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.feature_config.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.feature_config.model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
||||
self.feature_config.permission_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl RuntimeFeatureConfig {
|
||||
#[must_use]
|
||||
pub fn mcp(&self) -> &McpConfigCollection {
|
||||
&self.mcp
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn oauth(&self) -> Option<&OAuthConfig> {
|
||||
self.oauth.as_ref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn model(&self) -> Option<&str> {
|
||||
self.model.as_deref()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn permission_mode(&self) -> Option<ResolvedPermissionMode> {
|
||||
self.permission_mode
|
||||
}
|
||||
}
|
||||
|
||||
impl McpConfigCollection {
|
||||
#[must_use]
|
||||
pub fn servers(&self) -> &BTreeMap<String, ScopedMcpServerConfig> {
|
||||
&self.servers
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn get(&self, name: &str) -> Option<&ScopedMcpServerConfig> {
|
||||
self.servers.get(name)
|
||||
}
|
||||
}
|
||||
|
||||
impl ScopedMcpServerConfig {
|
||||
#[must_use]
|
||||
pub fn transport(&self) -> McpTransport {
|
||||
self.config.transport()
|
||||
}
|
||||
}
|
||||
|
||||
impl McpServerConfig {
|
||||
#[must_use]
|
||||
pub fn transport(&self) -> McpTransport {
|
||||
match self {
|
||||
Self::Stdio(_) => McpTransport::Stdio,
|
||||
Self::Sse(_) => McpTransport::Sse,
|
||||
Self::Http(_) => McpTransport::Http,
|
||||
Self::Ws(_) => McpTransport::Ws,
|
||||
Self::Sdk(_) => McpTransport::Sdk,
|
||||
Self::ClaudeAiProxy(_) => McpTransport::ClaudeAiProxy,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn read_optional_json_object(
|
||||
path: &Path,
|
||||
) -> Result<Option<BTreeMap<String, JsonValue>>, ConfigError> {
|
||||
let is_legacy_config = path.file_name().and_then(|name| name.to_str()) == Some(".claude.json");
|
||||
let contents = match fs::read_to_string(path) {
|
||||
Ok(contents) => contents,
|
||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(error) => return Err(ConfigError::Io(error)),
|
||||
};
|
||||
|
||||
if contents.trim().is_empty() {
|
||||
return Ok(Some(BTreeMap::new()));
|
||||
}
|
||||
|
||||
let parsed = match JsonValue::parse(&contents) {
|
||||
Ok(parsed) => parsed,
|
||||
Err(error) if is_legacy_config => return Ok(None),
|
||||
Err(error) => return Err(ConfigError::Parse(format!("{}: {error}", path.display()))),
|
||||
};
|
||||
let Some(object) = parsed.as_object() else {
|
||||
if is_legacy_config {
|
||||
return Ok(None);
|
||||
}
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{}: top-level settings value must be a JSON object",
|
||||
path.display()
|
||||
)));
|
||||
};
|
||||
Ok(Some(object.clone()))
|
||||
}
|
||||
|
||||
fn merge_mcp_servers(
|
||||
target: &mut BTreeMap<String, ScopedMcpServerConfig>,
|
||||
source: ConfigSource,
|
||||
root: &BTreeMap<String, JsonValue>,
|
||||
path: &Path,
|
||||
) -> Result<(), ConfigError> {
|
||||
let Some(mcp_servers) = root.get("mcpServers") else {
|
||||
return Ok(());
|
||||
};
|
||||
let servers = expect_object(mcp_servers, &format!("{}: mcpServers", path.display()))?;
|
||||
for (name, value) in servers {
|
||||
let parsed = parse_mcp_server_config(
|
||||
name,
|
||||
value,
|
||||
&format!("{}: mcpServers.{name}", path.display()),
|
||||
)?;
|
||||
target.insert(
|
||||
name.clone(),
|
||||
ScopedMcpServerConfig {
|
||||
scope: source,
|
||||
config: parsed,
|
||||
},
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_optional_model(root: &JsonValue) -> Option<String> {
|
||||
root.as_object()
|
||||
.and_then(|object| object.get("model"))
|
||||
.and_then(JsonValue::as_str)
|
||||
.map(ToOwned::to_owned)
|
||||
}
|
||||
|
||||
fn parse_optional_permission_mode(
|
||||
root: &JsonValue,
|
||||
) -> Result<Option<ResolvedPermissionMode>, ConfigError> {
|
||||
let Some(object) = root.as_object() else {
|
||||
return Ok(None);
|
||||
};
|
||||
if let Some(mode) = object.get("permissionMode").and_then(JsonValue::as_str) {
|
||||
return parse_permission_mode_label(mode, "merged settings.permissionMode").map(Some);
|
||||
}
|
||||
let Some(mode) = object
|
||||
.get("permissions")
|
||||
.and_then(JsonValue::as_object)
|
||||
.and_then(|permissions| permissions.get("defaultMode"))
|
||||
.and_then(JsonValue::as_str)
|
||||
else {
|
||||
return Ok(None);
|
||||
};
|
||||
parse_permission_mode_label(mode, "merged settings.permissions.defaultMode").map(Some)
|
||||
}
|
||||
|
||||
fn parse_permission_mode_label(
|
||||
mode: &str,
|
||||
context: &str,
|
||||
) -> Result<ResolvedPermissionMode, ConfigError> {
|
||||
match mode {
|
||||
"default" | "plan" | "read-only" => Ok(ResolvedPermissionMode::ReadOnly),
|
||||
"acceptEdits" | "auto" | "workspace-write" => Ok(ResolvedPermissionMode::WorkspaceWrite),
|
||||
"dontAsk" | "danger-full-access" => Ok(ResolvedPermissionMode::DangerFullAccess),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported permission mode {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_optional_oauth_config(
|
||||
root: &JsonValue,
|
||||
context: &str,
|
||||
) -> Result<Option<OAuthConfig>, ConfigError> {
|
||||
let Some(oauth_value) = root.as_object().and_then(|object| object.get("oauth")) else {
|
||||
return Ok(None);
|
||||
};
|
||||
let object = expect_object(oauth_value, context)?;
|
||||
let client_id = expect_string(object, "clientId", context)?.to_string();
|
||||
let authorize_url = expect_string(object, "authorizeUrl", context)?.to_string();
|
||||
let token_url = expect_string(object, "tokenUrl", context)?.to_string();
|
||||
let callback_port = optional_u16(object, "callbackPort", context)?;
|
||||
let manual_redirect_url =
|
||||
optional_string(object, "manualRedirectUrl", context)?.map(str::to_string);
|
||||
let scopes = optional_string_array(object, "scopes", context)?.unwrap_or_default();
|
||||
Ok(Some(OAuthConfig {
|
||||
client_id,
|
||||
authorize_url,
|
||||
token_url,
|
||||
callback_port,
|
||||
manual_redirect_url,
|
||||
scopes,
|
||||
}))
|
||||
}
|
||||
|
||||
fn parse_mcp_server_config(
|
||||
server_name: &str,
|
||||
value: &JsonValue,
|
||||
context: &str,
|
||||
) -> Result<McpServerConfig, ConfigError> {
|
||||
let object = expect_object(value, context)?;
|
||||
let server_type = optional_string(object, "type", context)?.unwrap_or("stdio");
|
||||
match server_type {
|
||||
"stdio" => Ok(McpServerConfig::Stdio(McpStdioServerConfig {
|
||||
command: expect_string(object, "command", context)?.to_string(),
|
||||
args: optional_string_array(object, "args", context)?.unwrap_or_default(),
|
||||
env: optional_string_map(object, "env", context)?.unwrap_or_default(),
|
||||
})),
|
||||
"sse" => Ok(McpServerConfig::Sse(parse_mcp_remote_server_config(
|
||||
object, context,
|
||||
)?)),
|
||||
"http" => Ok(McpServerConfig::Http(parse_mcp_remote_server_config(
|
||||
object, context,
|
||||
)?)),
|
||||
"ws" => Ok(McpServerConfig::Ws(McpWebSocketServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
||||
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
||||
})),
|
||||
"sdk" => Ok(McpServerConfig::Sdk(McpSdkServerConfig {
|
||||
name: expect_string(object, "name", context)?.to_string(),
|
||||
})),
|
||||
"claudeai-proxy" => Ok(McpServerConfig::ClaudeAiProxy(
|
||||
McpClaudeAiProxyServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
id: expect_string(object, "id", context)?.to_string(),
|
||||
},
|
||||
)),
|
||||
other => Err(ConfigError::Parse(format!(
|
||||
"{context}: unsupported MCP server type for {server_name}: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_mcp_remote_server_config(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<McpRemoteServerConfig, ConfigError> {
|
||||
Ok(McpRemoteServerConfig {
|
||||
url: expect_string(object, "url", context)?.to_string(),
|
||||
headers: optional_string_map(object, "headers", context)?.unwrap_or_default(),
|
||||
headers_helper: optional_string(object, "headersHelper", context)?.map(str::to_string),
|
||||
oauth: parse_optional_mcp_oauth_config(object, context)?,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_optional_mcp_oauth_config(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
context: &str,
|
||||
) -> Result<Option<McpOAuthConfig>, ConfigError> {
|
||||
let Some(value) = object.get("oauth") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let oauth = expect_object(value, &format!("{context}.oauth"))?;
|
||||
Ok(Some(McpOAuthConfig {
|
||||
client_id: optional_string(oauth, "clientId", context)?.map(str::to_string),
|
||||
callback_port: optional_u16(oauth, "callbackPort", context)?,
|
||||
auth_server_metadata_url: optional_string(oauth, "authServerMetadataUrl", context)?
|
||||
.map(str::to_string),
|
||||
xaa: optional_bool(oauth, "xaa", context)?,
|
||||
}))
|
||||
}
|
||||
|
||||
fn expect_object<'a>(
|
||||
value: &'a JsonValue,
|
||||
context: &str,
|
||||
) -> Result<&'a BTreeMap<String, JsonValue>, ConfigError> {
|
||||
value
|
||||
.as_object()
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: expected JSON object")))
|
||||
}
|
||||
|
||||
fn expect_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<&'a str, ConfigError> {
|
||||
object
|
||||
.get(key)
|
||||
.and_then(JsonValue::as_str)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: missing string field {key}")))
|
||||
}
|
||||
|
||||
fn optional_string<'a>(
|
||||
object: &'a BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<&'a str>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => value
|
||||
.as_str()
|
||||
.map(Some)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a string"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_bool(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<bool>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => value
|
||||
.as_bool()
|
||||
.map(Some)
|
||||
.ok_or_else(|| ConfigError::Parse(format!("{context}: field {key} must be a boolean"))),
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_u16(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<u16>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(number) = value.as_i64() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an integer"
|
||||
)));
|
||||
};
|
||||
let number = u16::try_from(number).map_err(|_| {
|
||||
ConfigError::Parse(format!("{context}: field {key} is out of range"))
|
||||
})?;
|
||||
Ok(Some(number))
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string_array(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<Vec<String>>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(array) = value.as_array() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an array"
|
||||
)));
|
||||
};
|
||||
array
|
||||
.iter()
|
||||
.map(|item| {
|
||||
item.as_str().map(ToOwned::to_owned).ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key} must contain only strings"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn optional_string_map(
|
||||
object: &BTreeMap<String, JsonValue>,
|
||||
key: &str,
|
||||
context: &str,
|
||||
) -> Result<Option<BTreeMap<String, String>>, ConfigError> {
|
||||
match object.get(key) {
|
||||
Some(value) => {
|
||||
let Some(map) = value.as_object() else {
|
||||
return Err(ConfigError::Parse(format!(
|
||||
"{context}: field {key} must be an object"
|
||||
)));
|
||||
};
|
||||
map.iter()
|
||||
.map(|(entry_key, entry_value)| {
|
||||
entry_value
|
||||
.as_str()
|
||||
.map(|text| (entry_key.clone(), text.to_string()))
|
||||
.ok_or_else(|| {
|
||||
ConfigError::Parse(format!(
|
||||
"{context}: field {key} must contain only string values"
|
||||
))
|
||||
})
|
||||
})
|
||||
.collect::<Result<BTreeMap<_, _>, _>>()
|
||||
.map(Some)
|
||||
}
|
||||
None => Ok(None),
|
||||
}
|
||||
}
|
||||
|
||||
fn deep_merge_objects(
|
||||
target: &mut BTreeMap<String, JsonValue>,
|
||||
source: &BTreeMap<String, JsonValue>,
|
||||
) {
|
||||
for (key, value) in source {
|
||||
match (target.get_mut(key), value) {
|
||||
(Some(JsonValue::Object(existing)), JsonValue::Object(incoming)) => {
|
||||
deep_merge_objects(existing, incoming);
|
||||
}
|
||||
_ => {
|
||||
target.insert(key.clone(), value.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ConfigLoader, ConfigSource, McpServerConfig, McpTransport, ResolvedPermissionMode,
|
||||
CLAUDE_CODE_SETTINGS_SCHEMA_NAME,
|
||||
};
|
||||
use crate::json::JsonValue;
|
||||
use std::fs;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
fn temp_dir() -> std::path::PathBuf {
|
||||
let nanos = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should be after epoch")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("runtime-config-{nanos}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_non_object_settings_files() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(home.join("settings.json"), "[]").expect("write bad settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("top-level settings value must be a JSON object"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loads_and_merges_claude_code_config_files_by_precedence() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.parent().expect("home parent").join(".claude.json"),
|
||||
r#"{"model":"haiku","env":{"A":"1"},"mcpServers":{"home":{"command":"uvx","args":["home"]}}}"#,
|
||||
)
|
||||
.expect("write user compat config");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"model":"sonnet","env":{"A2":"1"},"hooks":{"PreToolUse":["base"]},"permissions":{"defaultMode":"plan"}}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude.json"),
|
||||
r#"{"model":"project-compat","env":{"B":"2"}}"#,
|
||||
)
|
||||
.expect("write project compat config");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.json"),
|
||||
r#"{"env":{"C":"3"},"hooks":{"PostToolUse":["project"]},"mcpServers":{"project":{"command":"uvx","args":["project"]}}}"#,
|
||||
)
|
||||
.expect("write project settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
r#"{"model":"opus","permissionMode":"acceptEdits"}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
assert_eq!(CLAUDE_CODE_SETTINGS_SCHEMA_NAME, "SettingsSchema");
|
||||
assert_eq!(loaded.loaded_entries().len(), 5);
|
||||
assert_eq!(loaded.loaded_entries()[0].source, ConfigSource::User);
|
||||
assert_eq!(
|
||||
loaded.get("model"),
|
||||
Some(&JsonValue::String("opus".to_string()))
|
||||
);
|
||||
assert_eq!(loaded.model(), Some("opus"));
|
||||
assert_eq!(
|
||||
loaded.permission_mode(),
|
||||
Some(ResolvedPermissionMode::WorkspaceWrite)
|
||||
);
|
||||
assert_eq!(
|
||||
loaded
|
||||
.get("env")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("env object")
|
||||
.len(),
|
||||
4
|
||||
);
|
||||
assert!(loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PreToolUse"));
|
||||
assert!(loaded
|
||||
.get("hooks")
|
||||
.and_then(JsonValue::as_object)
|
||||
.expect("hooks object")
|
||||
.contains_key("PostToolUse"));
|
||||
assert!(loaded.mcp().get("home").is_some());
|
||||
assert!(loaded.mcp().get("project").is_some());
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_typed_mcp_and_oauth_config() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(cwd.join(".claude")).expect("project config dir");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"stdio-server": {
|
||||
"command": "uvx",
|
||||
"args": ["mcp-server"],
|
||||
"env": {"TOKEN": "secret"}
|
||||
},
|
||||
"remote-server": {
|
||||
"type": "http",
|
||||
"url": "https://example.test/mcp",
|
||||
"headers": {"Authorization": "Bearer token"},
|
||||
"headersHelper": "helper.sh",
|
||||
"oauth": {
|
||||
"clientId": "mcp-client",
|
||||
"callbackPort": 7777,
|
||||
"authServerMetadataUrl": "https://issuer.test/.well-known/oauth-authorization-server",
|
||||
"xaa": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"oauth": {
|
||||
"clientId": "runtime-client",
|
||||
"authorizeUrl": "https://console.test/oauth/authorize",
|
||||
"tokenUrl": "https://console.test/oauth/token",
|
||||
"callbackPort": 54545,
|
||||
"manualRedirectUrl": "https://console.test/oauth/callback",
|
||||
"scopes": ["org:read", "user:write"]
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write user settings");
|
||||
fs::write(
|
||||
cwd.join(".claude").join("settings.local.json"),
|
||||
r#"{
|
||||
"mcpServers": {
|
||||
"remote-server": {
|
||||
"type": "ws",
|
||||
"url": "wss://override.test/mcp",
|
||||
"headers": {"X-Env": "local"}
|
||||
}
|
||||
}
|
||||
}"#,
|
||||
)
|
||||
.expect("write local settings");
|
||||
|
||||
let loaded = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect("config should load");
|
||||
|
||||
let stdio_server = loaded
|
||||
.mcp()
|
||||
.get("stdio-server")
|
||||
.expect("stdio server should exist");
|
||||
assert_eq!(stdio_server.scope, ConfigSource::User);
|
||||
assert_eq!(stdio_server.transport(), McpTransport::Stdio);
|
||||
|
||||
let remote_server = loaded
|
||||
.mcp()
|
||||
.get("remote-server")
|
||||
.expect("remote server should exist");
|
||||
assert_eq!(remote_server.scope, ConfigSource::Local);
|
||||
assert_eq!(remote_server.transport(), McpTransport::Ws);
|
||||
match &remote_server.config {
|
||||
McpServerConfig::Ws(config) => {
|
||||
assert_eq!(config.url, "wss://override.test/mcp");
|
||||
assert_eq!(
|
||||
config.headers.get("X-Env").map(String::as_str),
|
||||
Some("local")
|
||||
);
|
||||
}
|
||||
other => panic!("expected ws config, got {other:?}"),
|
||||
}
|
||||
|
||||
let oauth = loaded.oauth().expect("oauth config should exist");
|
||||
assert_eq!(oauth.client_id, "runtime-client");
|
||||
assert_eq!(oauth.callback_port, Some(54_545));
|
||||
assert_eq!(oauth.scopes, vec!["org:read", "user:write"]);
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rejects_invalid_mcp_server_shapes() {
|
||||
let root = temp_dir();
|
||||
let cwd = root.join("project");
|
||||
let home = root.join("home").join(".claude");
|
||||
fs::create_dir_all(&home).expect("home config dir");
|
||||
fs::create_dir_all(&cwd).expect("project dir");
|
||||
fs::write(
|
||||
home.join("settings.json"),
|
||||
r#"{"mcpServers":{"broken":{"type":"http","url":123}}}"#,
|
||||
)
|
||||
.expect("write broken settings");
|
||||
|
||||
let error = ConfigLoader::new(&cwd, &home)
|
||||
.load()
|
||||
.expect_err("config should fail");
|
||||
assert!(error
|
||||
.to_string()
|
||||
.contains("mcpServers.broken: missing string field url"));
|
||||
|
||||
fs::remove_dir_all(root).expect("cleanup temp dir");
|
||||
}
|
||||
}
|
||||
583
rust/crates/runtime/src/conversation.rs
Normal file
583
rust/crates/runtime/src/conversation.rs
Normal file
@@ -0,0 +1,583 @@
|
||||
use std::collections::BTreeMap;
|
||||
use std::fmt::{Display, Formatter};
|
||||
|
||||
use crate::compact::{
|
||||
compact_session, estimate_session_tokens, CompactionConfig, CompactionResult,
|
||||
};
|
||||
use crate::permissions::{PermissionOutcome, PermissionPolicy, PermissionPrompter};
|
||||
use crate::session::{ContentBlock, ConversationMessage, Session};
|
||||
use crate::usage::{TokenUsage, UsageTracker};
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ApiRequest {
|
||||
pub system_prompt: Vec<String>,
|
||||
pub messages: Vec<ConversationMessage>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum AssistantEvent {
|
||||
TextDelta(String),
|
||||
ToolUse {
|
||||
id: String,
|
||||
name: String,
|
||||
input: String,
|
||||
},
|
||||
Usage(TokenUsage),
|
||||
MessageStop,
|
||||
}
|
||||
|
||||
pub trait ApiClient {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError>;
|
||||
}
|
||||
|
||||
pub trait ToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError>;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ToolError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl ToolError {
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for ToolError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for ToolError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeError {
|
||||
message: String,
|
||||
}
|
||||
|
||||
impl RuntimeError {
|
||||
#[must_use]
|
||||
pub fn new(message: impl Into<String>) -> Self {
|
||||
Self {
|
||||
message: message.into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Display for RuntimeError {
|
||||
fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result {
|
||||
write!(f, "{}", self.message)
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct TurnSummary {
|
||||
pub assistant_messages: Vec<ConversationMessage>,
|
||||
pub tool_results: Vec<ConversationMessage>,
|
||||
pub iterations: usize,
|
||||
pub usage: TokenUsage,
|
||||
}
|
||||
|
||||
pub struct ConversationRuntime<C, T> {
|
||||
session: Session,
|
||||
api_client: C,
|
||||
tool_executor: T,
|
||||
permission_policy: PermissionPolicy,
|
||||
system_prompt: Vec<String>,
|
||||
max_iterations: usize,
|
||||
usage_tracker: UsageTracker,
|
||||
}
|
||||
|
||||
impl<C, T> ConversationRuntime<C, T>
|
||||
where
|
||||
C: ApiClient,
|
||||
T: ToolExecutor,
|
||||
{
|
||||
#[must_use]
|
||||
pub fn new(
|
||||
session: Session,
|
||||
api_client: C,
|
||||
tool_executor: T,
|
||||
permission_policy: PermissionPolicy,
|
||||
system_prompt: Vec<String>,
|
||||
) -> Self {
|
||||
let usage_tracker = UsageTracker::from_session(&session);
|
||||
Self {
|
||||
session,
|
||||
api_client,
|
||||
tool_executor,
|
||||
permission_policy,
|
||||
system_prompt,
|
||||
max_iterations: 16,
|
||||
usage_tracker,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn with_max_iterations(mut self, max_iterations: usize) -> Self {
|
||||
self.max_iterations = max_iterations;
|
||||
self
|
||||
}
|
||||
|
||||
pub fn run_turn(
|
||||
&mut self,
|
||||
user_input: impl Into<String>,
|
||||
mut prompter: Option<&mut dyn PermissionPrompter>,
|
||||
) -> Result<TurnSummary, RuntimeError> {
|
||||
self.session
|
||||
.messages
|
||||
.push(ConversationMessage::user_text(user_input.into()));
|
||||
|
||||
let mut assistant_messages = Vec::new();
|
||||
let mut tool_results = Vec::new();
|
||||
let mut iterations = 0;
|
||||
|
||||
loop {
|
||||
iterations += 1;
|
||||
if iterations > self.max_iterations {
|
||||
return Err(RuntimeError::new(
|
||||
"conversation loop exceeded the maximum number of iterations",
|
||||
));
|
||||
}
|
||||
|
||||
let request = ApiRequest {
|
||||
system_prompt: self.system_prompt.clone(),
|
||||
messages: self.session.messages.clone(),
|
||||
};
|
||||
let events = self.api_client.stream(request)?;
|
||||
let (assistant_message, usage) = build_assistant_message(events)?;
|
||||
if let Some(usage) = usage {
|
||||
self.usage_tracker.record(usage);
|
||||
}
|
||||
let pending_tool_uses = assistant_message
|
||||
.blocks
|
||||
.iter()
|
||||
.filter_map(|block| match block {
|
||||
ContentBlock::ToolUse { id, name, input } => {
|
||||
Some((id.clone(), name.clone(), input.clone()))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
self.session.messages.push(assistant_message.clone());
|
||||
assistant_messages.push(assistant_message);
|
||||
|
||||
if pending_tool_uses.is_empty() {
|
||||
break;
|
||||
}
|
||||
|
||||
for (tool_use_id, tool_name, input) in pending_tool_uses {
|
||||
let permission_outcome = if let Some(prompt) = prompter.as_mut() {
|
||||
self.permission_policy
|
||||
.authorize(&tool_name, &input, Some(*prompt))
|
||||
} else {
|
||||
self.permission_policy.authorize(&tool_name, &input, None)
|
||||
};
|
||||
|
||||
let result_message = match permission_outcome {
|
||||
PermissionOutcome::Allow => {
|
||||
match self.tool_executor.execute(&tool_name, &input) {
|
||||
Ok(output) => ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
output,
|
||||
false,
|
||||
),
|
||||
Err(error) => ConversationMessage::tool_result(
|
||||
tool_use_id,
|
||||
tool_name,
|
||||
error.to_string(),
|
||||
true,
|
||||
),
|
||||
}
|
||||
}
|
||||
PermissionOutcome::Deny { reason } => {
|
||||
ConversationMessage::tool_result(tool_use_id, tool_name, reason, true)
|
||||
}
|
||||
};
|
||||
self.session.messages.push(result_message.clone());
|
||||
tool_results.push(result_message);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(TurnSummary {
|
||||
assistant_messages,
|
||||
tool_results,
|
||||
iterations,
|
||||
usage: self.usage_tracker.cumulative_usage(),
|
||||
})
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn compact(&self, config: CompactionConfig) -> CompactionResult {
|
||||
compact_session(&self.session, config)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn estimated_tokens(&self) -> usize {
|
||||
estimate_session_tokens(&self.session)
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn usage(&self) -> &UsageTracker {
|
||||
&self.usage_tracker
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn session(&self) -> &Session {
|
||||
&self.session
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn into_session(self) -> Session {
|
||||
self.session
|
||||
}
|
||||
}
|
||||
|
||||
fn build_assistant_message(
|
||||
events: Vec<AssistantEvent>,
|
||||
) -> Result<(ConversationMessage, Option<TokenUsage>), RuntimeError> {
|
||||
let mut text = String::new();
|
||||
let mut blocks = Vec::new();
|
||||
let mut finished = false;
|
||||
let mut usage = None;
|
||||
|
||||
for event in events {
|
||||
match event {
|
||||
AssistantEvent::TextDelta(delta) => text.push_str(&delta),
|
||||
AssistantEvent::ToolUse { id, name, input } => {
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
blocks.push(ContentBlock::ToolUse { id, name, input });
|
||||
}
|
||||
AssistantEvent::Usage(value) => usage = Some(value),
|
||||
AssistantEvent::MessageStop => {
|
||||
finished = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
flush_text_block(&mut text, &mut blocks);
|
||||
|
||||
if !finished {
|
||||
return Err(RuntimeError::new(
|
||||
"assistant stream ended without a message stop event",
|
||||
));
|
||||
}
|
||||
if blocks.is_empty() {
|
||||
return Err(RuntimeError::new("assistant stream produced no content"));
|
||||
}
|
||||
|
||||
Ok((
|
||||
ConversationMessage::assistant_with_usage(blocks, usage),
|
||||
usage,
|
||||
))
|
||||
}
|
||||
|
||||
fn flush_text_block(text: &mut String, blocks: &mut Vec<ContentBlock>) {
|
||||
if !text.is_empty() {
|
||||
blocks.push(ContentBlock::Text {
|
||||
text: std::mem::take(text),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
type ToolHandler = Box<dyn FnMut(&str) -> Result<String, ToolError>>;
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct StaticToolExecutor {
|
||||
handlers: BTreeMap<String, ToolHandler>,
|
||||
}
|
||||
|
||||
impl StaticToolExecutor {
|
||||
#[must_use]
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn register(
|
||||
mut self,
|
||||
tool_name: impl Into<String>,
|
||||
handler: impl FnMut(&str) -> Result<String, ToolError> + 'static,
|
||||
) -> Self {
|
||||
self.handlers.insert(tool_name.into(), Box::new(handler));
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
impl ToolExecutor for StaticToolExecutor {
|
||||
fn execute(&mut self, tool_name: &str, input: &str) -> Result<String, ToolError> {
|
||||
self.handlers
|
||||
.get_mut(tool_name)
|
||||
.ok_or_else(|| ToolError::new(format!("unknown tool: {tool_name}")))?(input)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{
|
||||
ApiClient, ApiRequest, AssistantEvent, ConversationRuntime, RuntimeError,
|
||||
StaticToolExecutor,
|
||||
};
|
||||
use crate::compact::CompactionConfig;
|
||||
use crate::permissions::{
|
||||
PermissionMode, PermissionPolicy, PermissionPromptDecision, PermissionPrompter,
|
||||
PermissionRequest,
|
||||
};
|
||||
use crate::prompt::{ProjectContext, SystemPromptBuilder};
|
||||
use crate::session::{ContentBlock, MessageRole, Session};
|
||||
use crate::usage::TokenUsage;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct ScriptedApiClient {
|
||||
call_count: usize,
|
||||
}
|
||||
|
||||
impl ApiClient for ScriptedApiClient {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
self.call_count += 1;
|
||||
match self.call_count {
|
||||
1 => {
|
||||
assert!(request
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == MessageRole::User));
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("Let me calculate that.".to_string()),
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "add".to_string(),
|
||||
input: "2,2".to_string(),
|
||||
},
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens: 20,
|
||||
output_tokens: 6,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 2,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
2 => {
|
||||
let last_message = request
|
||||
.messages
|
||||
.last()
|
||||
.expect("tool result should be present");
|
||||
assert_eq!(last_message.role, MessageRole::Tool);
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("The answer is 4.".to_string()),
|
||||
AssistantEvent::Usage(TokenUsage {
|
||||
input_tokens: 24,
|
||||
output_tokens: 4,
|
||||
cache_creation_input_tokens: 1,
|
||||
cache_read_input_tokens: 3,
|
||||
}),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
_ => Err(RuntimeError::new("unexpected extra API call")),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct PromptAllowOnce;
|
||||
|
||||
impl PermissionPrompter for PromptAllowOnce {
|
||||
fn decide(&mut self, request: &PermissionRequest) -> PermissionPromptDecision {
|
||||
assert_eq!(request.tool_name, "add");
|
||||
PermissionPromptDecision::Allow
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runs_user_to_tool_to_result_loop_end_to_end_and_tracks_usage() {
|
||||
let api_client = ScriptedApiClient { call_count: 0 };
|
||||
let tool_executor = StaticToolExecutor::new().register("add", |input| {
|
||||
let total = input
|
||||
.split(',')
|
||||
.map(|part| part.parse::<i32>().expect("input must be valid integer"))
|
||||
.sum::<i32>();
|
||||
Ok(total.to_string())
|
||||
});
|
||||
let permission_policy = PermissionPolicy::new(PermissionMode::WorkspaceWrite);
|
||||
let system_prompt = SystemPromptBuilder::new()
|
||||
.with_project_context(ProjectContext {
|
||||
cwd: PathBuf::from("/tmp/project"),
|
||||
current_date: "2026-03-31".to_string(),
|
||||
git_status: None,
|
||||
instruction_files: Vec::new(),
|
||||
})
|
||||
.with_os("linux", "6.8")
|
||||
.build();
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
api_client,
|
||||
tool_executor,
|
||||
permission_policy,
|
||||
system_prompt,
|
||||
);
|
||||
|
||||
let summary = runtime
|
||||
.run_turn("what is 2 + 2?", Some(&mut PromptAllowOnce))
|
||||
.expect("conversation loop should succeed");
|
||||
|
||||
assert_eq!(summary.iterations, 2);
|
||||
assert_eq!(summary.assistant_messages.len(), 2);
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
assert_eq!(runtime.session().messages.len(), 4);
|
||||
assert_eq!(summary.usage.output_tokens, 10);
|
||||
assert!(matches!(
|
||||
runtime.session().messages[1].blocks[1],
|
||||
ContentBlock::ToolUse { .. }
|
||||
));
|
||||
assert!(matches!(
|
||||
runtime.session().messages[2].blocks[0],
|
||||
ContentBlock::ToolResult {
|
||||
is_error: false,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn records_denied_tool_results_when_prompt_rejects() {
|
||||
struct RejectPrompter;
|
||||
impl PermissionPrompter for RejectPrompter {
|
||||
fn decide(&mut self, _request: &PermissionRequest) -> PermissionPromptDecision {
|
||||
PermissionPromptDecision::Deny {
|
||||
reason: "not now".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SingleCallApiClient;
|
||||
impl ApiClient for SingleCallApiClient {
|
||||
fn stream(&mut self, request: ApiRequest) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
if request
|
||||
.messages
|
||||
.iter()
|
||||
.any(|message| message.role == MessageRole::Tool)
|
||||
{
|
||||
return Ok(vec![
|
||||
AssistantEvent::TextDelta("I could not use the tool.".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
]);
|
||||
}
|
||||
Ok(vec![
|
||||
AssistantEvent::ToolUse {
|
||||
id: "tool-1".to_string(),
|
||||
name: "blocked".to_string(),
|
||||
input: "secret".to_string(),
|
||||
},
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
SingleCallApiClient,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::WorkspaceWrite),
|
||||
vec!["system".to_string()],
|
||||
);
|
||||
|
||||
let summary = runtime
|
||||
.run_turn("use the tool", Some(&mut RejectPrompter))
|
||||
.expect("conversation should continue after denied tool");
|
||||
|
||||
assert_eq!(summary.tool_results.len(), 1);
|
||||
assert!(matches!(
|
||||
&summary.tool_results[0].blocks[0],
|
||||
ContentBlock::ToolResult { is_error: true, output, .. } if output == "not now"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reconstructs_usage_tracker_from_restored_session() {
|
||||
struct SimpleApi;
|
||||
impl ApiClient for SimpleApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut session = Session::new();
|
||||
session
|
||||
.messages
|
||||
.push(crate::session::ConversationMessage::assistant_with_usage(
|
||||
vec![ContentBlock::Text {
|
||||
text: "earlier".to_string(),
|
||||
}],
|
||||
Some(TokenUsage {
|
||||
input_tokens: 11,
|
||||
output_tokens: 7,
|
||||
cache_creation_input_tokens: 2,
|
||||
cache_read_input_tokens: 1,
|
||||
}),
|
||||
));
|
||||
|
||||
let runtime = ConversationRuntime::new(
|
||||
session,
|
||||
SimpleApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
);
|
||||
|
||||
assert_eq!(runtime.usage().turns(), 1);
|
||||
assert_eq!(runtime.usage().cumulative_usage().total_tokens(), 21);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn compacts_session_after_turns() {
|
||||
struct SimpleApi;
|
||||
impl ApiClient for SimpleApi {
|
||||
fn stream(
|
||||
&mut self,
|
||||
_request: ApiRequest,
|
||||
) -> Result<Vec<AssistantEvent>, RuntimeError> {
|
||||
Ok(vec![
|
||||
AssistantEvent::TextDelta("done".to_string()),
|
||||
AssistantEvent::MessageStop,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
let mut runtime = ConversationRuntime::new(
|
||||
Session::new(),
|
||||
SimpleApi,
|
||||
StaticToolExecutor::new(),
|
||||
PermissionPolicy::new(PermissionMode::DangerFullAccess),
|
||||
vec!["system".to_string()],
|
||||
);
|
||||
runtime.run_turn("a", None).expect("turn a");
|
||||
runtime.run_turn("b", None).expect("turn b");
|
||||
runtime.run_turn("c", None).expect("turn c");
|
||||
|
||||
let result = runtime.compact(CompactionConfig {
|
||||
preserve_recent_messages: 2,
|
||||
max_estimated_tokens: 1,
|
||||
});
|
||||
assert!(result.summary.contains("Conversation summary"));
|
||||
assert_eq!(
|
||||
result.compacted_session.messages[0].role,
|
||||
MessageRole::System
|
||||
);
|
||||
}
|
||||
}
|
||||
550
rust/crates/runtime/src/file_ops.rs
Normal file
550
rust/crates/runtime/src/file_ops.rs
Normal file
@@ -0,0 +1,550 @@
|
||||
use std::cmp::Reverse;
|
||||
use std::fs;
|
||||
use std::io;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::Instant;
|
||||
|
||||
use glob::Pattern;
|
||||
use regex::RegexBuilder;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct TextFilePayload {
|
||||
#[serde(rename = "filePath")]
|
||||
pub file_path: String,
|
||||
pub content: String,
|
||||
#[serde(rename = "numLines")]
|
||||
pub num_lines: usize,
|
||||
#[serde(rename = "startLine")]
|
||||
pub start_line: usize,
|
||||
#[serde(rename = "totalLines")]
|
||||
pub total_lines: usize,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct ReadFileOutput {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
pub file: TextFilePayload,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct StructuredPatchHunk {
|
||||
#[serde(rename = "oldStart")]
|
||||
pub old_start: usize,
|
||||
#[serde(rename = "oldLines")]
|
||||
pub old_lines: usize,
|
||||
#[serde(rename = "newStart")]
|
||||
pub new_start: usize,
|
||||
#[serde(rename = "newLines")]
|
||||
pub new_lines: usize,
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct WriteFileOutput {
|
||||
#[serde(rename = "type")]
|
||||
pub kind: String,
|
||||
#[serde(rename = "filePath")]
|
||||
pub file_path: String,
|
||||
pub content: String,
|
||||
#[serde(rename = "structuredPatch")]
|
||||
pub structured_patch: Vec<StructuredPatchHunk>,
|
||||
#[serde(rename = "originalFile")]
|
||||
pub original_file: Option<String>,
|
||||
#[serde(rename = "gitDiff")]
|
||||
pub git_diff: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct EditFileOutput {
|
||||
#[serde(rename = "filePath")]
|
||||
pub file_path: String,
|
||||
#[serde(rename = "oldString")]
|
||||
pub old_string: String,
|
||||
#[serde(rename = "newString")]
|
||||
pub new_string: String,
|
||||
#[serde(rename = "originalFile")]
|
||||
pub original_file: String,
|
||||
#[serde(rename = "structuredPatch")]
|
||||
pub structured_patch: Vec<StructuredPatchHunk>,
|
||||
#[serde(rename = "userModified")]
|
||||
pub user_modified: bool,
|
||||
#[serde(rename = "replaceAll")]
|
||||
pub replace_all: bool,
|
||||
#[serde(rename = "gitDiff")]
|
||||
pub git_diff: Option<serde_json::Value>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||
pub struct GlobSearchOutput {
|
||||
#[serde(rename = "durationMs")]
|
||||
pub duration_ms: u128,
|
||||
#[serde(rename = "numFiles")]
|
||||
pub num_files: usize,
|
||||
pub filenames: Vec<String>,
|
||||
pub truncated: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrepSearchInput {
|
||||
pub pattern: String,
|
||||
pub path: Option<String>,
|
||||
pub glob: Option<String>,
|
||||
#[serde(rename = "output_mode")]
|
||||
pub output_mode: Option<String>,
|
||||
#[serde(rename = "-B")]
|
||||
pub before: Option<usize>,
|
||||
#[serde(rename = "-A")]
|
||||
pub after: Option<usize>,
|
||||
#[serde(rename = "-C")]
|
||||
pub context_short: Option<usize>,
|
||||
pub context: Option<usize>,
|
||||
#[serde(rename = "-n")]
|
||||
pub line_numbers: Option<bool>,
|
||||
#[serde(rename = "-i")]
|
||||
pub case_insensitive: Option<bool>,
|
||||
#[serde(rename = "type")]
|
||||
pub file_type: Option<String>,
|
||||
pub head_limit: Option<usize>,
|
||||
pub offset: Option<usize>,
|
||||
pub multiline: Option<bool>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub struct GrepSearchOutput {
|
||||
pub mode: Option<String>,
|
||||
#[serde(rename = "numFiles")]
|
||||
pub num_files: usize,
|
||||
pub filenames: Vec<String>,
|
||||
pub content: Option<String>,
|
||||
#[serde(rename = "numLines")]
|
||||
pub num_lines: Option<usize>,
|
||||
#[serde(rename = "numMatches")]
|
||||
pub num_matches: Option<usize>,
|
||||
#[serde(rename = "appliedLimit")]
|
||||
pub applied_limit: Option<usize>,
|
||||
#[serde(rename = "appliedOffset")]
|
||||
pub applied_offset: Option<usize>,
|
||||
}
|
||||
|
||||
pub fn read_file(
|
||||
path: &str,
|
||||
offset: Option<usize>,
|
||||
limit: Option<usize>,
|
||||
) -> io::Result<ReadFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let content = fs::read_to_string(&absolute_path)?;
|
||||
let lines: Vec<&str> = content.lines().collect();
|
||||
let start_index = offset.unwrap_or(0).min(lines.len());
|
||||
let end_index = limit.map_or(lines.len(), |limit| {
|
||||
start_index.saturating_add(limit).min(lines.len())
|
||||
});
|
||||
let selected = lines[start_index..end_index].join("\n");
|
||||
|
||||
Ok(ReadFileOutput {
|
||||
kind: String::from("text"),
|
||||
file: TextFilePayload {
|
||||
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||
content: selected,
|
||||
num_lines: end_index.saturating_sub(start_index),
|
||||
start_line: start_index.saturating_add(1),
|
||||
total_lines: lines.len(),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
pub fn write_file(path: &str, content: &str) -> io::Result<WriteFileOutput> {
|
||||
let absolute_path = normalize_path_allow_missing(path)?;
|
||||
let original_file = fs::read_to_string(&absolute_path).ok();
|
||||
if let Some(parent) = absolute_path.parent() {
|
||||
fs::create_dir_all(parent)?;
|
||||
}
|
||||
fs::write(&absolute_path, content)?;
|
||||
|
||||
Ok(WriteFileOutput {
|
||||
kind: if original_file.is_some() {
|
||||
String::from("update")
|
||||
} else {
|
||||
String::from("create")
|
||||
},
|
||||
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||
content: content.to_owned(),
|
||||
structured_patch: make_patch(original_file.as_deref().unwrap_or(""), content),
|
||||
original_file,
|
||||
git_diff: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn edit_file(
|
||||
path: &str,
|
||||
old_string: &str,
|
||||
new_string: &str,
|
||||
replace_all: bool,
|
||||
) -> io::Result<EditFileOutput> {
|
||||
let absolute_path = normalize_path(path)?;
|
||||
let original_file = fs::read_to_string(&absolute_path)?;
|
||||
if old_string == new_string {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::InvalidInput,
|
||||
"old_string and new_string must differ",
|
||||
));
|
||||
}
|
||||
if !original_file.contains(old_string) {
|
||||
return Err(io::Error::new(
|
||||
io::ErrorKind::NotFound,
|
||||
"old_string not found in file",
|
||||
));
|
||||
}
|
||||
|
||||
let updated = if replace_all {
|
||||
original_file.replace(old_string, new_string)
|
||||
} else {
|
||||
original_file.replacen(old_string, new_string, 1)
|
||||
};
|
||||
fs::write(&absolute_path, &updated)?;
|
||||
|
||||
Ok(EditFileOutput {
|
||||
file_path: absolute_path.to_string_lossy().into_owned(),
|
||||
old_string: old_string.to_owned(),
|
||||
new_string: new_string.to_owned(),
|
||||
original_file: original_file.clone(),
|
||||
structured_patch: make_patch(&original_file, &updated),
|
||||
user_modified: false,
|
||||
replace_all,
|
||||
git_diff: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn glob_search(pattern: &str, path: Option<&str>) -> io::Result<GlobSearchOutput> {
|
||||
let started = Instant::now();
|
||||
let base_dir = path
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
let search_pattern = if Path::new(pattern).is_absolute() {
|
||||
pattern.to_owned()
|
||||
} else {
|
||||
base_dir.join(pattern).to_string_lossy().into_owned()
|
||||
};
|
||||
|
||||
let mut matches = Vec::new();
|
||||
let entries = glob::glob(&search_pattern)
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
for entry in entries.flatten() {
|
||||
if entry.is_file() {
|
||||
matches.push(entry);
|
||||
}
|
||||
}
|
||||
|
||||
matches.sort_by_key(|path| {
|
||||
fs::metadata(path)
|
||||
.and_then(|metadata| metadata.modified())
|
||||
.ok()
|
||||
.map(Reverse)
|
||||
});
|
||||
|
||||
let truncated = matches.len() > 100;
|
||||
let filenames = matches
|
||||
.into_iter()
|
||||
.take(100)
|
||||
.map(|path| path.to_string_lossy().into_owned())
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
Ok(GlobSearchOutput {
|
||||
duration_ms: started.elapsed().as_millis(),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
truncated,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn grep_search(input: &GrepSearchInput) -> io::Result<GrepSearchOutput> {
|
||||
let base_path = input
|
||||
.path
|
||||
.as_deref()
|
||||
.map(normalize_path)
|
||||
.transpose()?
|
||||
.unwrap_or(std::env::current_dir()?);
|
||||
|
||||
let regex = RegexBuilder::new(&input.pattern)
|
||||
.case_insensitive(input.case_insensitive.unwrap_or(false))
|
||||
.dot_matches_new_line(input.multiline.unwrap_or(false))
|
||||
.build()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
|
||||
let glob_filter = input
|
||||
.glob
|
||||
.as_deref()
|
||||
.map(Pattern::new)
|
||||
.transpose()
|
||||
.map_err(|error| io::Error::new(io::ErrorKind::InvalidInput, error.to_string()))?;
|
||||
let file_type = input.file_type.as_deref();
|
||||
let output_mode = input
|
||||
.output_mode
|
||||
.clone()
|
||||
.unwrap_or_else(|| String::from("files_with_matches"));
|
||||
let context = input.context.or(input.context_short).unwrap_or(0);
|
||||
|
||||
let mut filenames = Vec::new();
|
||||
let mut content_lines = Vec::new();
|
||||
let mut total_matches = 0usize;
|
||||
|
||||
for file_path in collect_search_files(&base_path)? {
|
||||
if !matches_optional_filters(&file_path, glob_filter.as_ref(), file_type) {
|
||||
continue;
|
||||
}
|
||||
|
||||
let Ok(file_contents) = fs::read_to_string(&file_path) else {
|
||||
continue;
|
||||
};
|
||||
|
||||
if output_mode == "count" {
|
||||
let count = regex.find_iter(&file_contents).count();
|
||||
if count > 0 {
|
||||
filenames.push(file_path.to_string_lossy().into_owned());
|
||||
total_matches += count;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
let lines: Vec<&str> = file_contents.lines().collect();
|
||||
let mut matched_lines = Vec::new();
|
||||
for (index, line) in lines.iter().enumerate() {
|
||||
if regex.is_match(line) {
|
||||
total_matches += 1;
|
||||
matched_lines.push(index);
|
||||
}
|
||||
}
|
||||
|
||||
if matched_lines.is_empty() {
|
||||
continue;
|
||||
}
|
||||
|
||||
filenames.push(file_path.to_string_lossy().into_owned());
|
||||
if output_mode == "content" {
|
||||
for index in matched_lines {
|
||||
let start = index.saturating_sub(input.before.unwrap_or(context));
|
||||
let end = (index + input.after.unwrap_or(context) + 1).min(lines.len());
|
||||
for (current, line) in lines.iter().enumerate().take(end).skip(start) {
|
||||
let prefix = if input.line_numbers.unwrap_or(true) {
|
||||
format!("{}:{}:", file_path.to_string_lossy(), current + 1)
|
||||
} else {
|
||||
format!("{}:", file_path.to_string_lossy())
|
||||
};
|
||||
content_lines.push(format!("{prefix}{line}"));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let (filenames, applied_limit, applied_offset) =
|
||||
apply_limit(filenames, input.head_limit, input.offset);
|
||||
let content_output = if output_mode == "content" {
|
||||
let (lines, limit, offset) = apply_limit(content_lines, input.head_limit, input.offset);
|
||||
return Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
num_lines: Some(lines.len()),
|
||||
content: Some(lines.join("\n")),
|
||||
num_matches: None,
|
||||
applied_limit: limit,
|
||||
applied_offset: offset,
|
||||
});
|
||||
} else {
|
||||
None
|
||||
};
|
||||
|
||||
Ok(GrepSearchOutput {
|
||||
mode: Some(output_mode.clone()),
|
||||
num_files: filenames.len(),
|
||||
filenames,
|
||||
content: content_output,
|
||||
num_lines: None,
|
||||
num_matches: (output_mode == "count").then_some(total_matches),
|
||||
applied_limit,
|
||||
applied_offset,
|
||||
})
|
||||
}
|
||||
|
||||
fn collect_search_files(base_path: &Path) -> io::Result<Vec<PathBuf>> {
|
||||
if base_path.is_file() {
|
||||
return Ok(vec![base_path.to_path_buf()]);
|
||||
}
|
||||
|
||||
let mut files = Vec::new();
|
||||
for entry in WalkDir::new(base_path) {
|
||||
let entry = entry.map_err(|error| io::Error::other(error.to_string()))?;
|
||||
if entry.file_type().is_file() {
|
||||
files.push(entry.path().to_path_buf());
|
||||
}
|
||||
}
|
||||
Ok(files)
|
||||
}
|
||||
|
||||
fn matches_optional_filters(
|
||||
path: &Path,
|
||||
glob_filter: Option<&Pattern>,
|
||||
file_type: Option<&str>,
|
||||
) -> bool {
|
||||
if let Some(glob_filter) = glob_filter {
|
||||
let path_string = path.to_string_lossy();
|
||||
if !glob_filter.matches(&path_string) && !glob_filter.matches_path(path) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
if let Some(file_type) = file_type {
|
||||
let extension = path.extension().and_then(|extension| extension.to_str());
|
||||
if extension != Some(file_type) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
true
|
||||
}
|
||||
|
||||
fn apply_limit<T>(
|
||||
items: Vec<T>,
|
||||
limit: Option<usize>,
|
||||
offset: Option<usize>,
|
||||
) -> (Vec<T>, Option<usize>, Option<usize>) {
|
||||
let offset_value = offset.unwrap_or(0);
|
||||
let mut items = items.into_iter().skip(offset_value).collect::<Vec<_>>();
|
||||
let explicit_limit = limit.unwrap_or(250);
|
||||
if explicit_limit == 0 {
|
||||
return (items, None, (offset_value > 0).then_some(offset_value));
|
||||
}
|
||||
|
||||
let truncated = items.len() > explicit_limit;
|
||||
items.truncate(explicit_limit);
|
||||
(
|
||||
items,
|
||||
truncated.then_some(explicit_limit),
|
||||
(offset_value > 0).then_some(offset_value),
|
||||
)
|
||||
}
|
||||
|
||||
fn make_patch(original: &str, updated: &str) -> Vec<StructuredPatchHunk> {
|
||||
let mut lines = Vec::new();
|
||||
for line in original.lines() {
|
||||
lines.push(format!("-{line}"));
|
||||
}
|
||||
for line in updated.lines() {
|
||||
lines.push(format!("+{line}"));
|
||||
}
|
||||
|
||||
vec![StructuredPatchHunk {
|
||||
old_start: 1,
|
||||
old_lines: original.lines().count(),
|
||||
new_start: 1,
|
||||
new_lines: updated.lines().count(),
|
||||
lines,
|
||||
}]
|
||||
}
|
||||
|
||||
fn normalize_path(path: &str) -> io::Result<PathBuf> {
|
||||
let candidate = if Path::new(path).is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
std::env::current_dir()?.join(path)
|
||||
};
|
||||
candidate.canonicalize()
|
||||
}
|
||||
|
||||
fn normalize_path_allow_missing(path: &str) -> io::Result<PathBuf> {
|
||||
let candidate = if Path::new(path).is_absolute() {
|
||||
PathBuf::from(path)
|
||||
} else {
|
||||
std::env::current_dir()?.join(path)
|
||||
};
|
||||
|
||||
if let Ok(canonical) = candidate.canonicalize() {
|
||||
return Ok(canonical);
|
||||
}
|
||||
|
||||
if let Some(parent) = candidate.parent() {
|
||||
let canonical_parent = parent
|
||||
.canonicalize()
|
||||
.unwrap_or_else(|_| parent.to_path_buf());
|
||||
if let Some(name) = candidate.file_name() {
|
||||
return Ok(canonical_parent.join(name));
|
||||
}
|
||||
}
|
||||
|
||||
Ok(candidate)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use super::{edit_file, glob_search, grep_search, read_file, write_file, GrepSearchInput};
|
||||
|
||||
fn temp_path(name: &str) -> std::path::PathBuf {
|
||||
let unique = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.expect("time should move forward")
|
||||
.as_nanos();
|
||||
std::env::temp_dir().join(format!("clawd-native-{name}-{unique}"))
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reads_and_writes_files() {
|
||||
let path = temp_path("read-write.txt");
|
||||
let write_output = write_file(path.to_string_lossy().as_ref(), "one\ntwo\nthree")
|
||||
.expect("write should succeed");
|
||||
assert_eq!(write_output.kind, "create");
|
||||
|
||||
let read_output = read_file(path.to_string_lossy().as_ref(), Some(1), Some(1))
|
||||
.expect("read should succeed");
|
||||
assert_eq!(read_output.file.content, "two");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn edits_file_contents() {
|
||||
let path = temp_path("edit.txt");
|
||||
write_file(path.to_string_lossy().as_ref(), "alpha beta alpha")
|
||||
.expect("initial write should succeed");
|
||||
let output = edit_file(path.to_string_lossy().as_ref(), "alpha", "omega", true)
|
||||
.expect("edit should succeed");
|
||||
assert!(output.replace_all);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn globs_and_greps_directory() {
|
||||
let dir = temp_path("search-dir");
|
||||
std::fs::create_dir_all(&dir).expect("directory should be created");
|
||||
let file = dir.join("demo.rs");
|
||||
write_file(
|
||||
file.to_string_lossy().as_ref(),
|
||||
"fn main() {\n println!(\"hello\");\n}\n",
|
||||
)
|
||||
.expect("file write should succeed");
|
||||
|
||||
let globbed = glob_search("**/*.rs", Some(dir.to_string_lossy().as_ref()))
|
||||
.expect("glob should succeed");
|
||||
assert_eq!(globbed.num_files, 1);
|
||||
|
||||
let grep_output = grep_search(&GrepSearchInput {
|
||||
pattern: String::from("hello"),
|
||||
path: Some(dir.to_string_lossy().into_owned()),
|
||||
glob: Some(String::from("**/*.rs")),
|
||||
output_mode: Some(String::from("content")),
|
||||
before: None,
|
||||
after: None,
|
||||
context_short: None,
|
||||
context: None,
|
||||
line_numbers: Some(true),
|
||||
case_insensitive: Some(false),
|
||||
file_type: None,
|
||||
head_limit: Some(10),
|
||||
offset: Some(0),
|
||||
multiline: Some(false),
|
||||
})
|
||||
.expect("grep should succeed");
|
||||
assert!(grep_output.content.unwrap_or_default().contains("hello"));
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user