kablan 0.5.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 +65 -0
- package/assets/Kablan.icns +0 -0
- package/bin/cli.js +218 -0
- package/bin/download.js +195 -0
- package/bin/install-app.js +136 -0
- package/package.json +37 -0
package/README.md
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# Kablan
|
|
2
|
+
|
|
3
|
+
Run coding agents — Claude Code, Codex, Gemini CLI, Amp and others — against your repositories
|
|
4
|
+
from a board, and watch them work.
|
|
5
|
+
|
|
6
|
+
Each task gets its own git worktree and its own branch, so several agents can run at once without
|
|
7
|
+
standing on each other. You follow the conversation, review the diff, start the project's dev
|
|
8
|
+
server, and merge or open a PR when it looks right.
|
|
9
|
+
|
|
10
|
+
## Run it
|
|
11
|
+
|
|
12
|
+
```bash
|
|
13
|
+
npx kablan
|
|
14
|
+
```
|
|
15
|
+
|
|
16
|
+
Nothing to install. This downloads the binary for your platform, caches it under `~/.kablan/bin`,
|
|
17
|
+
and opens Kablan in your browser.
|
|
18
|
+
|
|
19
|
+
## Install it as an app (macOS)
|
|
20
|
+
|
|
21
|
+
```bash
|
|
22
|
+
npx kablan --install
|
|
23
|
+
```
|
|
24
|
+
|
|
25
|
+
Puts `Kablan.app` in `~/Applications`. Opening it starts Kablan in the background — no terminal
|
|
26
|
+
window — and opens your browser. Output goes to `~/Library/Logs/Kablan/kablan.log`.
|
|
27
|
+
|
|
28
|
+
Because the bundle is assembled on your machine rather than downloaded, macOS does not quarantine
|
|
29
|
+
it: no "unidentified developer" prompt, and nothing to notarise.
|
|
30
|
+
|
|
31
|
+
- Update: `npx kablan@latest --install`
|
|
32
|
+
- Remove: `npx kablan --uninstall` (cached binaries stay in `~/.kablan/bin`)
|
|
33
|
+
|
|
34
|
+
## What you need
|
|
35
|
+
|
|
36
|
+
- **Node 18 or newer**, to run this wrapper.
|
|
37
|
+
- **A coding agent, already authenticated.** Kablan drives each agent's own CLI, so it uses the
|
|
38
|
+
subscription you already have and never sees your model credentials.
|
|
39
|
+
- **Git.** Every attempt is a worktree on its own branch.
|
|
40
|
+
|
|
41
|
+
Platforms: macOS on Apple silicon and Intel, Linux x64, Windows x64.
|
|
42
|
+
|
|
43
|
+
## Environment
|
|
44
|
+
|
|
45
|
+
| Variable | What it does |
|
|
46
|
+
| --- | --- |
|
|
47
|
+
| `PORT` | Port to serve on. Defaults to one the OS picks. |
|
|
48
|
+
| `HOST` | Host to bind. Defaults to `127.0.0.1`. |
|
|
49
|
+
| `KABLAN_LOCAL=1` | Use binaries from `npx-cli/dist/` instead of a release, for development. |
|
|
50
|
+
| `KABLAN_DEBUG=1` | Verbose wrapper output. |
|
|
51
|
+
|
|
52
|
+
## MCP
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
npx kablan --mcp
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Runs the MCP task server on stdio, so an agent can create and update Kablan tasks itself.
|
|
59
|
+
|
|
60
|
+
## About
|
|
61
|
+
|
|
62
|
+
Kablan is a fork of [Vibe Kanban](https://github.com/BloopAI/vibe-kanban) by Bloop AI, used under
|
|
63
|
+
the Apache License 2.0. It is not affiliated with or endorsed by Bloop AI.
|
|
64
|
+
|
|
65
|
+
Source and issues: [github.com/AmarShaked/kablan.dev](https://github.com/AmarShaked/kablan.dev)
|
|
Binary file
|
package/bin/cli.js
ADDED
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
const { execSync, spawn } = require("child_process");
|
|
4
|
+
const AdmZip = require("adm-zip");
|
|
5
|
+
const path = require("path");
|
|
6
|
+
const fs = require("fs");
|
|
7
|
+
const { ensureBinary, BINARY_TAG, CACHE_DIR, LOCAL_DEV_MODE, LOCAL_DIST_DIR, getLatestVersion } = require("./download");
|
|
8
|
+
const { installMacApp, uninstallMacApp, appDir } = require("./install-app");
|
|
9
|
+
|
|
10
|
+
const CLI_VERSION = require("../package.json").version;
|
|
11
|
+
|
|
12
|
+
// Resolve effective arch for our published 64-bit binaries only.
|
|
13
|
+
// Any ARM → arm64; anything else → x64. On macOS, handle Rosetta.
|
|
14
|
+
function getEffectiveArch() {
|
|
15
|
+
const platform = process.platform;
|
|
16
|
+
const nodeArch = process.arch;
|
|
17
|
+
|
|
18
|
+
if (platform === "darwin") {
|
|
19
|
+
// If Node itself is arm64, we're natively on Apple silicon
|
|
20
|
+
if (nodeArch === "arm64") return "arm64";
|
|
21
|
+
|
|
22
|
+
// Otherwise check for Rosetta translation
|
|
23
|
+
try {
|
|
24
|
+
const translated = execSync("sysctl -in sysctl.proc_translated", {
|
|
25
|
+
encoding: "utf8",
|
|
26
|
+
}).trim();
|
|
27
|
+
if (translated === "1") return "arm64";
|
|
28
|
+
} catch {
|
|
29
|
+
// sysctl key not present → assume true Intel
|
|
30
|
+
}
|
|
31
|
+
return "x64";
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Non-macOS: coerce to broad families we support
|
|
35
|
+
if (/arm/i.test(nodeArch)) return "arm64";
|
|
36
|
+
|
|
37
|
+
// On Windows with 32-bit Node (ia32), detect OS arch via env
|
|
38
|
+
if (platform === "win32") {
|
|
39
|
+
const pa = process.env.PROCESSOR_ARCHITECTURE || "";
|
|
40
|
+
const paw = process.env.PROCESSOR_ARCHITEW6432 || "";
|
|
41
|
+
if (/arm/i.test(pa) || /arm/i.test(paw)) return "arm64";
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
return "x64";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const platform = process.platform;
|
|
48
|
+
const arch = getEffectiveArch();
|
|
49
|
+
|
|
50
|
+
// Map to our build target names
|
|
51
|
+
function getPlatformDir() {
|
|
52
|
+
if (platform === "linux" && arch === "x64") return "linux-x64";
|
|
53
|
+
if (platform === "linux" && arch === "arm64") return "linux-arm64";
|
|
54
|
+
if (platform === "win32" && arch === "x64") return "windows-x64";
|
|
55
|
+
if (platform === "win32" && arch === "arm64") return "windows-arm64";
|
|
56
|
+
if (platform === "darwin" && arch === "x64") return "macos-x64";
|
|
57
|
+
if (platform === "darwin" && arch === "arm64") return "macos-arm64";
|
|
58
|
+
|
|
59
|
+
console.error(`Unsupported platform: ${platform}-${arch}`);
|
|
60
|
+
console.error("Supported platforms:");
|
|
61
|
+
console.error(" - Linux x64");
|
|
62
|
+
console.error(" - Linux ARM64");
|
|
63
|
+
console.error(" - Windows x64");
|
|
64
|
+
console.error(" - Windows ARM64");
|
|
65
|
+
console.error(" - macOS x64 (Intel)");
|
|
66
|
+
console.error(" - macOS ARM64 (Apple Silicon)");
|
|
67
|
+
process.exit(1);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getBinaryName(base) {
|
|
71
|
+
return platform === "win32" ? `${base}.exe` : base;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const platformDir = getPlatformDir();
|
|
75
|
+
// In local dev mode, extract directly to dist directory; otherwise use global cache
|
|
76
|
+
const versionCacheDir = LOCAL_DEV_MODE
|
|
77
|
+
? path.join(LOCAL_DIST_DIR, platformDir)
|
|
78
|
+
: path.join(CACHE_DIR, BINARY_TAG, platformDir);
|
|
79
|
+
|
|
80
|
+
function showProgress(downloaded, total) {
|
|
81
|
+
const percent = total ? Math.round((downloaded / total) * 100) : 0;
|
|
82
|
+
const mb = (downloaded / (1024 * 1024)).toFixed(1);
|
|
83
|
+
const totalMb = total ? (total / (1024 * 1024)).toFixed(1) : "?";
|
|
84
|
+
process.stderr.write(`\r Downloading: ${mb}MB / ${totalMb}MB (${percent}%)`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function extractAndRun(baseName, launch) {
|
|
88
|
+
const binName = getBinaryName(baseName);
|
|
89
|
+
const binPath = path.join(versionCacheDir, binName);
|
|
90
|
+
const zipPath = path.join(versionCacheDir, `${baseName}.zip`);
|
|
91
|
+
|
|
92
|
+
// Clean old binary if exists
|
|
93
|
+
try {
|
|
94
|
+
if (fs.existsSync(binPath)) {
|
|
95
|
+
fs.unlinkSync(binPath);
|
|
96
|
+
}
|
|
97
|
+
} catch (err) {
|
|
98
|
+
if (process.env.KABLAN_DEBUG) {
|
|
99
|
+
console.warn(`Warning: Could not delete existing binary: ${err.message}`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Download if not cached
|
|
104
|
+
if (!fs.existsSync(zipPath)) {
|
|
105
|
+
console.error(`Downloading ${baseName}...`);
|
|
106
|
+
try {
|
|
107
|
+
await ensureBinary(platformDir, baseName, showProgress);
|
|
108
|
+
console.error(""); // newline after progress
|
|
109
|
+
} catch (err) {
|
|
110
|
+
console.error(`\nDownload failed: ${err.message}`);
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Extract
|
|
116
|
+
if (!fs.existsSync(binPath)) {
|
|
117
|
+
try {
|
|
118
|
+
const zip = new AdmZip(zipPath);
|
|
119
|
+
zip.extractAllTo(versionCacheDir, true);
|
|
120
|
+
} catch (err) {
|
|
121
|
+
console.error("Extraction failed:", err.message);
|
|
122
|
+
try {
|
|
123
|
+
fs.unlinkSync(zipPath);
|
|
124
|
+
} catch {}
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (!fs.existsSync(binPath)) {
|
|
130
|
+
console.error(`Extracted binary not found at: ${binPath}`);
|
|
131
|
+
console.error("This usually indicates a corrupt download. Please try again.");
|
|
132
|
+
process.exit(1);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// Set permissions (non-Windows)
|
|
136
|
+
if (platform !== "win32") {
|
|
137
|
+
try {
|
|
138
|
+
fs.chmodSync(binPath, 0o755);
|
|
139
|
+
} catch {}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return launch(binPath);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
async function main() {
|
|
146
|
+
fs.mkdirSync(versionCacheDir, { recursive: true });
|
|
147
|
+
|
|
148
|
+
const args = process.argv.slice(2);
|
|
149
|
+
const isMcpMode = args.includes("--mcp");
|
|
150
|
+
|
|
151
|
+
// `--install` puts a double-clickable app in ~/Applications and exits; it never starts a
|
|
152
|
+
// server itself, so re-running it to update is safe while Kablan is open.
|
|
153
|
+
if (args.includes("--uninstall")) {
|
|
154
|
+
const removed = uninstallMacApp();
|
|
155
|
+
console.log(removed ? `Removed ${removed}` : `Nothing installed at ${appDir()}`);
|
|
156
|
+
console.log("Cached binaries are still in ~/.kablan/bin; delete that to reclaim the space.");
|
|
157
|
+
return;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (args.includes("--install")) {
|
|
161
|
+
await extractAndRun("kablan", (bin) => {
|
|
162
|
+
const app = installMacApp(bin, CLI_VERSION);
|
|
163
|
+
console.log(`\nInstalled ${app}`);
|
|
164
|
+
console.log("Open it from Launchpad or Spotlight — it starts Kablan in the background");
|
|
165
|
+
console.log("and opens your browser. Logs: ~/Library/Logs/Kablan/kablan.log");
|
|
166
|
+
console.log("\nUpdate with `npx kablan@latest --install`, remove with `npx kablan --uninstall`.");
|
|
167
|
+
});
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Non-blocking update check. Skipped in MCP mode, where stdout is a protocol stream, and in
|
|
172
|
+
// local dev mode, where the binaries did not come from a release.
|
|
173
|
+
if (!isMcpMode && !LOCAL_DEV_MODE) {
|
|
174
|
+
getLatestVersion()
|
|
175
|
+
.then((latest) => {
|
|
176
|
+
if (latest && latest !== CLI_VERSION) {
|
|
177
|
+
setTimeout(() => {
|
|
178
|
+
console.log(`\nUpdate available: ${CLI_VERSION} -> ${latest}`);
|
|
179
|
+
console.log(`Run: npx kablan@latest`);
|
|
180
|
+
}, 2000);
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
.catch(() => {});
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
if (isMcpMode) {
|
|
187
|
+
await extractAndRun("kablan-mcp", (bin) => {
|
|
188
|
+
const proc = spawn(bin, [], { stdio: "inherit" });
|
|
189
|
+
proc.on("exit", (c) => process.exit(c || 0));
|
|
190
|
+
proc.on("error", (e) => {
|
|
191
|
+
console.error("MCP server error:", e.message);
|
|
192
|
+
process.exit(1);
|
|
193
|
+
});
|
|
194
|
+
process.on("SIGINT", () => {
|
|
195
|
+
proc.kill("SIGINT");
|
|
196
|
+
});
|
|
197
|
+
process.on("SIGTERM", () => proc.kill("SIGTERM"));
|
|
198
|
+
});
|
|
199
|
+
} else {
|
|
200
|
+
const modeLabel = LOCAL_DEV_MODE ? " (local dev)" : "";
|
|
201
|
+
console.log(`Starting kablan v${CLI_VERSION}${modeLabel}...`);
|
|
202
|
+
await extractAndRun("kablan", (bin) => {
|
|
203
|
+
if (platform === "win32") {
|
|
204
|
+
execSync(`"${bin}"`, { stdio: "inherit" });
|
|
205
|
+
} else {
|
|
206
|
+
execSync(`"${bin}"`, { stdio: "inherit" });
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
main().catch((err) => {
|
|
213
|
+
console.error("Fatal error:", err.message);
|
|
214
|
+
if (process.env.KABLAN_DEBUG) {
|
|
215
|
+
console.error(err.stack);
|
|
216
|
+
}
|
|
217
|
+
process.exit(1);
|
|
218
|
+
});
|
package/bin/download.js
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
const https = require("https");
|
|
2
|
+
const fs = require("fs");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const crypto = require("crypto");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Where the binaries come from.
|
|
8
|
+
*
|
|
9
|
+
* Upstream hosted these in a Cloudflare R2 bucket whose URL was substituted in at pack time —
|
|
10
|
+
* infrastructure this fork does not have, which is why `npx kablan` could never have worked by
|
|
11
|
+
* renaming the package alone. They come from this repository's own GitHub release instead: one
|
|
12
|
+
* artifact store, one place to look when a download fails, and no second account to keep alive.
|
|
13
|
+
*
|
|
14
|
+
* The tag is derived from this package's own version rather than injected, which makes it
|
|
15
|
+
* impossible to publish a package pointing at binaries from a different release.
|
|
16
|
+
*/
|
|
17
|
+
const REPO = "AmarShaked/kablan.dev";
|
|
18
|
+
const PKG_VERSION = require("../package.json").version;
|
|
19
|
+
const BINARY_TAG = `v${PKG_VERSION}`;
|
|
20
|
+
const RELEASE_BASE = `https://github.com/${REPO}/releases/download/${BINARY_TAG}`;
|
|
21
|
+
|
|
22
|
+
const CACHE_DIR = path.join(require("os").homedir(), ".kablan", "bin");
|
|
23
|
+
|
|
24
|
+
// Local development mode: use binaries from npx-cli/dist/ instead of the release.
|
|
25
|
+
// Only activate if dist/ exists (i.e., running from source after local-build.sh)
|
|
26
|
+
const LOCAL_DIST_DIR = path.join(__dirname, "..", "dist");
|
|
27
|
+
const LOCAL_DEV_MODE = fs.existsSync(LOCAL_DIST_DIR) || process.env.KABLAN_LOCAL === "1";
|
|
28
|
+
|
|
29
|
+
function get(url, onResponse, reject) {
|
|
30
|
+
https
|
|
31
|
+
.get(url, { headers: { "User-Agent": `kablan-cli/${PKG_VERSION}` } }, (res) => {
|
|
32
|
+
if (res.statusCode === 301 || res.statusCode === 302) {
|
|
33
|
+
return get(res.headers.location, onResponse, reject);
|
|
34
|
+
}
|
|
35
|
+
onResponse(res);
|
|
36
|
+
})
|
|
37
|
+
.on("error", reject);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function fetchText(url) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
get(
|
|
43
|
+
url,
|
|
44
|
+
(res) => {
|
|
45
|
+
if (res.statusCode !== 200) {
|
|
46
|
+
return reject(new Error(`HTTP ${res.statusCode} fetching ${url}`));
|
|
47
|
+
}
|
|
48
|
+
let data = "";
|
|
49
|
+
res.on("data", (chunk) => (data += chunk));
|
|
50
|
+
res.on("end", () => resolve(data));
|
|
51
|
+
},
|
|
52
|
+
reject
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function fetchJson(url) {
|
|
58
|
+
return JSON.parse(await fetchText(url));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function downloadFile(url, destPath, expectedSha256, onProgress) {
|
|
62
|
+
const tempPath = destPath + ".tmp";
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const file = fs.createWriteStream(tempPath);
|
|
65
|
+
const hash = crypto.createHash("sha256");
|
|
66
|
+
|
|
67
|
+
const cleanup = () => {
|
|
68
|
+
try {
|
|
69
|
+
fs.unlinkSync(tempPath);
|
|
70
|
+
} catch {}
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
get(
|
|
74
|
+
url,
|
|
75
|
+
(res) => {
|
|
76
|
+
if (res.statusCode !== 200) {
|
|
77
|
+
file.close();
|
|
78
|
+
cleanup();
|
|
79
|
+
return reject(new Error(`HTTP ${res.statusCode} downloading ${url}`));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const totalSize = parseInt(res.headers["content-length"], 10);
|
|
83
|
+
let downloadedSize = 0;
|
|
84
|
+
|
|
85
|
+
res.on("data", (chunk) => {
|
|
86
|
+
downloadedSize += chunk.length;
|
|
87
|
+
hash.update(chunk);
|
|
88
|
+
if (onProgress) onProgress(downloadedSize, totalSize);
|
|
89
|
+
});
|
|
90
|
+
res.pipe(file);
|
|
91
|
+
|
|
92
|
+
file.on("finish", () => {
|
|
93
|
+
file.close();
|
|
94
|
+
const actualSha256 = hash.digest("hex");
|
|
95
|
+
if (expectedSha256 && actualSha256 !== expectedSha256) {
|
|
96
|
+
cleanup();
|
|
97
|
+
reject(
|
|
98
|
+
new Error(`Checksum mismatch: expected ${expectedSha256}, got ${actualSha256}`)
|
|
99
|
+
);
|
|
100
|
+
} else {
|
|
101
|
+
try {
|
|
102
|
+
fs.renameSync(tempPath, destPath);
|
|
103
|
+
resolve(destPath);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
cleanup();
|
|
106
|
+
reject(err);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
(err) => {
|
|
112
|
+
file.close();
|
|
113
|
+
cleanup();
|
|
114
|
+
reject(err);
|
|
115
|
+
}
|
|
116
|
+
);
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The release's checksum file, parsed into { filename: sha256 }. Fetched at most once per run.
|
|
122
|
+
* A release without one still installs — the download is over HTTPS from a pinned tag — but the
|
|
123
|
+
* extra check is cheap and catches a truncated or swapped asset.
|
|
124
|
+
*/
|
|
125
|
+
let checksumsPromise = null;
|
|
126
|
+
function getChecksums() {
|
|
127
|
+
if (!checksumsPromise) {
|
|
128
|
+
checksumsPromise = fetchText(`${RELEASE_BASE}/sha256sums.txt`)
|
|
129
|
+
.then((text) => {
|
|
130
|
+
const map = {};
|
|
131
|
+
for (const line of text.split("\n")) {
|
|
132
|
+
const m = line.trim().match(/^([0-9a-f]{64})\s+\*?(.+)$/i);
|
|
133
|
+
if (m) map[m[2]] = m[1].toLowerCase();
|
|
134
|
+
}
|
|
135
|
+
return map;
|
|
136
|
+
})
|
|
137
|
+
.catch(() => ({}));
|
|
138
|
+
}
|
|
139
|
+
return checksumsPromise;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function ensureBinary(platform, binaryName, onProgress) {
|
|
143
|
+
// In local dev mode, use binaries directly from npx-cli/dist/
|
|
144
|
+
if (LOCAL_DEV_MODE) {
|
|
145
|
+
const localZipPath = path.join(LOCAL_DIST_DIR, platform, `${binaryName}.zip`);
|
|
146
|
+
if (fs.existsSync(localZipPath)) {
|
|
147
|
+
return localZipPath;
|
|
148
|
+
}
|
|
149
|
+
throw new Error(
|
|
150
|
+
`Local binary not found: ${localZipPath}\n` +
|
|
151
|
+
`Run ./local-build.sh first to build the binaries.`
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const cacheDir = path.join(CACHE_DIR, BINARY_TAG, platform);
|
|
156
|
+
const zipPath = path.join(cacheDir, `${binaryName}.zip`);
|
|
157
|
+
|
|
158
|
+
if (fs.existsSync(zipPath)) return zipPath;
|
|
159
|
+
|
|
160
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
161
|
+
|
|
162
|
+
// Assets are flat on the release, so the platform is part of the name.
|
|
163
|
+
const assetName = `${binaryName}-${platform}.zip`;
|
|
164
|
+
const checksums = await getChecksums();
|
|
165
|
+
|
|
166
|
+
try {
|
|
167
|
+
await downloadFile(`${RELEASE_BASE}/${assetName}`, zipPath, checksums[assetName], onProgress);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
if (/HTTP 404/.test(err.message)) {
|
|
170
|
+
throw new Error(
|
|
171
|
+
`${binaryName} is not published for ${platform} in ${BINARY_TAG}.\n` +
|
|
172
|
+
`See https://github.com/${REPO}/releases/tag/${BINARY_TAG} for what that release contains.`
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
throw err;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
return zipPath;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
async function getLatestVersion() {
|
|
182
|
+
const release = await fetchJson(`https://api.github.com/repos/${REPO}/releases/latest`);
|
|
183
|
+
return String(release.tag_name || "").replace(/^v/, "");
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
module.exports = {
|
|
187
|
+
REPO,
|
|
188
|
+
RELEASE_BASE,
|
|
189
|
+
BINARY_TAG,
|
|
190
|
+
CACHE_DIR,
|
|
191
|
+
LOCAL_DEV_MODE,
|
|
192
|
+
LOCAL_DIST_DIR,
|
|
193
|
+
ensureBinary,
|
|
194
|
+
getLatestVersion,
|
|
195
|
+
};
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
const fs = require("fs");
|
|
2
|
+
const os = require("os");
|
|
3
|
+
const path = require("path");
|
|
4
|
+
const { execFileSync } = require("child_process");
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Installs Kablan into ~/Applications as a real macOS app.
|
|
8
|
+
*
|
|
9
|
+
* The point is a double-clickable icon that starts Kablan in the background — no Terminal
|
|
10
|
+
* window, no command to remember. It also happens to sidestep Gatekeeper: the bundle is
|
|
11
|
+
* assembled here, on this machine, from a binary this process downloaded, so nothing carries the
|
|
12
|
+
* quarantine flag that produces "unidentified developer". A downloaded .app would need
|
|
13
|
+
* notarisation and a paid Apple account; one built locally needs neither.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const APP_NAME = "Kablan";
|
|
17
|
+
const BUNDLE_ID = "dev.kablan.app";
|
|
18
|
+
|
|
19
|
+
function appDir() {
|
|
20
|
+
// ~/Applications rather than /Applications: it needs no password, and Spotlight and Launchpad
|
|
21
|
+
// both index it.
|
|
22
|
+
return path.join(os.homedir(), "Applications", `${APP_NAME}.app`);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* A GUI-launched process inherits a minimal PATH — roughly /usr/bin:/bin — so the coding agents,
|
|
27
|
+
* node and pnpm are all missing and every task fails at its first command. The launcher asks the
|
|
28
|
+
* login shell what PATH really is, the same thing a terminal would have.
|
|
29
|
+
*/
|
|
30
|
+
function launcherScript() {
|
|
31
|
+
return `#!/bin/bash
|
|
32
|
+
# Generated by \`npx kablan --install\`. Re-run that to update.
|
|
33
|
+
set -u
|
|
34
|
+
|
|
35
|
+
HERE="$(cd "$(dirname "$0")" && pwd)"
|
|
36
|
+
|
|
37
|
+
# Adopt the login shell's PATH; a GUI launch would otherwise see almost nothing.
|
|
38
|
+
SHELL_BIN="\${SHELL:-/bin/zsh}"
|
|
39
|
+
RESOLVED="$("$SHELL_BIN" -ilc 'printf %s "$PATH"' 2>/dev/null || true)"
|
|
40
|
+
if [ -n "$RESOLVED" ]; then
|
|
41
|
+
export PATH="$RESOLVED"
|
|
42
|
+
fi
|
|
43
|
+
for dir in /opt/homebrew/bin /usr/local/bin /usr/bin /bin; do
|
|
44
|
+
case ":$PATH:" in *":$dir:"*) ;; *) PATH="$dir:$PATH" ;; esac
|
|
45
|
+
done
|
|
46
|
+
export PATH
|
|
47
|
+
|
|
48
|
+
# There is no terminal attached, so keep output where it can be read after the fact.
|
|
49
|
+
LOG_DIR="$HOME/Library/Logs/${APP_NAME}"
|
|
50
|
+
mkdir -p "$LOG_DIR"
|
|
51
|
+
|
|
52
|
+
exec "$HERE/${APP_NAME.toLowerCase()}-server" >>"$LOG_DIR/${APP_NAME.toLowerCase()}.log" 2>&1
|
|
53
|
+
`;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function infoPlist(version) {
|
|
57
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
58
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
59
|
+
<plist version="1.0">
|
|
60
|
+
<dict>
|
|
61
|
+
<key>CFBundleName</key><string>${APP_NAME}</string>
|
|
62
|
+
<key>CFBundleDisplayName</key><string>${APP_NAME}</string>
|
|
63
|
+
<key>CFBundleIdentifier</key><string>${BUNDLE_ID}</string>
|
|
64
|
+
<key>CFBundleVersion</key><string>${version}</string>
|
|
65
|
+
<key>CFBundleShortVersionString</key><string>${version}</string>
|
|
66
|
+
<key>CFBundleExecutable</key><string>${APP_NAME}</string>
|
|
67
|
+
<key>CFBundleIconFile</key><string>${APP_NAME}</string>
|
|
68
|
+
<key>CFBundlePackageType</key><string>APPL</string>
|
|
69
|
+
<key>CFBundleInfoDictionaryVersion</key><string>6.0</string>
|
|
70
|
+
<key>NSHighResolutionCapable</key><true/>
|
|
71
|
+
<key>LSMinimumSystemVersion</key><string>11.0</string>
|
|
72
|
+
</dict>
|
|
73
|
+
</plist>
|
|
74
|
+
`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* @param binPath the extracted server binary to install
|
|
79
|
+
* @param version the wrapper's version, shown in Finder's Get Info
|
|
80
|
+
*/
|
|
81
|
+
function installMacApp(binPath, version) {
|
|
82
|
+
if (process.platform !== "darwin") {
|
|
83
|
+
throw new Error(
|
|
84
|
+
`--install builds a macOS app bundle and this is ${process.platform}.\n` +
|
|
85
|
+
`On Linux and Windows, run \`npx kablan\` directly.`
|
|
86
|
+
);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const app = appDir();
|
|
90
|
+
const macos = path.join(app, "Contents", "MacOS");
|
|
91
|
+
const resources = path.join(app, "Contents", "Resources");
|
|
92
|
+
|
|
93
|
+
// Replace wholesale rather than merging, so an older layout cannot leave stale files behind.
|
|
94
|
+
fs.rmSync(app, { recursive: true, force: true });
|
|
95
|
+
fs.mkdirSync(macos, { recursive: true });
|
|
96
|
+
fs.mkdirSync(resources, { recursive: true });
|
|
97
|
+
|
|
98
|
+
fs.writeFileSync(path.join(app, "Contents", "Info.plist"), infoPlist(version));
|
|
99
|
+
|
|
100
|
+
const serverName = `${APP_NAME.toLowerCase()}-server`;
|
|
101
|
+
fs.copyFileSync(binPath, path.join(macos, serverName));
|
|
102
|
+
fs.chmodSync(path.join(macos, serverName), 0o755);
|
|
103
|
+
|
|
104
|
+
const launcher = path.join(macos, APP_NAME);
|
|
105
|
+
fs.writeFileSync(launcher, launcherScript());
|
|
106
|
+
fs.chmodSync(launcher, 0o755);
|
|
107
|
+
|
|
108
|
+
const icon = path.join(__dirname, "..", "assets", `${APP_NAME}.icns`);
|
|
109
|
+
if (fs.existsSync(icon)) {
|
|
110
|
+
fs.copyFileSync(icon, path.join(resources, `${APP_NAME}.icns`));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Finder caches icons per bundle; without this the app can show a blank page icon until
|
|
114
|
+
// something else invalidates it.
|
|
115
|
+
try {
|
|
116
|
+
execFileSync("/usr/bin/touch", [app]);
|
|
117
|
+
execFileSync(
|
|
118
|
+
"/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister",
|
|
119
|
+
["-f", app],
|
|
120
|
+
{ stdio: "ignore" }
|
|
121
|
+
);
|
|
122
|
+
} catch {
|
|
123
|
+
// Cosmetic only — the app works either way.
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return app;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function uninstallMacApp() {
|
|
130
|
+
const app = appDir();
|
|
131
|
+
if (!fs.existsSync(app)) return null;
|
|
132
|
+
fs.rmSync(app, { recursive: true, force: true });
|
|
133
|
+
return app;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
module.exports = { installMacApp, uninstallMacApp, appDir, APP_NAME };
|
package/package.json
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "kablan",
|
|
3
|
+
"version": "0.5.0",
|
|
4
|
+
"description": "NPX wrapper around the Kablan server and its MCP task server",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"author": "Shaked Amar",
|
|
7
|
+
"homepage": "https://github.com/AmarShaked/kablan.dev",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/AmarShaked/kablan.dev.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/AmarShaked/kablan.dev/issues"
|
|
14
|
+
},
|
|
15
|
+
"keywords": [
|
|
16
|
+
"claude-code",
|
|
17
|
+
"codex",
|
|
18
|
+
"coding-agent",
|
|
19
|
+
"kanban",
|
|
20
|
+
"git-worktree",
|
|
21
|
+
"ai"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18"
|
|
25
|
+
},
|
|
26
|
+
"bin": {
|
|
27
|
+
"kablan": "bin/cli.js"
|
|
28
|
+
},
|
|
29
|
+
"files": [
|
|
30
|
+
"bin",
|
|
31
|
+
"assets"
|
|
32
|
+
],
|
|
33
|
+
"dependencies": {
|
|
34
|
+
"adm-zip": "^0.5.16"
|
|
35
|
+
},
|
|
36
|
+
"private": false
|
|
37
|
+
}
|