kaizen-agent 1.47.0 → 1.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +111 -84
- package/cli/build-web.ts +4 -2
- package/cli/hermes.ts +65 -0
- package/cli/install.ts +24 -10
- package/cli/localsend.test.ts +84 -0
- package/cli/localsend.ts +131 -0
- package/cli/notes.test.ts +6 -4
- package/cli/settings.ts +1 -0
- package/cli/state.test.ts +37 -0
- package/cli/state.ts +35 -20
- package/cli/video.test.ts +479 -0
- package/cli/video.ts +507 -0
- package/cli/web.ts +127 -1
- package/cli/worktree.test.ts +3 -1
- package/package.json +1 -1
- package/skills/kaizen/config.default.yml +4 -0
- package/skills/media/kaizen-book/SKILL.md +107 -0
- package/skills/media/kaizen-book/assets/reader.html +440 -0
- package/skills/media/kaizen-book/assets/styles.json +45 -0
- package/skills/media/kaizen-book/examples/book.json +14 -0
- package/skills/media/kaizen-book/examples/images/reach.svg +1 -0
- package/skills/media/kaizen-book/examples/manuscript.md +127 -0
- package/skills/media/kaizen-book/references/interview.md +85 -0
- package/skills/media/kaizen-book/references/proofread.md +47 -0
- package/skills/media/kaizen-book/references/writing.md +53 -0
- package/skills/media/kaizen-book/scripts/build_book.py +279 -0
- package/skills/media/kaizen-book/scripts/check.sh +10 -0
- package/skills/media/kaizen-book/scripts/check_book.py +128 -0
- package/skills/media/kaizen-book/scripts/deploy.py +58 -0
- package/skills/media/kaizen-book/scripts/pdf.mjs +99 -0
- package/skills/media/kaizen-book/scripts/setup.sh +9 -0
- package/skills/media/kaizen-book/scripts/verify.mjs +95 -0
- package/skills/{story-deck → media/kaizen-deck}/SKILL.md +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/check.sh +1 -1
- package/skills/{story-deck → media/kaizen-deck}/scripts/install_fonts.py +2 -2
- package/skills/media/kaizen-video/SKILL.md +112 -0
- package/skills/media/kaizen-video/assets/CREDITS.txt +16 -0
- package/skills/media/kaizen-video/assets/film-fonts/Fraunces.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/Inter.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay-Italic.ttf +0 -0
- package/skills/media/kaizen-video/assets/film-fonts/PlayfairDisplay.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Fredoka.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/Montserrat.ttf +0 -0
- package/skills/media/kaizen-video/assets/fonts/SpecialElite.ttf +0 -0
- package/skills/media/kaizen-video/assets/music/investigations.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/lights.mp3 +0 -0
- package/skills/media/kaizen-video/assets/music/sunny.mp3 +0 -0
- package/skills/media/kaizen-video/examples/config.json +152 -0
- package/skills/media/kaizen-video/examples/sample-film/README.md +10 -0
- package/skills/media/kaizen-video/examples/sample-film/film.json +63 -0
- package/skills/media/kaizen-video/film.html +246 -0
- package/skills/media/kaizen-video/hyperframes.json +12 -0
- package/skills/media/kaizen-video/presets/educational-warning/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/educational-warning/README.md +26 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/educational-warning/index.html +236 -0
- package/skills/media/kaizen-video/presets/educational-warning/package.json +10 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-cinematic-drama/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-fun-energetic/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-promo-booth/style.json +39 -0
- package/skills/media/kaizen-video/presets/film-viral-social/CREDITS.txt +1 -0
- package/skills/media/kaizen-video/presets/film-viral-social/README.md +2 -0
- package/skills/media/kaizen-video/presets/film-viral-social/style.json +39 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/README.md +35 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/chime.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-cheerful/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/CREDITS.txt +3 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/README.md +52 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/InstrumentSerif.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/fonts/SpaceMono.ttf +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/boing.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/pop.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/tick.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/index.html +272 -0
- package/skills/media/kaizen-video/presets/tiktok-explainer/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/CREDITS.txt +12 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/README.md +51 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/projector.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/index.html +260 -0
- package/skills/media/kaizen-video/presets/tiktok-mystery/package.json +10 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/CREDITS.txt +8 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/README.md +24 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/clock.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ding.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/ending.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/heart.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/reveal.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/shutter.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/stamp.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/assets/sfx/whoosh.wav +0 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/index.html +266 -0
- package/skills/media/kaizen-video/presets/tiktok-scam-alert/package.json +10 -0
- package/skills/media/kaizen-video/references/presets.md +76 -0
- package/skills/{story-video → media/kaizen-video}/references/production.md +2 -2
- package/skills/{story-video → media/kaizen-video}/references/revisions.md +9 -9
- package/skills/media/kaizen-video/scripts/__pycache__/intake.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_film.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/make_tiktok.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/render_cut.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/sfx.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/srt.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/transcribe.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/__pycache__/vo.cpython-314.pyc +0 -0
- package/skills/media/kaizen-video/scripts/check.sh +22 -0
- package/skills/{story-video → media/kaizen-video}/scripts/intake.py +2 -2
- package/skills/media/kaizen-video/scripts/make_film.py +263 -0
- package/skills/media/kaizen-video/scripts/make_tiktok.py +213 -0
- package/skills/{story-video → media/kaizen-video}/scripts/master.sh +12 -11
- package/skills/media/kaizen-video/scripts/render_cut.py +88 -0
- package/skills/media/kaizen-video/scripts/setup.sh +14 -0
- package/skills/{story-video → media/kaizen-video}/scripts/sfx.py +3 -3
- package/skills/media/kaizen-video/scripts/srt.py +9 -0
- package/skills/media/kaizen-video/scripts/still.sh +7 -0
- package/skills/media/kaizen-video/scripts/transcribe.py +45 -0
- package/skills/media/kaizen-video/tools/carve.mjs +560 -0
- package/skills/media/kaizen-video/tools/lib/main-module.mjs +13 -0
- package/web/board/views.ts +1 -1
- package/web/board.html +3 -1
- package/web/board.js +2 -2
- package/web/notebook.js +1 -1
- package/web/notes.html +2 -0
- package/web/term.js +38 -36
- package/web/term.ts +43 -10
- package/web/video.html +298 -0
- package/web/video.js +81 -0
- package/web/video.ts +610 -0
- package/skills/story-video/SKILL.md +0 -108
- package/skills/story-video/engine/package.json +0 -12
- package/skills/story-video/engine/src/Root.tsx +0 -24
- package/skills/story-video/engine/src/engine/Film.tsx +0 -405
- package/skills/story-video/engine/src/engine/presets.json +0 -58
- package/skills/story-video/engine/src/index.ts +0 -3
- package/skills/story-video/engine/tsconfig.json +0 -1
- package/skills/story-video/examples/sample-film/README.md +0 -12
- package/skills/story-video/examples/sample-film/film.json +0 -16
- package/skills/story-video/references/presets.md +0 -21
- package/skills/story-video/scripts/check.sh +0 -19
- package/skills/story-video/scripts/setup.sh +0 -25
- package/skills/story-video/scripts/srt.py +0 -30
- package/skills/story-video/scripts/still.sh +0 -10
- /package/skills/{story-deck → media/kaizen-deck}/examples/build-example.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/rules.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/references/story-arc.md +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/kit.js +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/prep_images.py +0 -0
- /package/skills/{story-deck → media/kaizen-deck}/scripts/qa.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/examples/sample-film/lines.json +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/licensing.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/references/script.md +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/music.sh +0 -0
- /package/skills/{story-video → media/kaizen-video}/scripts/vo.py +0 -0
package/README.md
CHANGED
|
@@ -1,78 +1,118 @@
|
|
|
1
|
-
<
|
|
2
|
-
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/banner.png" alt="kaizen" width="
|
|
3
|
-
</
|
|
1
|
+
<p align="center">
|
|
2
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/readme/banner.png" alt="kaizen: build what you have in mind. Apps, documents, videos and games, with your AI agent" width="100%">
|
|
3
|
+
</p>
|
|
4
4
|
|
|
5
|
-
<p align="center"
|
|
5
|
+
<p align="center">
|
|
6
|
+
<b>Build what you have in mind: an app, a document, a video, a game.</b><br>
|
|
7
|
+
Your AI agent plans it, you approve, it builds, and a reviewer that never saw the work checks it.
|
|
8
|
+
</p>
|
|
6
9
|
|
|
7
|
-
<p align="center"
|
|
10
|
+
<p align="center">
|
|
11
|
+
<a href="https://www.npmjs.com/package/kaizen-agent"><img alt="npm" src="https://img.shields.io/npm/v/kaizen-agent?style=for-the-badge&color=000&labelColor=1c1c1e&logo=npm"></a>
|
|
12
|
+
<a href="https://github.com/hfadhlullah/kaizen/stargazers"><img alt="Stars" src="https://img.shields.io/github/stars/hfadhlullah/kaizen?style=for-the-badge&logo=github&color=000&labelColor=1c1c1e"></a>
|
|
13
|
+
<img alt="Bun 1.1+" src="https://img.shields.io/badge/Bun-1.1%2B-000?style=for-the-badge&logo=bun&logoColor=white">
|
|
14
|
+
<img alt="macOS · Linux · Windows beta" src="https://img.shields.io/badge/macOS_·_Linux_·_Windows_beta-000?style=for-the-badge">
|
|
15
|
+
<a href="LICENSE"><img alt="License MIT" src="https://img.shields.io/badge/license-MIT-000?style=for-the-badge"></a>
|
|
16
|
+
</p>
|
|
8
17
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
You ask an AI coding agent to retry a payment webhook when the bank times out. It starts editing at once, then says *"Done, tests pass."* The tests do pass. They were written by the same agent, from the same assumption: that a timeout means the charge failed. Sometimes the bank said yes and the reply got lost. Now a retry charges the customer twice, and nothing in the agent's report would make you look.
|
|
18
|
+
> **kaizen runs inside an AI agent; it is not one.** The agent does the work; kaizen gives it a plan, an approval stop and an independent review. It works with Claude Code, Codex, Antigravity and Gemini CLI, and the installer offers to set one up if you have none. A run costs several times a single prompt, so it is meant for work where being wrong is expensive. Windows support is in beta.
|
|
12
19
|
|
|
13
|
-
|
|
20
|
+
<br>
|
|
14
21
|
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
| The author grades its own work, so its blind spot is in the code *and* in the check | A second agent that never saw the work reviews it, from the request, the plan and the diff only |
|
|
19
|
-
| Findings get fixed in an endless loop, or silently dropped | Serious findings are fixed and re-checked, at most twice; the rest come to you, and what is left goes to a backlog |
|
|
20
|
-
| Requests live in sheets, docs and social posts; the plan in one chat, the review in another, the commit somewhere else | One board, from an icebox that gathers those requests to an idea, a plan, the build, the review, the fix loop and the commit, every step on file |
|
|
22
|
+
<p align="center">
|
|
23
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/readme/what-kaizen-does.png" alt="What kaizen does: plan first, a reviewer that starts cold, a fix loop that ends, one board, requests from where they live, a notebook agents read, on your phone, any agent. Built by Husein Fadhlullah" width="100%">
|
|
24
|
+
</p>
|
|
21
25
|
|
|
22
|
-
|
|
26
|
+
<details>
|
|
27
|
+
<summary><b>What kaizen does</b>, as text</summary>
|
|
23
28
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
**
|
|
29
|
+
| | |
|
|
30
|
+
|---|---|
|
|
31
|
+
| **Any kind of work** | Each run works out what it is making (code, writing, communication, operations, research) and plans, builds and reviews it in those terms. Videos and decks go through the bundled `kaizen-video` (films from scratch, or editing your own footage) and `kaizen-deck` skills; books go through `kaizen-book` (interview, writing, proofreading, then an e-book reader plus colour and black-and-white print PDFs). |
|
|
32
|
+
| **Plan first** | The agent writes what it will do, and what *wrong* would look like, then waits for your approve or change. |
|
|
33
|
+
| **A reviewer that starts cold** | A second agent reviews from the request, the plan and the diff only. It never saw the work, so it has nothing to defend. |
|
|
34
|
+
| **A fix loop that ends** | Serious findings are fixed and re-checked, at most twice. The rest come to you; what is left goes to a backlog. |
|
|
35
|
+
| **One board** | `kaizen web`: icebox, ideas, plan, build, review, fix, commit and push, in the browser or the terminal. |
|
|
36
|
+
| **Requests from where they live** | Sheets, docs and social posts are gathered into an icebox, grouped, and promoted to ideas when you pick them. |
|
|
37
|
+
| **A notebook agents read first** | An Obsidian-compatible vault under `.kaizen/notes/` that every stage reads before it plans or builds. |
|
|
38
|
+
| **On your phone** | The board installs as a PWA over Tailscale. `kaizen setup` prints the QR code. |
|
|
39
|
+
| **Any agent, any session** | Every stage is a file in `.kaizen/runs/<id>/`, so another session or another tool can pick a run up. |
|
|
40
|
+
|
|
41
|
+
</details>
|
|
42
|
+
|
|
43
|
+
<p align="center">
|
|
44
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/hero.gif" alt="Animated walkthrough of the kaizen board, from a gathered request to a reviewed, fixed run" width="100%">
|
|
45
|
+
</p>
|
|
29
46
|
|
|
30
|
-
|
|
31
|
-
- Already use an AI coding agent, and have caught it confidently wrong more than once.
|
|
32
|
-
- Change things where a mistake is expensive: money, data, migrations, anything a customer sees.
|
|
33
|
-
- Want to see and steer the plan before code is written, not reverse-engineer it from a diff.
|
|
47
|
+
## The problem
|
|
34
48
|
|
|
35
|
-
|
|
49
|
+
You ask an AI coding agent to retry a payment webhook when the bank times out. It starts editing at once, then says *"Done, tests pass."* The tests do pass. They were written by the same agent, from the same assumption: that a timeout means the charge failed. Sometimes the bank said yes and the reply got lost. Now a retry charges the customer twice, and nothing in the agent's report would make you look.
|
|
36
50
|
|
|
37
|
-
|
|
38
|
-
- You want the agent to ship without you. Kaizen is built to stop and ask.
|
|
39
|
-
- You do not use an AI coding agent. Kaizen runs inside one; it is not one.
|
|
51
|
+
None of this is a lack of intelligence. It is a missing workflow. Many assistants now ask before they act, and some check their own draft. The difference is *who* checks: a reviewer that shares the author's context shares its blind spots. Kaizen's reviewer starts cold, on purpose.
|
|
40
52
|
|
|
41
53
|
## Install
|
|
42
54
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
55
|
+
1. **Install** on macOS or Linux:
|
|
56
|
+
```bash
|
|
57
|
+
curl -fsSL https://bun.sh/install | bash && ~/.bun/bin/bunx kaizen-agent
|
|
58
|
+
```
|
|
59
|
+
On Windows (beta), in PowerShell:
|
|
60
|
+
```powershell
|
|
61
|
+
irm bun.sh/install.ps1 | iex; & "$env:USERPROFILE\.bun\bin\bunx.exe" kaizen-agent
|
|
62
|
+
```
|
|
63
|
+
Already have Bun? `bunx kaizen-agent`.
|
|
64
|
+
2. **Run `kaizen setup`.** It walks you through your project, settings, the board and the phone app.
|
|
65
|
+
3. **Open the board** with `kaizen web`, or type `/kaizen <request>` in your agent.
|
|
66
|
+
|
|
67
|
+
<p align="center">
|
|
68
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/board-v2.png" alt="The kaizen board: icebox, ideas, plan, build, review and done columns" width="100%">
|
|
69
|
+
</p>
|
|
70
|
+
|
|
71
|
+
## Requirements
|
|
72
|
+
|
|
73
|
+
| | |
|
|
74
|
+
|---|---|
|
|
75
|
+
| Runtime | [Bun](https://bun.sh) 1.1 or newer. The installer above gets it. |
|
|
76
|
+
| Agent | Claude Code, Codex, Antigravity or Gemini CLI. Claude Code gets the full flow with separate subagents; the others run every stage in one session. |
|
|
77
|
+
| Git | Needed for the board's commit and push. Optional otherwise. |
|
|
78
|
+
| OS | macOS, Linux. Windows in beta. |
|
|
79
|
+
| Phone app | [Tailscale](https://tailscale.com/download) on the PC and the phone, same account. |
|
|
80
|
+
|
|
81
|
+
## How it works
|
|
82
|
+
|
|
83
|
+
```mermaid
|
|
84
|
+
flowchart LR
|
|
85
|
+
R[Request] --> P[Plan]
|
|
86
|
+
P --> A{You approve}
|
|
87
|
+
A -- change --> P
|
|
88
|
+
A -- yes --> B[Build]
|
|
89
|
+
B --> V[Review<br/>cold agent]
|
|
90
|
+
V -- high findings --> F[Fix] --> V
|
|
91
|
+
V -- clean --> D[Done]
|
|
92
|
+
V -. the rest .-> L[Backlog]
|
|
47
93
|
```
|
|
48
94
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
### Windows
|
|
54
|
-
|
|
55
|
-
Windows support is still in beta. In PowerShell:
|
|
56
|
-
|
|
57
|
-
```powershell
|
|
58
|
-
irm bun.sh/install.ps1 | iex; & "$env:USERPROFILE\.bun\bin\bunx.exe" kaizen-agent
|
|
59
|
-
```
|
|
95
|
+
<p align="center">
|
|
96
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/readme/architecture.png" alt="From idea to finished work: your idea, plan, build, a review in a separate context, a refine loop, and the finished app, document, video or game" width="100%">
|
|
97
|
+
</p>
|
|
60
98
|
|
|
61
|
-
|
|
99
|
+
Each stage is its own agent with a clean context: planner, builder, reviewer. They never share a context window. The main thread owns the run's state in `.kaizen/runs/<id>/state.json`; the plan, the build notes, the review and every fix round are files beside it. Small changes take a short path in one session, so a typo does not cost a payroll change.
|
|
62
100
|
|
|
63
|
-
##
|
|
101
|
+
## What kaizen changes on your machine
|
|
64
102
|
|
|
65
|
-
|
|
66
|
-
kaizen
|
|
67
|
-
|
|
103
|
+
- Installs itself to `~/kaizen`, and keeps settings and the project list in `~/.kaizen/`.
|
|
104
|
+
- Links its skills, agents and `/kaizen-*` commands into each agent it finds: `~/.claude`, `~/.codex`, `~/.agents`, `~/.gemini`. Re-running is safe.
|
|
105
|
+
- Per project, `kaizen init` writes `.kaizen/` and, in a git repo, a `.gitignore` entry that keeps run state out of commits but keeps the `spec*.md` files and `config.yml`.
|
|
106
|
+
- `kaizen web` serves the board on `127.0.0.1:7420`. After `kaizen setup` it is also reachable from your own Tailscale network, nothing wider; never use `tailscale funnel`.
|
|
107
|
+
- `kaizen uninstall` removes the app and its links, and asks whether to keep your runs and settings (`--purge` deletes them too).
|
|
68
108
|
|
|
69
|
-
|
|
109
|
+
## Who it is for
|
|
70
110
|
|
|
71
|
-
|
|
111
|
+
**Good fit if you** build and maintain a product with an AI coding agent, have caught it confidently wrong more than once, and change things where a mistake is expensive: money, data, migrations, anything a customer sees.
|
|
72
112
|
|
|
73
|
-
|
|
113
|
+
**Not for you if** your changes are typos and one-liners, or you want the agent to ship without you. Kaizen is built to stop and ask.
|
|
74
114
|
|
|
75
|
-
|
|
115
|
+
## Phone app
|
|
76
116
|
|
|
77
117
|
The board is a PWA: installed from the browser, it opens full screen with the kaizen icon. It needs [Tailscale](https://tailscale.com/download) on this PC and on your phone, both signed in to the same account.
|
|
78
118
|
|
|
@@ -82,32 +122,18 @@ kaizen setup # guided: starts the board, runs tailscale serve, prints a Q
|
|
|
82
122
|
|
|
83
123
|
Scan the QR code with your phone camera, then **Add to Home screen** (iOS: Share menu; Android: browser menu, *Install app*).
|
|
84
124
|
|
|
85
|
-
By hand: run `kaizen web`, then `tailscale serve --bg 127.0.0.1:7420`, add `<pc>.<tailnet>.ts.net` to `~/.kaizen/web-hosts`, and open `https://<pc>.<tailnet>.ts.net` on the phone. Details in [docs/guide.md](docs/guide.md#
|
|
125
|
+
By hand: run `kaizen web`, then `tailscale serve --bg 127.0.0.1:7420`, add `<pc>.<tailnet>.ts.net` to `~/.kaizen/web-hosts`, and open `https://<pc>.<tailnet>.ts.net` on the phone. Details in [docs/guide.md](docs/guide.md#in-a-browser). Never use `tailscale funnel` — it publishes the board to the internet.
|
|
86
126
|
|
|
87
127
|
To add a launcher (Spotlight on macOS, the app menu on Linux, a Desktop shortcut on Windows), run `kaizen web --shortcut` once.
|
|
88
128
|
|
|
89
129
|
## Development
|
|
90
130
|
|
|
91
|
-
TypeScript on
|
|
131
|
+
TypeScript on Bun, zero runtime dependencies. No build step for the CLI.
|
|
92
132
|
|
|
93
133
|
```bash
|
|
94
134
|
git clone https://github.com/hfadhlullah/kaizen.git && cd kaizen
|
|
95
|
-
bun install
|
|
96
|
-
bun
|
|
97
|
-
```
|
|
98
|
-
|
|
99
|
-
To work on kaizen, install from your checkout so every link points at it:
|
|
100
|
-
|
|
101
|
-
```bash
|
|
102
|
-
bun run cli/install.ts
|
|
103
|
-
```
|
|
104
|
-
|
|
105
|
-
Skills, agents and commands are symlinked into every AI agent it finds; re-running is safe. From a checkout the workflow updates on any run, and `kaizen upgrade` leaves your local edits alone — update them with git.
|
|
106
|
-
|
|
107
|
-
After editing `web/notebook.ts` or `web/notes-lib.ts`, rebuild the committed bundle:
|
|
108
|
-
|
|
109
|
-
```bash
|
|
110
|
-
bun run build:web # rewrites web/notebook.js; a test fails if it goes stale
|
|
135
|
+
bun install && bun test
|
|
136
|
+
bun run cli/install.ts # install from this checkout
|
|
111
137
|
```
|
|
112
138
|
|
|
113
139
|
### Layout
|
|
@@ -115,7 +141,7 @@ bun run build:web # rewrites web/notebook.js; a test fails if it goes st
|
|
|
115
141
|
| Path | What it is |
|
|
116
142
|
|---|---|
|
|
117
143
|
| `cli/` | The installer and the runtime behind every `kaizen` command: `install.ts` (the CLI), `state.ts` (runs), `web.ts` (board server), `dashboard.ts` (terminal board), `sources.ts`, `settings.ts`, `notes.ts`. Tests beside them. |
|
|
118
|
-
| `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, `
|
|
144
|
+
| `skills/` | The workflow as Agent Skills folders: `kaizen/` (`SKILL.md`, the `spec-*.md` stage contracts, `adapters.md`, `config.default.yml`), `kaizen-help/`, and `media/` with `kaizen-deck/`, `kaizen-video/`, `kaizen-book/`. |
|
|
119
145
|
| `agents/` | The Claude Code subagents: `kaizen-planner`, `kaizen-builder`, `kaizen-reviewer`. |
|
|
120
146
|
| `commands/` | The `/kaizen-*` slash commands, one file each. |
|
|
121
147
|
| `web/` | The board and notebook front end: `board.html`, `notes.html`, `notebook.ts`. |
|
|
@@ -124,20 +150,21 @@ bun run build:web # rewrites web/notebook.js; a test fails if it goes st
|
|
|
124
150
|
| `design.md` | Design notes for the repo. |
|
|
125
151
|
| `.kaizen/` | This repo's own runs — kaizen is built with kaizen. Gitignored: a fresh clone gets it on its first run. |
|
|
126
152
|
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
```bash
|
|
130
|
-
cd bot
|
|
131
|
-
cp .env.example .env # PROVIDER, MODEL, that provider's key
|
|
132
|
-
bun start # http://127.0.0.1:7430 — run `kaizen web` first
|
|
133
|
-
bun test
|
|
134
|
-
bun run build # the desktop app, into dist/
|
|
135
|
-
```
|
|
153
|
+
Kaizen Bot: [bot/README.md](bot/README.md). Other docs: [docs/README.md](docs/README.md). Pull requests welcome; CI runs `bun test` on Ubuntu and Windows and it must pass. For non-trivial work, dogfood: `/kaizen <change>` in this repo plans, builds and reviews it — [`skills/kaizen/spec.md`](skills/kaizen/spec.md) is the contract the stages follow.
|
|
136
154
|
|
|
137
|
-
|
|
155
|
+
## What you can make
|
|
138
156
|
|
|
139
|
-
|
|
157
|
+
<p align="center">
|
|
158
|
+
<img src="https://raw.githubusercontent.com/hfadhlullah/kaizen/main/assets/readme/bento.png" alt="What you can make with kaizen: an app, a document, a video, a game. Plan first, a fresh review, keep refining, keep it together" width="100%">
|
|
159
|
+
</p>
|
|
140
160
|
|
|
141
161
|
## License
|
|
142
162
|
|
|
143
|
-
MIT
|
|
163
|
+
kaizen is licensed under the [MIT License](LICENSE).
|
|
164
|
+
|
|
165
|
+
<p>
|
|
166
|
+
<a href="https://github.com/hfadhlullah"><img src="https://github.com/hfadhlullah.png?size=64" width="32" height="32" alt="" align="left"></a>
|
|
167
|
+
Built by <a href="https://github.com/hfadhlullah"><b>Husein Fadhlullah</b></a>, with kaizen.
|
|
168
|
+
</p>
|
|
169
|
+
|
|
170
|
+
<sub>kaizen is not affiliated with Anthropic, OpenAI or Google. Claude Code, Codex, Antigravity and Gemini CLI are trademarks of their owners.</sub>
|
package/cli/build-web.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// `bun run build:web`: bundles the notebook's script and CodeMirror into
|
|
2
2
|
// web/notebook.js, the in-card terminal and xterm.js into web/term.js (+ its
|
|
3
3
|
// stylesheet, web/term.css), the board's modules (web/board/) into web/board.js and
|
|
4
|
-
// Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, all committed, so an install or a checkout serves it with no
|
|
4
|
+
// Kaizen Bot's page modules (bot/web/app/) into bot/web/app.js, the Video page's script into web/video.js, all committed, so an install or a checkout serves it with no
|
|
5
5
|
// install step and no network. The first line is a hash of what it was built from;
|
|
6
6
|
// cli/notes.test.ts fails when a committed file no longer matches.
|
|
7
7
|
import { createHash } from "node:crypto";
|
|
@@ -10,11 +10,12 @@ import { join, dirname } from "node:path";
|
|
|
10
10
|
|
|
11
11
|
const root = dirname(import.meta.dir);
|
|
12
12
|
const modules = (dir: string) => readdirSync(join(root, dir)).filter((f) => f.endsWith(".ts")).sort().map((f) => `${dir}/${f}`);
|
|
13
|
-
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", ...modules("web/board"), ...modules("bot/web/app")];
|
|
13
|
+
const SOURCES = ["web/notebook.ts", "web/notes-lib.ts", "web/term.ts", "web/video.ts", ...modules("web/board"), ...modules("bot/web/app")];
|
|
14
14
|
export const OUT = join(root, "web", "notebook.js");
|
|
15
15
|
export const TERM_OUT = join(root, "web", "term.js");
|
|
16
16
|
export const BOARD_OUT = join(root, "web", "board.js");
|
|
17
17
|
export const BOT_OUT = join(root, "bot", "web", "app.js");
|
|
18
|
+
export const VIDEO_OUT = join(root, "web", "video.js");
|
|
18
19
|
|
|
19
20
|
// The sources and the exact dependency versions: a CodeMirror bump is a rebuild too.
|
|
20
21
|
export function sourceHash() {
|
|
@@ -31,6 +32,7 @@ if (import.meta.main) {
|
|
|
31
32
|
["web/term.ts", TERM_OUT, "terminal"],
|
|
32
33
|
["web/board/main.ts", BOARD_OUT, "board"],
|
|
33
34
|
["bot/web/app/main.ts", BOT_OUT, "bot page"],
|
|
35
|
+
["web/video.ts", VIDEO_OUT, "video page"],
|
|
34
36
|
] as const;
|
|
35
37
|
for (const [src, out, what] of bundles) {
|
|
36
38
|
// The terminal, the board and the bot page are classic scripts: wrapped, or their
|
package/cli/hermes.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
// WhatsApp through Hermes (`hermes send`): the chats it knows, and one file with a
|
|
2
|
+
// message to one of them. Always argv, never a shell line: the message is one argument.
|
|
3
|
+
import { spawn } from "node:child_process";
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
|
|
7
|
+
export type Chat = { id: string; name: string; type: string };
|
|
8
|
+
type Out = { code: number | string; out: string; err: string };
|
|
9
|
+
|
|
10
|
+
// On PATH first (tests put a fake there), else where Hermes' installer puts it: a board
|
|
11
|
+
// started from a desktop launcher often has no ~/.local/bin on its PATH.
|
|
12
|
+
function bin() {
|
|
13
|
+
const p = Bun.which("hermes", { PATH: process.env.PATH ?? "" });
|
|
14
|
+
if (p) return p;
|
|
15
|
+
const home = join(process.env.HOME || process.env.USERPROFILE || "", ".local", "bin", "hermes");
|
|
16
|
+
return existsSync(home) ? home : null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function run(args: string[], ms: number): Promise<Out> {
|
|
20
|
+
const exe = bin();
|
|
21
|
+
if (!exe) return Promise.resolve({ code: "Hermes is not installed (no hermes on PATH or in ~/.local/bin)", out: "", err: "" });
|
|
22
|
+
return new Promise((done) => {
|
|
23
|
+
const p = spawn(exe, args, { env: process.env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true });
|
|
24
|
+
let out = "", err = "";
|
|
25
|
+
p.stdout!.on("data", (c) => { out += c; });
|
|
26
|
+
p.stderr!.on("data", (c) => { err += c; });
|
|
27
|
+
const t = setTimeout(() => { p.kill("SIGKILL"); done({ code: `Hermes did not finish in ${Math.round(ms / 1000)} s`, out, err }); }, ms);
|
|
28
|
+
p.on("error", (e) => { clearTimeout(t); done({ code: `Hermes could not start: ${e.message}`, out, err }); });
|
|
29
|
+
p.on("close", (c) => { clearTimeout(t); done({ code: c ?? 1, out, err }); });
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
const lastLine = (s: string) => s.trim().split("\n").filter(Boolean).pop()?.replace(/^hermes send:\s*/, "") ?? "";
|
|
33
|
+
|
|
34
|
+
export async function targets(): Promise<{ chats: Chat[] } | { error: string }> {
|
|
35
|
+
const r = await run(["send", "--list", "whatsapp", "--json"], 10_000);
|
|
36
|
+
if (typeof r.code === "string") return { error: r.code };
|
|
37
|
+
let j: any;
|
|
38
|
+
try { j = JSON.parse(r.out); } catch { return { error: lastLine(r.err) || "Hermes gave no chat list" }; }
|
|
39
|
+
const wa = j?.platforms?.whatsapp;
|
|
40
|
+
if (!Array.isArray(wa) || !wa.length) return { error: "WhatsApp is not set up in Hermes (hermes gateway setup)" };
|
|
41
|
+
return { chats: wa.filter((c: any) => typeof c?.id === "string" && c.id).map((c: any) => ({ id: `whatsapp:${c.id}`, name: String(c.name ?? c.id), type: String(c.type ?? "") })) };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Hermes attaches every MEDIA:<path> it finds in the text, so the user's words may not
|
|
45
|
+
// carry one: that would send a file the page never offered.
|
|
46
|
+
export function message(text: string, file: string): string | { error: string } {
|
|
47
|
+
if (text.length > 1000) return { error: "the message is over 1,000 characters" };
|
|
48
|
+
if (/MEDIA:/i.test(text)) return { error: "the message may not contain MEDIA:" };
|
|
49
|
+
if (/[`\n\r]/.test(file)) return { error: "the file's path cannot be passed to Hermes" };
|
|
50
|
+
// The path in backticks: Hermes reads a quoted path whole, spaces and all.
|
|
51
|
+
return `${text.trim()}${text.trim() ? "\n" : ""}MEDIA:\`${file}\``;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Sent only when Hermes exits 0 with {success: true}; anything else is its own error.
|
|
55
|
+
export async function sendMedia(target: string, text: string, file: string): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
56
|
+
const m = message(text, file);
|
|
57
|
+
if (typeof m !== "string") return { ok: false, error: m.error };
|
|
58
|
+
// "--" so a message starting with "-" is text, not an option.
|
|
59
|
+
const r = await run(["send", "-t", target, "--json", "--", m], 5 * 60_000);
|
|
60
|
+
if (typeof r.code === "string") return { ok: false, error: r.code };
|
|
61
|
+
let j: any = null;
|
|
62
|
+
try { j = JSON.parse(r.out); } catch { /* not JSON: fall through to stderr */ }
|
|
63
|
+
if (r.code === 0 && j?.success && !j.error) return { ok: true };
|
|
64
|
+
return { ok: false, error: String(j?.error ?? (lastLine(r.err) || `Hermes exited ${r.code}`)).slice(0, 400) };
|
|
65
|
+
}
|
package/cli/install.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
import {
|
|
3
3
|
symlinkSync, mkdirSync, readdirSync, lstatSync, unlinkSync,
|
|
4
4
|
existsSync, copyFileSync, readFileSync, appendFileSync, rmSync, writeFileSync, renameSync,
|
|
5
|
-
statSync, cpSync, realpathSync, rmdirSync,
|
|
5
|
+
statSync, cpSync, realpathSync, rmdirSync, readlinkSync,
|
|
6
6
|
} from "node:fs";
|
|
7
7
|
import { join, dirname, basename, resolve } from "node:path";
|
|
8
8
|
import { homedir, tmpdir } from "node:os";
|
|
@@ -46,8 +46,12 @@ const c = {
|
|
|
46
46
|
green: (s: string) => `\x1b[32m${s}\x1b[0m`,
|
|
47
47
|
};
|
|
48
48
|
|
|
49
|
-
// Skill folders shipped in skills/ and linked into every agent
|
|
50
|
-
|
|
49
|
+
// Skill folders shipped in skills/ and linked into every agent, by their last
|
|
50
|
+
// path segment: media/kaizen-deck is linked as skills/kaizen-deck.
|
|
51
|
+
const SKILLS = ["kaizen", "kaizen-help", "media/kaizen-deck", "media/kaizen-video", "media/kaizen-book"];
|
|
52
|
+
// Skill names an older release linked; uninstall drops them and install
|
|
53
|
+
// clears them when they are still our links.
|
|
54
|
+
const RENAMED_SKILLS = ["story-deck", "story-video", "kaizen-motion"];
|
|
51
55
|
|
|
52
56
|
// Where each agent reads skills from. The skill folders follow the open Agent
|
|
53
57
|
// Skills standard, so every one of these can run the workflow; agents/ and
|
|
@@ -244,7 +248,9 @@ async function removeEverything() {
|
|
|
244
248
|
|
|
245
249
|
for (const root of roots) {
|
|
246
250
|
for (const a of AGENTS) {
|
|
247
|
-
for (const n of SKILLS) drop(join(root, a.dir, "skills", n));
|
|
251
|
+
for (const n of SKILLS) drop(join(root, a.dir, "skills", basename(n)));
|
|
252
|
+
// An old name is only ours while it is still a link; a real folder there is the user's.
|
|
253
|
+
for (const n of RENAMED_SKILLS) if (isLink(join(root, a.dir, "skills", n))) drop(join(root, a.dir, "skills", n));
|
|
248
254
|
for (const sub of ["agents", "commands"]) {
|
|
249
255
|
const dir = join(root, a.dir, sub);
|
|
250
256
|
try {
|
|
@@ -311,6 +317,12 @@ function removeLink(p: string) {
|
|
|
311
317
|
try { unlinkSync(p); } catch { rmdirSync(p); }
|
|
312
318
|
}
|
|
313
319
|
|
|
320
|
+
// Setup only wires the board to the phone; it installs nothing, so no agent is needed.
|
|
321
|
+
if (args.has("setup")) {
|
|
322
|
+
await runSetup();
|
|
323
|
+
process.exit(0);
|
|
324
|
+
}
|
|
325
|
+
|
|
314
326
|
// Nothing to install into. kaizen is a workflow an agent runs; on its own it does
|
|
315
327
|
// nothing, so send them to the agent first rather than laying out files no reader
|
|
316
328
|
// will ever load.
|
|
@@ -406,11 +418,6 @@ async function openUrl(url: string) {
|
|
|
406
418
|
try { await Bun.$`${opener} ${url}`.quiet(); return true; } catch { return false; }
|
|
407
419
|
}
|
|
408
420
|
|
|
409
|
-
if (args.has("setup")) {
|
|
410
|
-
await runSetup();
|
|
411
|
-
process.exit(0);
|
|
412
|
-
}
|
|
413
|
-
|
|
414
421
|
async function runSetup() {
|
|
415
422
|
const s = await import("./setup.ts");
|
|
416
423
|
if (!process.stdin.isTTY) { console.log(s.manual()); return; }
|
|
@@ -523,7 +530,7 @@ if (interactive && !upgrade && Bun.argv.length === 2) {
|
|
|
523
530
|
if (!stale) {
|
|
524
531
|
const here = found(home);
|
|
525
532
|
const linked = here.length > 0 && here.every((a) =>
|
|
526
|
-
SKILLS.every((n) => existsSync(join(home, a.dir, "skills", n))));
|
|
533
|
+
SKILLS.every((n) => existsSync(join(home, a.dir, "skills", basename(n)))));
|
|
527
534
|
if (linked) {
|
|
528
535
|
const { dashboard } = await import("./dashboard.ts");
|
|
529
536
|
await dashboard(repoDir, here.map((a) => a.name), async (action) => {
|
|
@@ -899,6 +906,13 @@ for (const [src, sub] of links) {
|
|
|
899
906
|
wrote++;
|
|
900
907
|
if (verbose) console.log(c.dim(` link ${name}`));
|
|
901
908
|
}
|
|
909
|
+
if (!check) {
|
|
910
|
+
for (const a of targets) for (const n of RENAMED_SKILLS) {
|
|
911
|
+
const p = join(root, a.dir, "skills", n);
|
|
912
|
+
// Ours only: a dangling link to a folder of that same name, which kaizen has since renamed.
|
|
913
|
+
if (isLink(p) && !existsSync(p) && basename(readlinkSync(p).replace(/[\\/]+$/, "")) === n) removeLink(p);
|
|
914
|
+
}
|
|
915
|
+
}
|
|
902
916
|
|
|
903
917
|
// A project's spec copies are what a run there reads, and they are copied once.
|
|
904
918
|
function specDrift() {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// LocalSend's sending half against a fake device that speaks protocol v2: what it
|
|
2
|
+
// receives, and what each of its refusals reads as on the page.
|
|
3
|
+
import { test, expect } from "bun:test";
|
|
4
|
+
import { mkdtempSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { randomBytes, X509Certificate, createPrivateKey } from "node:crypto";
|
|
8
|
+
import { identity, sendFile, type Device } from "./localsend.ts";
|
|
9
|
+
|
|
10
|
+
// prepare: what prepare-upload answers (a status, or "hang" to never answer).
|
|
11
|
+
function fakeDevice(prepare: number | "hang") {
|
|
12
|
+
const got: { prepare?: any; upload?: { query: Record<string, string>; bytes: Buffer } } = {};
|
|
13
|
+
const srv = Bun.serve({ port: 0, hostname: "127.0.0.1", async fetch(req) {
|
|
14
|
+
const u = new URL(req.url);
|
|
15
|
+
if (u.pathname === "/api/localsend/v2/prepare-upload") {
|
|
16
|
+
got.prepare = await req.json();
|
|
17
|
+
if (prepare === "hang") { await Bun.sleep(5000); return new Response(null, { status: 500 }); }
|
|
18
|
+
if (prepare !== 200) return new Response(null, { status: prepare });
|
|
19
|
+
return Response.json({ sessionId: "s-1", files: { f1: "tok-1" } });
|
|
20
|
+
}
|
|
21
|
+
if (u.pathname === "/api/localsend/v2/upload") {
|
|
22
|
+
const q = Object.fromEntries(u.searchParams);
|
|
23
|
+
if (q.sessionId !== "s-1" || q.token !== "tok-1" || q.fileId !== "f1") return new Response(null, { status: 403 });
|
|
24
|
+
got.upload = { query: q, bytes: Buffer.from(await req.arrayBuffer()) };
|
|
25
|
+
return new Response(null, { status: 200 });
|
|
26
|
+
}
|
|
27
|
+
return new Response(null, { status: 404 });
|
|
28
|
+
} });
|
|
29
|
+
const dev: Device = { alias: "Pixel 8", deviceModel: "Pixel", deviceType: "mobile", ip: "127.0.0.1", port: srv.port, protocol: "http", fingerprint: "fp" };
|
|
30
|
+
return { dev, got, stop: () => srv.stop(true) };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function file() {
|
|
34
|
+
const f = join(mkdtempSync(join(tmpdir(), "kaizen-ls-")), "demo-9x16.mp4");
|
|
35
|
+
writeFileSync(f, randomBytes(300_000));
|
|
36
|
+
return f;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
test("a send carries the file's bytes with the session and token the device gave", async () => {
|
|
40
|
+
const d = fakeDevice(200), f = file(), steps: string[] = [];
|
|
41
|
+
try {
|
|
42
|
+
expect(await sendFile(d.dev, f, (p) => steps.push(p.phase))).toEqual({ ok: true });
|
|
43
|
+
expect(d.got.prepare.files.f1).toMatchObject({ id: "f1", fileName: "demo-9x16.mp4", size: 300_000, fileType: "video/mp4" });
|
|
44
|
+
expect(d.got.prepare.info.alias).toStartWith("kaizen");
|
|
45
|
+
expect(d.got.upload!.query).toEqual({ sessionId: "s-1", fileId: "f1", token: "tok-1" });
|
|
46
|
+
expect(d.got.upload!.bytes.equals(await Bun.file(f).bytes().then(Buffer.from))).toBe(true);
|
|
47
|
+
expect(steps[0]).toBe("waiting");
|
|
48
|
+
expect(steps.at(-1)).toBe("uploading");
|
|
49
|
+
} finally { d.stop(); }
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test("a refusal, a busy device and no answer each say so, never sent", async () => {
|
|
53
|
+
const f = file();
|
|
54
|
+
for (const [status, words] of [[403, "Pixel 8 said no"], [409, "Pixel 8 is busy with another transfer"], [401, "PIN"], [500, "Pixel 8 answered 500"]] as const) {
|
|
55
|
+
const d = fakeDevice(status);
|
|
56
|
+
try {
|
|
57
|
+
const r = await sendFile(d.dev, f);
|
|
58
|
+
expect(r.ok).toBe(false);
|
|
59
|
+
expect((r as any).error).toContain(words);
|
|
60
|
+
expect(d.got.upload).toBeUndefined();
|
|
61
|
+
} finally { d.stop(); }
|
|
62
|
+
}
|
|
63
|
+
const d = fakeDevice("hang");
|
|
64
|
+
try {
|
|
65
|
+
const r = await sendFile(d.dev, f, () => {}, { acceptMs: 300 });
|
|
66
|
+
expect(r).toEqual({ ok: false, error: "No answer from Pixel 8 in 1 minute" });
|
|
67
|
+
} finally { d.stop(); }
|
|
68
|
+
// Nobody there at all.
|
|
69
|
+
const r = await sendFile({ ...d.dev, port: 1 }, f);
|
|
70
|
+
expect(r.ok).toBe(false);
|
|
71
|
+
expect((r as any).error).toStartWith("Could not reach Pixel 8");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
// A real LocalSend in HTTPS mode refuses a sender without a client certificate
|
|
75
|
+
// ("tlsv13 alert certificate required"), and reads the sender's fingerprint as its SHA-256.
|
|
76
|
+
test("we carry a self-signed certificate whose SHA-256 is our fingerprint", () => {
|
|
77
|
+
const me = identity(), x = new X509Certificate(me.cert);
|
|
78
|
+
expect(x.subject).toBe("CN=kaizen");
|
|
79
|
+
expect(x.verify(x.publicKey)).toBe(true);
|
|
80
|
+
expect(x.checkPrivateKey(createPrivateKey(me.key))).toBe(true);
|
|
81
|
+
expect(x.fingerprint256.replace(/:/g, "")).toBe(me.fingerprint);
|
|
82
|
+
expect(new Date(x.validTo).getTime()).toBeGreaterThan(Date.now() + 365 * 86_400_000);
|
|
83
|
+
expect(identity()).toBe(me);
|
|
84
|
+
});
|