@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 +33 -2
- package/bin/clave.mjs +38 -0
- package/package.json +7 -6
- package/src/agentsmd.mjs +32 -0
- package/src/config.mjs +44 -0
- package/src/http.mjs +23 -0
- package/src/init.mjs +118 -0
- package/src/login.mjs +25 -0
- package/src/prompt.mjs +31 -0
package/README.md
CHANGED
|
@@ -1,3 +1,34 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @dotlabshq/clave
|
|
2
2
|
|
|
3
|
-
|
|
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
|
-
"
|
|
3
|
-
"
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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" } }
|
package/src/agentsmd.mjs
ADDED
|
@@ -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
|
+
}
|