@dotlabshq/clave 0.0.0-stage → 0.1.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 CHANGED
@@ -1,3 +1,34 @@
1
- # Temporary Holding Version
1
+ # @dotlabshq/clave
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Connect a repository to [clave](https://clave.one), the plan and work tracker for people and AI agents, in one command.
4
+
5
+ ```bash
6
+ npx @dotlabshq/clave init
7
+ ```
8
+
9
+ It runs in your terminal, in the repository:
10
+
11
+ 1. **Signs you in** through the browser (no token to copy). The sign-in lasts for this run only and is not saved.
12
+ 2. **Picks the org** (one where you are owner or admin) and **the project**: it matches this repository's git remote, or creates a project.
13
+ 3. **Picks or creates the unit** (team) your agents work for.
14
+ 4. **Registers your agents** (default `coder`) and issues one token for each.
15
+ 5. **Adds them to Claude Code** for this project (`claude mcp add --scope local`: stored on this machine, not in the repository). Without the `claude` CLI it writes `.mcp.json` with variable names only and the tokens to a git-ignored `.env.clave`.
16
+ 6. **Adds a clave block** to `AGENTS.md` or `CLAUDE.md` (it asks first), so every agent session starts with `session_start`.
17
+
18
+ Tokens are never printed, so they never reach the chat or your AI agent's context.
19
+
20
+ ## Options
21
+
22
+ | Option | |
23
+ |---|---|
24
+ | `--org <slug>` | Org to use (default: ask) |
25
+ | `--project <key>` | Existing project key (default: match this repository) |
26
+ | `--agents <a,b>` | Agent names, e.g. `coder,reviewer` (default: ask) |
27
+ | `--yes` | Accept the defaults without asking |
28
+ | `--no-open` | Print the sign-in link instead of opening the browser |
29
+ | `--no-claude` | Write `.mcp.json` and `.env.clave` instead of using the `claude` CLI |
30
+ | `--url <site>` | clave site (default `https://clave.one`, or `CLAVE_URL`) |
31
+
32
+ Start Claude Code in the repository after `init`; servers added while a session is open load at the next session start.
33
+
34
+ Guide for AI agents: https://clave.one/getting-started-ai.md
package/bin/clave.mjs ADDED
@@ -0,0 +1,38 @@
1
+ #!/usr/bin/env node
2
+ // clave CLI: `clave init` bu repoyu clave'e bağlar (giriş, proje, agent'lar, MCP ayarı).
3
+ import { readFileSync } from "node:fs";
4
+ import { init } from "../src/init.mjs";
5
+
6
+ const args = process.argv.slice(2);
7
+ const flag = (name) => { const i = args.indexOf(`--${name}`); return i >= 0 ? args[i + 1] : undefined; };
8
+ const has = (name) => args.includes(`--${name}`);
9
+ const version = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
10
+
11
+ const help = `clave ${version}: connect this repository to clave (https://clave.one)
12
+
13
+ Usage: npx @dotlabshq/clave init [options]
14
+
15
+ Signs you in through the browser, picks the org and project, registers your
16
+ agents, issues their tokens and adds them to Claude Code for this project.
17
+ Tokens are never printed.
18
+
19
+ Options:
20
+ --org <slug> Org to use (default: ask)
21
+ --project <key> Existing project key (default: match this repository)
22
+ --agents <a,b> Agent names (default: ask; "coder")
23
+ --yes Accept the defaults without asking
24
+ --no-open Don't open the browser; print the sign-in link only
25
+ --no-claude Don't use the claude CLI; write .mcp.json and .env.clave
26
+ --url <site> clave site (default: https://clave.one)
27
+ -h, --help This help
28
+ -v, --version The version`;
29
+
30
+ if (has("help") || args.includes("-h") || args.length === 0) { console.log(help); process.exit(0); }
31
+ if (has("version") || args.includes("-v")) { console.log(version); process.exit(0); }
32
+ if (args[0] !== "init") { console.error(`Unknown command: ${args[0]}\n\n${help}`); process.exit(2); }
33
+
34
+ init({
35
+ site: flag("url") ?? process.env.CLAVE_URL ?? "https://clave.one",
36
+ org: flag("org"), project: flag("project"), agents: flag("agents"),
37
+ yes: has("yes"), open: !has("no-open"), claude: has("no-claude") ? "never" : "auto",
38
+ }).catch((e) => { console.error(`clave: ${e.message}`); process.exit(1); });
package/package.json CHANGED
@@ -1,6 +1,7 @@
1
- {
2
- "name": "@dotlabshq/clave",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
1
+ { "name": "@dotlabshq/clave", "version": "0.1.0", "type": "module",
2
+ "description": "Set up clave (clave.one) in a repository: sign in, project, agents and MCP config for Claude Code in one command.",
3
+ "license": "MIT", "homepage": "https://clave.one/docs/getting-started-ai",
4
+ "bin": { "clave": "bin/clave.mjs" },
5
+ "files": ["bin", "src", "README.md"],
6
+ "engines": { "node": ">=18" },
7
+ "scripts": { "test": "node --test test/*.test.mjs", "typecheck": "node --check bin/clave.mjs && for f in src/*.mjs; do node --check $f || exit 1; done" } }
@@ -0,0 +1,32 @@
1
+ // AGENTS.md / CLAUDE.md'ye clave bloğu: her oturum aynı başlasın. Varsa blok yenilenir.
2
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
3
+ import { join } from "node:path";
4
+
5
+ const START = "<!-- clave:start -->", END = "<!-- clave:end -->";
6
+
7
+ export function block({ project, servers, site }) {
8
+ return `${START}
9
+ ## Task management: clave
10
+
11
+ - clave project: ${project}
12
+ - MCP server${servers.length > 1 ? "s" : ""}: ${servers.map((s) => `\`${s}\``).join(", ")} (${site}/v1/mcp). Setup guide: ${site}/getting-started-ai.md
13
+ - Start every session with \`session_start { project: "${project}" }\`: read the brief, the rules (binding) and the gotchas.
14
+ - Before starting work, find or create its item in clave; claim it, report progress, finish with evidence.
15
+ - Ask before risky steps with \`request_approval\` (production, deletes, external publishing). Leave what the next session needs with \`remember\`.
16
+ ${END}
17
+ `;
18
+ }
19
+
20
+ /** Hangi dosya: CLAUDE.md varsa o, yoksa AGENTS.md (yoksa oluşturulur). */
21
+ export function targetFile(cwd) {
22
+ for (const f of ["AGENTS.md", "CLAUDE.md"]) if (existsSync(join(cwd, f))) return join(cwd, f);
23
+ return join(cwd, "AGENTS.md");
24
+ }
25
+
26
+ export function upsert(path, text) {
27
+ const old = existsSync(path) ? readFileSync(path, "utf8") : "";
28
+ const i = old.indexOf(START), j = old.indexOf(END);
29
+ const next = i >= 0 && j > i ? old.slice(0, i) + text.trimEnd() + old.slice(j + END.length) : `${old}${old && !old.endsWith("\n\n") ? (old.endsWith("\n") ? "\n" : "\n\n") : ""}${text}`;
30
+ writeFileSync(path, next);
31
+ return old ? "updated" : "created";
32
+ }
package/src/config.mjs ADDED
@@ -0,0 +1,44 @@
1
+ // MCP ayarını yazar; secret ekrana ve modele hiç gelmez.
2
+ // Claude Code varsa: `claude mcp add --scope local` (bu makinedeki proje ayarı, repoya girmez).
3
+ // Yoksa: .mcp.json yalnız değişken adlarıyla (commit edilebilir) + git'e girmeyen .env.clave (0600).
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync, writeFileSync, chmodSync, appendFileSync } from "node:fs";
6
+ import { join } from "node:path";
7
+
8
+ export const envName = (agent) => `CLAVE_TOKEN_${agent.toUpperCase().replace(/[^A-Z0-9]/g, "_")}`;
9
+ export const serverName = (agent) => `clave-${agent}`;
10
+
11
+ export function hasClaude(run = spawnSync) {
12
+ const r = run("claude", ["--version"], { stdio: "ignore" });
13
+ return !r.error && r.status === 0;
14
+ }
15
+
16
+ /** Claude Code'a ekler; aynı adlı eski kayıt varsa önce siler. */
17
+ export function addToClaude({ agent, mcpUrl, secret, cwd, run = spawnSync }) {
18
+ const name = serverName(agent);
19
+ run("claude", ["mcp", "remove", "--scope", "local", name], { cwd, stdio: "ignore" });
20
+ const r = run("claude", ["mcp", "add", "--scope", "local", "--transport", "http", name, mcpUrl, "--header", `Authorization: Bearer ${secret}`], { cwd, stdio: "ignore" });
21
+ if (r.error || r.status !== 0) throw new Error(`claude mcp add ${name} failed`);
22
+ return name;
23
+ }
24
+
25
+ /** Claude olmadan: .mcp.json (değişken adı) ve .env.clave (secret), .gitignore'a .env.clave. */
26
+ export function writeFiles({ agents, mcpUrl, cwd }) {
27
+ const mcpPath = join(cwd, ".mcp.json");
28
+ const cfg = existsSync(mcpPath) ? JSON.parse(readFileSync(mcpPath, "utf8")) : {};
29
+ cfg.mcpServers ??= {};
30
+ for (const { agent } of agents) {
31
+ cfg.mcpServers[serverName(agent)] = { type: "http", url: mcpUrl, headers: { Authorization: `Bearer \${${envName(agent)}}` } };
32
+ }
33
+ writeFileSync(mcpPath, JSON.stringify(cfg, null, 2) + "\n");
34
+
35
+ const envPath = join(cwd, ".env.clave");
36
+ const old = existsSync(envPath) ? readFileSync(envPath, "utf8").split("\n").filter((l) => l && !agents.some((a) => l.startsWith(`${envName(a.agent)}=`))) : [];
37
+ writeFileSync(envPath, [...old, ...agents.map((a) => `${envName(a.agent)}=${a.secret}`)].join("\n") + "\n");
38
+ chmodSync(envPath, 0o600);
39
+
40
+ const gi = join(cwd, ".gitignore");
41
+ const lines = existsSync(gi) ? readFileSync(gi, "utf8").split("\n") : [];
42
+ if (!lines.includes(".env.clave")) appendFileSync(gi, `${lines.length && lines[lines.length - 1] !== "" ? "\n" : ""}.env.clave\n`);
43
+ return { mcpPath, envPath };
44
+ }
package/src/http.mjs ADDED
@@ -0,0 +1,23 @@
1
+ // clave'in REST'i: site adresi + /v1/mcp (Flect önek siler; yerelde web aynı yolu API'ye geçirir).
2
+ // Kimlik cihaz akışından gelen kişi token'ı; org'a bağlı çağrılar X-Org-Id ile gider.
3
+
4
+ export class ApiError extends Error {
5
+ constructor(status, code, message) { super(message || code); this.status = status; this.code = code; }
6
+ }
7
+
8
+ export function urls(site) {
9
+ const base = site.replace(/\/+$/, "");
10
+ return { site: base, api: `${base}/v1/mcp`, auth: `${base}/v1/auth`, mcp: `${base}/v1/mcp` };
11
+ }
12
+
13
+ export function client({ api, token, org = null, fetchImpl = fetch }) {
14
+ return async function call(method, path, body) {
15
+ const headers = { authorization: `Bearer ${token}`, accept: "application/json" };
16
+ if (org) headers["x-org-id"] = org;
17
+ if (body !== undefined) headers["content-type"] = "application/json";
18
+ const r = await fetchImpl(`${api}${path}`, { method, headers, ...(body !== undefined ? { body: JSON.stringify(body) } : {}) });
19
+ const data = await r.json().catch(() => ({}));
20
+ if (!r.ok) throw new ApiError(r.status, data.error ?? `http_${r.status}`, data.message ?? data.error);
21
+ return data;
22
+ };
23
+ }
package/src/init.mjs ADDED
@@ -0,0 +1,118 @@
1
+ // `clave init`: bu repo için clave kurulumu, kişinin kendi kimliğiyle (cihaz girişi):
2
+ // org → proje → birim → agent'lar ve token'ları → MCP ayarı → AGENTS.md. Token'lar ekrana basılmaz.
3
+ import { spawnSync } from "node:child_process";
4
+ import { hostname } from "node:os";
5
+ import { basename } from "node:path";
6
+ import { client, urls } from "./http.mjs";
7
+ import { deviceLogin, openBrowser } from "./login.mjs";
8
+ import { prompter } from "./prompt.mjs";
9
+ import { addToClaude, hasClaude, serverName, writeFiles, envName } from "./config.mjs";
10
+ import { block, targetFile, upsert } from "./agentsmd.mjs";
11
+
12
+ const NAME = /^[a-z0-9][a-z0-9-]{1,31}$/;
13
+
14
+ /** git remote → "github.com/owner/repo"; remote yoksa klasör adı. */
15
+ export function repoOf(cwd, run = spawnSync) {
16
+ const r = run("git", ["config", "--get", "remote.origin.url"], { cwd, encoding: "utf8" });
17
+ const raw = !r.error && r.status === 0 ? String(r.stdout).trim() : "";
18
+ const m = raw.match(/^(?:https?:\/\/|ssh:\/\/)?(?:[^@/]+@)?([^/:]+)[/:](.+?)(?:\.git)?\/?$/);
19
+ return { remote: m ? `${m[1]}/${m[2]}` : "", name: m ? basename(m[2]) : basename(cwd) };
20
+ }
21
+
22
+ export function matchProject(projects, repo) {
23
+ const live = projects.filter((p) => !p.archived);
24
+ const norm = (s) => String(s ?? "").toLowerCase().replace(/\.git$/, "").replace(/\/+$/, "");
25
+ return live.find((p) => repo.remote && p.repo && (norm(repo.remote).endsWith(norm(p.repo)) || norm(p.repo).endsWith(norm(repo.remote))))
26
+ ?? live.find((p) => p.key === repo.name.toLowerCase() || norm(p.name) === norm(repo.name)) ?? null;
27
+ }
28
+
29
+ export async function init(opts) {
30
+ const { site = "https://clave.one", cwd = process.cwd(), yes = false, open = true, claude: useClaude = "auto",
31
+ log = (s) => console.log(s), fetchImpl = fetch, run = spawnSync, loginInterval } = opts;
32
+ const u = urls(site);
33
+ const ask = opts.prompter ?? prompter({ yes });
34
+ try {
35
+ // 1. Giriş
36
+ const token = opts.token ?? await deviceLogin({ auth: u.auth, log, open: open ? openBrowser : null, fetchImpl, ...(loginInterval ? { interval: loginInterval } : {}) });
37
+ const me = client({ api: u.api, token, fetchImpl });
38
+
39
+ // 2. Org
40
+ const { orgs } = await me("GET", "/orgs");
41
+ const editable = orgs.filter((o) => o.role === "owner" || o.role === "admin");
42
+ if (editable.length === 0) throw new Error(orgs.length ? "You are not an owner or admin of any org; ask one to run this." : `You have no org yet. Create one at ${u.site}/start, then run this again.`);
43
+ const pick = opts.org ? editable.find((o) => o.slug === opts.org || o.id === opts.org) : null;
44
+ if (opts.org && !pick) throw new Error(`No org "${opts.org}" where you are owner or admin.`);
45
+ const org = pick ?? await ask.choose("Org", editable.map((o) => ({ label: `${o.name} (${o.slug})`, ...o })));
46
+ const call = client({ api: u.api, token, org: org.slug, fetchImpl });
47
+ log(`Org: ${org.name}`);
48
+
49
+ // 3. Proje
50
+ const setup = await call("GET", "/setup");
51
+ const repo = repoOf(cwd, run);
52
+ let project = opts.project ? setup.projects.find((p) => p.key === opts.project) : matchProject(setup.projects, repo);
53
+ if (opts.project && !project) throw new Error(`No project "${opts.project}" in ${org.name}.`);
54
+ if (project && !opts.project && !(await ask.confirm(`Use project "${project.name}" (${project.key})?`, true))) project = null;
55
+ if (!project) {
56
+ const name = await ask.ask("New project name", repo.name);
57
+ const lines = setup.lines.filter((l) => !l.archived);
58
+ const { key } = await call("POST", "/setup/projects", { name, repo: repo.remote, line: lines[0]?.key ?? "" });
59
+ project = { key, name };
60
+ log(`Project created: ${name} (${key})`);
61
+ } else log(`Project: ${project.name} (${project.key})`);
62
+
63
+ // 4. Birim
64
+ const units = setup.units.filter((x) => !x.archived);
65
+ let unit;
66
+ if (units.length) {
67
+ const pickU = await ask.choose("Unit the agents work for", units.map((x) => ({ label: x.name, ...x })));
68
+ unit = pickU.key;
69
+ log(`Unit: ${pickU.name}`);
70
+ }
71
+ else {
72
+ const name = await ask.ask("No units yet. Name the team that owns the work", "Core team");
73
+ unit = (await call("POST", "/setup/units", { name })).key;
74
+ log(`Unit created: ${name}`);
75
+ }
76
+ if (!setup.lines.some((l) => !l.archived) || !setup.goals.some((g) => !g.archived)) {
77
+ log(`Tip: the plan has no business line or goal yet. Finish it at ${u.site}/app/welcome`);
78
+ }
79
+
80
+ // 5. Agent'lar ve token'lar
81
+ const wanted = (opts.agents ?? await ask.ask("Agents to set up (comma-separated)", "coder")).split(",").map((s) => s.trim().toLowerCase()).filter(Boolean);
82
+ const bad = wanted.find((n) => !NAME.test(n));
83
+ if (bad) throw new Error(`"${bad}" is not a valid agent name: 2–32 lowercase letters, digits, dashes.`);
84
+ const { agents: existing } = await call("GET", "/agents");
85
+ const issued = [];
86
+ for (const agent of wanted) {
87
+ if (!existing.some((a) => a.name === agent)) { await call("POST", "/agents", { name: agent, unit }); log(`Agent registered: ${agent}`); }
88
+ const { secret } = await call("POST", `/agents/${encodeURIComponent(agent)}/tokens`, { label: hostname().slice(0, 30) });
89
+ issued.push({ agent, secret });
90
+ }
91
+
92
+ // 6. MCP ayarı
93
+ const claudeOk = useClaude === "never" ? false : useClaude === "always" ? true : hasClaude(run);
94
+ let servers;
95
+ if (claudeOk) {
96
+ servers = issued.map(({ agent, secret }) => addToClaude({ agent, mcpUrl: u.mcp, secret, cwd, run }));
97
+ log(`Claude Code: added ${servers.join(", ")} for this project (local scope; not in the repository).`);
98
+ } else {
99
+ const { mcpPath, envPath } = writeFiles({ agents: issued, mcpUrl: u.mcp, cwd });
100
+ servers = issued.map(({ agent }) => serverName(agent));
101
+ log(`Wrote ${basename(mcpPath)} (variable names only) and ${basename(envPath)} (tokens, git-ignored). Load it before starting your client: set -a; . ./.env.clave; set +a`);
102
+ log(`Codex: add [mcp_servers.${servers[0]}] url = "${u.mcp}", bearer_token_env_var = "${envName(issued[0].agent)}" to ~/.codex/config.toml`);
103
+ }
104
+ issued.forEach((x) => { x.secret = ""; });
105
+
106
+ // 7. AGENTS.md
107
+ const file = targetFile(cwd);
108
+ if (await ask.confirm(`Add the clave block to ${basename(file)}?`, true)) {
109
+ const how = upsert(file, block({ project: project.key, servers, site: u.site }));
110
+ log(`${basename(file)} ${how}.`);
111
+ }
112
+
113
+ log(`\nDone. Start your agent in this repository; its first call is session_start { project: "${project.key}" }.`);
114
+ return { org: org.slug, project: project.key, unit, agents: wanted, servers, claude: claudeOk };
115
+ } finally {
116
+ ask.close?.();
117
+ }
118
+ }
package/src/login.mjs ADDED
@@ -0,0 +1,25 @@
1
+ // Tarayıcıyla giriş: auth-service'in CLI cihaz akışı. /start bir onay adresi verir, kişi tarayıcıda clave'e
2
+ // girip onaylar, /poll kişi token'ını döner (24 saat). Token yalnız bellekte tutulur, diske yazılmaz.
3
+ import { spawn } from "node:child_process";
4
+
5
+ export function openBrowser(url) {
6
+ const cmd = process.platform === "darwin" ? "open" : process.platform === "win32" ? "cmd" : "xdg-open";
7
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
8
+ try { spawn(cmd, args, { stdio: "ignore", detached: true }).unref(); return true; } catch { return false; }
9
+ }
10
+
11
+ export async function deviceLogin({ auth, log, open = openBrowser, fetchImpl = fetch, interval = 2000, timeout = 600_000 }) {
12
+ const r = await fetchImpl(`${auth}/start`);
13
+ if (!r.ok) throw new Error(`Sign-in could not start (HTTP ${r.status})`);
14
+ const { state, url } = await r.json();
15
+ log(`Sign in to clave in your browser:\n ${url}`);
16
+ if (open) open(url);
17
+ const until = Date.now() + timeout;
18
+ while (Date.now() < until) {
19
+ await new Promise((res) => setTimeout(res, interval));
20
+ const p = await fetchImpl(`${auth}/poll/${encodeURIComponent(state)}`).then((x) => x.json()).catch(() => ({ status: "pending" }));
21
+ if (p.status === "done" && p.token) return p.token;
22
+ if (p.status === "expired") throw new Error("The sign-in link expired. Run the command again.");
23
+ }
24
+ throw new Error("Timed out waiting for the sign-in.");
25
+ }
package/src/prompt.mjs ADDED
@@ -0,0 +1,31 @@
1
+ // Soru-cevap: --yes ile varsayılanlar, yoksa terminalden.
2
+ import { createInterface } from "node:readline/promises";
3
+ import { stdin, stdout } from "node:process";
4
+
5
+ export function prompter({ yes = false, input = stdin, output = stdout } = {}) {
6
+ let rl = null;
7
+ const line = async (q) => {
8
+ rl ??= createInterface({ input, output });
9
+ return (await rl.question(q)).trim();
10
+ };
11
+ return {
12
+ async ask(q, def = "") {
13
+ if (yes) return def;
14
+ const a = await line(def ? `${q} [${def}]: ` : `${q}: `);
15
+ return a || def;
16
+ },
17
+ async confirm(q, def = true) {
18
+ if (yes) return def;
19
+ const a = (await line(`${q} [${def ? "Y/n" : "y/N"}]: `)).toLowerCase();
20
+ return a ? a.startsWith("y") : def;
21
+ },
22
+ async choose(q, options, defIndex = 0) {
23
+ if (yes || options.length === 1) return options[defIndex];
24
+ options.forEach((o, i) => output.write(` ${i + 1}) ${o.label}\n`));
25
+ const a = await line(`${q} [${defIndex + 1}]: `);
26
+ const n = Number(a || defIndex + 1);
27
+ return options[Number.isInteger(n) && n >= 1 && n <= options.length ? n - 1 : defIndex];
28
+ },
29
+ close() { rl?.close(); },
30
+ };
31
+ }