scribeclock 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.
@@ -0,0 +1,190 @@
1
+ import { usageError } from "../cli/errors.js";
2
+ import { changedFiles, currentBranch, detectTaskKey, gitState, lastCommit, repoRef } from "../git.js";
3
+ import { EXIT } from "../types.js";
4
+ import { fmtDate, relTime } from "../util/format.js";
5
+ import { cmd, emit, list, num, personName, str, taskKey } from "./_shared.js";
6
+ const REPO_FLAG = { name: "repo", alias: "r", type: "string", placeholder: "<remote|owner/repo>", description: "Repository (default: this folder's git origin, else its name)." };
7
+ const SESSION_FLAG = { name: "session", type: "string", description: "Agent/terminal session key (default: SCRIBECLOCK_SESSION or \"cli\")." };
8
+ function repoOf(ctx, inv) {
9
+ const repo = str(inv.flags.repo) ?? repoRef(ctx.cwd);
10
+ if (!repo)
11
+ throw usageError("Not in a git repository.", "Pass --repo owner/repo.");
12
+ return repo;
13
+ }
14
+ function sessionKey(ctx, inv) {
15
+ return str(inv.flags.session) ?? ctx.env.SCRIBECLOCK_SESSION ?? "cli";
16
+ }
17
+ export const teamCommand = cmd({
18
+ path: "team",
19
+ group: "Team",
20
+ summary: "Who is active in this repository right now: branch, files, task, git state, contested files, handoffs.",
21
+ tool: "get_repo_team",
22
+ flags: [REPO_FLAG, { name: "stale", type: "number", placeholder: "<minutes>", description: "Sessions quiet for longer count as stale (default 15)." }],
23
+ async run(ctx, inv) {
24
+ const repo = repoOf(ctx, inv);
25
+ const data = await ctx.call("get_repo_team", { repo, stale_after_minutes: num(inv.flags.stale) });
26
+ return emit(ctx, data, () => {
27
+ const active = (data.active ?? data.sessions ?? []);
28
+ const stale = (data.stale ?? []);
29
+ ctx.out.log(ctx.out.bold(`${repo} — ${active.length} active session${active.length === 1 ? "" : "s"}${stale.length ? ctx.out.dim(`, ${stale.length} stale`) : ""}`));
30
+ if (active.length) {
31
+ ctx.out.table(["who", "branch", "files", "task", "summary", "git", "seen"], active.map((s) => {
32
+ const g = (s.git ?? {});
33
+ const risks = [Number(g.unpushed_commits ?? g.ahead ?? 0) ? `${String(g.unpushed_commits ?? g.ahead)} unpushed` : "", Number(g.uncommitted_count ?? g.uncommitted_files?.length ?? 0) ? "uncommitted" : "", g.conflicts?.length ? ctx.out.red("conflicts") : ""].filter(Boolean).join(", ");
34
+ return [personName(s.person) + (s.is_me ? ctx.out.dim(" (me)") : "") + (s.client_name ? ctx.out.dim(` · ${String(s.client_name)}`) : ""), s.branch, (s.files ?? []).slice(0, 4).join(", ") + ((s.files ?? []).length > 4 ? ` +${(s.files ?? []).length - 4}` : ""), s.task?.key ?? "", s.summary, risks, relTime(String(s.last_heartbeat_at ?? s.last_seen_at ?? ""), ctx.now())];
35
+ }));
36
+ }
37
+ const contested = (data.contested_files ?? []);
38
+ if (contested.length) {
39
+ ctx.out.log("");
40
+ ctx.out.log(ctx.out.yellow(ctx.out.bold("Contested files")));
41
+ for (const c of contested)
42
+ ctx.out.log(` ${String(c.path ?? c.file)} ${ctx.out.dim((c.people ?? c.sessions ?? []).map(personName).join(", "))}`);
43
+ }
44
+ const handoffs = (data.recent_handoffs ?? data.handoffs ?? []);
45
+ if (handoffs.length) {
46
+ ctx.out.log("");
47
+ ctx.out.log(ctx.out.bold("Recent handoffs"));
48
+ for (const h of handoffs.slice(0, 5))
49
+ ctx.out.log(` ${fmtDate(String(h.created_at ?? ""), { time: true })} ${personName(h.person)}${h.task && h.task.key ? ` ${String(h.task.key)}` : ""}: ${String(h.summary ?? "").slice(0, 100)}`);
50
+ ctx.out.info(ctx.out.dim("Read one in full: scribeclock handoff"));
51
+ }
52
+ for (const w of (data.warnings ?? []))
53
+ ctx.out.warn(typeof w === "string" ? w : JSON.stringify(w));
54
+ });
55
+ },
56
+ });
57
+ export const overlapCommand = cmd({
58
+ path: "overlap",
59
+ group: "Team",
60
+ summary: "Before editing: is a teammate already on these files? Exit 4 when there is an overlap.",
61
+ tool: "check_repo_overlap",
62
+ positionals: [{ name: "files", variadic: true, description: "Paths you are about to touch (default: files with uncommitted changes)." }],
63
+ flags: [REPO_FLAG, SESSION_FLAG, { name: "no-dirs", type: "boolean", description: "Only report same-file overlaps, not same-folder ones." }, { name: "stale", type: "number", description: "Stale threshold in minutes." }],
64
+ examples: ["scribeclock overlap src/app.ts src/lib/", "scribeclock overlap # uses your uncommitted files", "scribeclock overlap --json | jq .overlaps"],
65
+ async run(ctx, inv) {
66
+ const repo = repoOf(ctx, inv);
67
+ const files = inv.positionals.length ? inv.positionals : changedFiles(ctx.cwd);
68
+ if (!files.length)
69
+ throw usageError("No files to check.", "Pass paths, or make some changes first.");
70
+ const data = await ctx.call("check_repo_overlap", { repo, files, session_key: sessionKey(ctx, inv), include_directories: inv.flags["no-dirs"] ? false : undefined, stale_after_minutes: num(inv.flags.stale) });
71
+ const overlaps = (data.overlaps ?? []);
72
+ const clear = data.clear === true || overlaps.length === 0;
73
+ if (ctx.flags.json)
74
+ ctx.out.jsonOut(data);
75
+ else if (clear)
76
+ ctx.out.log(`${ctx.out.green("✓")} Nobody else is on ${files.length} file${files.length === 1 ? "" : "s"} in ${repo}.`);
77
+ else {
78
+ ctx.out.log(ctx.out.yellow(ctx.out.bold(`${overlaps.length} overlap${overlaps.length === 1 ? "" : "s"} in ${repo}`)));
79
+ for (const o of overlaps) {
80
+ const g = o.unpushed_files;
81
+ ctx.out.log(` ${ctx.out.bold(personName(o.person))}${o.is_my_other_session ? ctx.out.dim(" (your other session)") : ""} · ${String(o.severity ?? "")} · ${String(o.branch ?? "")} · since ${relTime(String(o.since ?? o.started_at ?? ""), ctx.now())}`);
82
+ ctx.out.log(` ${(o.files ?? o.matching_files ?? []).join(", ")}${o.summary ? ctx.out.dim(` — ${String(o.summary)}`) : ""}`);
83
+ if (g?.length)
84
+ ctx.out.log(` ${ctx.out.red(`unpushed changes on: ${g.join(", ")}`)}`);
85
+ }
86
+ for (const w of (data.warnings ?? []))
87
+ ctx.out.warn(typeof w === "string" ? w : JSON.stringify(w));
88
+ }
89
+ return clear ? EXIT.OK : EXIT.CONFLICT;
90
+ },
91
+ });
92
+ export const teamReport = cmd({
93
+ path: "team report",
94
+ group: "Team",
95
+ summary: "Heartbeat: tell teammates what you are working on (branch, files, summary, task, git state).",
96
+ tool: "report_repo_activity",
97
+ positionals: [{ name: "summary", description: "One line on what you are doing." }],
98
+ flags: [REPO_FLAG, SESSION_FLAG, { name: "files", alias: "f", type: "array", description: "Files/folders you touch (default: uncommitted changes)." }, { name: "task", alias: "t", type: "string", description: "Task key (default: from branch/commit)." }],
99
+ async run(ctx, inv) {
100
+ const repo = repoOf(ctx, inv);
101
+ const task = str(inv.flags.task) ? taskKey(ctx, String(inv.flags.task)) : detectTaskKey(ctx.cwd)?.key;
102
+ const data = await ctx.call("report_repo_activity", { repo, branch: currentBranch(ctx.cwd) ?? undefined, files: list(inv.flags.files) ?? changedFiles(ctx.cwd), summary: inv.positionals.join(" ") || undefined, task_id: task, git: gitState(ctx.cwd), session_key: sessionKey(ctx, inv), client_name: "ScribeClock CLI" });
103
+ const overlaps = (data.overlaps ?? []);
104
+ return emit(ctx, data, () => {
105
+ ctx.out.log(`${ctx.out.green("✓")} Presence recorded for ${repo}${task ? ` · ${task}` : ""}.`);
106
+ for (const o of overlaps)
107
+ ctx.out.warn(`${personName(o.person)} is on ${(o.files ?? []).join(", ")} (${String(o.severity ?? "")}).`);
108
+ for (const w of (data.warnings ?? data.git_risk ?? []))
109
+ ctx.out.warn(typeof w === "string" ? w : JSON.stringify(w));
110
+ });
111
+ },
112
+ });
113
+ export const handoffCommand = cmd({
114
+ path: "handoff",
115
+ group: "Team",
116
+ summary: "Read the latest handoff for this repo / a task / a person, as a resume brief.",
117
+ tool: "get_handoff",
118
+ flags: [REPO_FLAG, { name: "task", alias: "t", type: "string", description: "Only handoffs for this task." }, { name: "person", type: "string", description: "me, a name, an email or uuid." }, { name: "limit", alias: "n", type: "number", description: "How many (default 5)." }],
119
+ examples: ["scribeclock handoff", "scribeclock handoff --task #731", "scribeclock handoff --person me --limit 10"],
120
+ async run(ctx, inv) {
121
+ const task = str(inv.flags.task) ? taskKey(ctx, String(inv.flags.task)) : undefined;
122
+ const repo = task ? str(inv.flags.repo) ?? repoRef(ctx.cwd) ?? undefined : repoOf(ctx, inv);
123
+ const data = await ctx.call("get_handoff", { repo, task_id: task, person: str(inv.flags.person), limit: num(inv.flags.limit) });
124
+ return emit(ctx, data, () => {
125
+ const latest = data.latest;
126
+ if (!latest) {
127
+ ctx.out.log(ctx.out.dim("No handoff notes yet."));
128
+ return;
129
+ }
130
+ ctx.out.log(ctx.out.bold(`Handoff from ${personName(latest.person)} · ${fmtDate(String(latest.created_at ?? ""), { time: true })}${latest.task && latest.task.key ? ` · ${String(latest.task.key)}` : ""}`));
131
+ ctx.out.log("");
132
+ ctx.out.log(String(data.resume_brief ?? latest.summary ?? ""));
133
+ for (const w of (data.warnings ?? []))
134
+ ctx.out.warn(typeof w === "string" ? w : JSON.stringify(w));
135
+ const others = (data.handoffs ?? []).filter((h) => h.id !== latest.id);
136
+ if (others.length) {
137
+ ctx.out.log("");
138
+ ctx.out.log(ctx.out.dim(`${others.length} earlier: ${others.map((h) => `${fmtDate(String(h.created_at ?? ""))} ${personName(h.person)}`).join("; ")}`));
139
+ }
140
+ });
141
+ },
142
+ });
143
+ export const handoffRecord = cmd({
144
+ path: "handoff record",
145
+ group: "Team",
146
+ summary: 'Leave a handoff note (what is done, unfinished, next steps) and end your presence: handoff record "summary".',
147
+ tool: "record_handoff",
148
+ positionals: [{ name: "summary", required: true, description: "What was done, in plain words (≥ 10 chars)." }],
149
+ flags: [
150
+ REPO_FLAG,
151
+ SESSION_FLAG,
152
+ { name: "unfinished", alias: "u", type: "array", description: "What is not done yet." },
153
+ { name: "next", type: "array", description: "Next steps, in order." },
154
+ { name: "blocker", alias: "b", type: "array", description: "Blockers." },
155
+ { name: "task", alias: "t", type: "string", description: "Linked task (default: from branch/commit)." },
156
+ { name: "reason", type: "string", choices: ["session_end", "clock_out", "checkpoint", "blocked", "progress", "completed"], description: "Why (default session_end)." },
157
+ { name: "keep-session", type: "boolean", description: "Keep showing this session as active." },
158
+ ],
159
+ examples: ['scribeclock handoff record "Vendor picker wired up; tests green" -u "mobile layout" --next "QA on iPhone" --next "ship"'],
160
+ async run(ctx, inv) {
161
+ const repo = repoOf(ctx, inv);
162
+ const summary = inv.positionals.join(" ");
163
+ if (summary.length < 10)
164
+ throw usageError("Write a summary of at least 10 characters.");
165
+ const commit = lastCommit(ctx.cwd);
166
+ const task = str(inv.flags.task) ? taskKey(ctx, String(inv.flags.task)) : detectTaskKey(ctx.cwd)?.key;
167
+ const data = await ctx.call("record_handoff", {
168
+ repo,
169
+ summary,
170
+ unfinished: list(inv.flags.unfinished),
171
+ next_steps: list(inv.flags.next),
172
+ blockers: list(inv.flags.blocker),
173
+ files_changed: changedFiles(ctx.cwd),
174
+ branch: currentBranch(ctx.cwd) ?? undefined,
175
+ last_commit: commit ? { sha: commit.sha, message: commit.message.slice(0, 300) } : undefined,
176
+ git: gitState(ctx.cwd),
177
+ task_id: task,
178
+ reason: str(inv.flags.reason),
179
+ end_session: inv.flags["keep-session"] ? false : undefined,
180
+ session_key: sessionKey(ctx, inv),
181
+ client_name: "ScribeClock CLI",
182
+ });
183
+ return emit(ctx, data, () => {
184
+ ctx.out.log(`${ctx.out.green("✓")} Handoff recorded for ${repo}${task ? ` · ${task}` : ""}.`);
185
+ for (const w of (data.warnings ?? []))
186
+ ctx.out.warn(typeof w === "string" ? w : JSON.stringify(w));
187
+ });
188
+ },
189
+ });
190
+ export const TEAM_COMMANDS = [teamCommand, overlapCommand, teamReport, handoffCommand, handoffRecord];
@@ -0,0 +1,257 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { CliError, usageError } from "../cli/errors.js";
3
+ import { closest } from "../cli/parser.js";
4
+ import { confirm } from "../cli/confirm.js";
5
+ import { maskSecrets } from "../cli/output.js";
6
+ import { maskArgs, sensitivity } from "../cli/policy.js";
7
+ import { firstSentence, kebab, parseToolArgs, requireArgs, schemaFlags, snake } from "../mcp/schema.js";
8
+ import { describeContent, renderResult } from "../render.js";
9
+ import { EXIT } from "../types.js";
10
+ import { toolError } from "../context.js";
11
+ /** `scribeclock <tool>` / `scribeclock call <tool>`: argument parsing from the schema, policy, output. */
12
+ export async function runTool(ctx, tool, tokens) {
13
+ const args = parseToolArgs(tokens, tool.inputSchema, {
14
+ readFile: (p) => readFileSync(p, "utf8"),
15
+ readStdin: () => readFileSync(0, "utf8"),
16
+ });
17
+ const org = ctx.org();
18
+ if (org && tool.inputSchema?.properties?.organization_id && args.organization_id === undefined)
19
+ args.organization_id = org;
20
+ requireArgs(args, tool.inputSchema, tool.name);
21
+ const result = await executeGuarded(ctx, tool.name, args, tool);
22
+ if (result.status === "cancelled")
23
+ return EXIT.ERROR;
24
+ if (result.status === "dry_run")
25
+ return EXIT.OK;
26
+ const policy = sensitivity(tool.name, tool, args);
27
+ emit(ctx, tool, result.data, result.content);
28
+ if (policy.maskOutput && !ctx.flags.reveal && !ctx.flags.json)
29
+ ctx.out.info(ctx.out.dim("Values are masked; add --reveal to print them."));
30
+ return EXIT.OK;
31
+ }
32
+ /**
33
+ * Applies the destructive/sensitive policy around one tool call: --dry-run prints the call (or asks
34
+ * the server to describe it), confirmation is required for sensitive tools (refused without a TTY
35
+ * unless --yes), and secret values in the result are masked unless --reveal.
36
+ */
37
+ export async function executeGuarded(ctx, toolName, args, def) {
38
+ const tool = def ?? (await ctx.tools().catch(() => [])).find((t) => t.name === toolName);
39
+ const policy = sensitivity(toolName, tool, args);
40
+ const shown = policy.maskArgs ? maskArgs(args) : args;
41
+ if (ctx.flags.dryRun) {
42
+ if (policy.serverDryRun) {
43
+ const preview = await ctx.call(toolName, { ...args, [policy.serverDryRun.flag]: false });
44
+ if (ctx.flags.json)
45
+ ctx.out.jsonOut({ ...preview, dry_run: true });
46
+ else {
47
+ renderResult(ctx.out, preview);
48
+ ctx.out.info(ctx.out.dim("Dry run: the server described the change; nothing was deleted."));
49
+ }
50
+ return { status: "dry_run" };
51
+ }
52
+ if (ctx.flags.json)
53
+ ctx.out.jsonOut({ ok: true, dry_run: true, tool: toolName, arguments: shown });
54
+ else
55
+ ctx.out.log(`${ctx.out.dim("dry run →")} ${toolName} ${JSON.stringify(shown)}`);
56
+ return { status: "dry_run" };
57
+ }
58
+ if (policy.confirm) {
59
+ const ok = await confirm(ctx, `${toolName} ${policy.reason ? `${policy.reason}: ` : ""}${JSON.stringify(shown)}. Continue?`, { danger: true });
60
+ if (!ok) {
61
+ ctx.out.info("Cancelled.");
62
+ return { status: "cancelled" };
63
+ }
64
+ if (policy.serverDryRun)
65
+ args[policy.serverDryRun.flag] = true;
66
+ }
67
+ const outcome = await ctx.callRaw(toolName, args);
68
+ if (!outcome.ok)
69
+ throw toolError(toolName, outcome);
70
+ const data = policy.maskOutput ? maskSecrets(outcome.data, ctx.flags.reveal) : outcome.data;
71
+ return { status: "done", data, content: outcome.content };
72
+ }
73
+ function emit(ctx, _tool, data, content) {
74
+ if (ctx.flags.json) {
75
+ ctx.out.jsonOut(content.some((c) => c.type !== "text") ? { ...data, _content: content.filter((c) => c.type !== "text") } : data);
76
+ return;
77
+ }
78
+ renderResult(ctx.out, data);
79
+ describeContent(ctx.out, content.filter((c) => c.type !== "text"));
80
+ }
81
+ export function renderToolHelp(ctx, tool) {
82
+ const out = ctx.out;
83
+ const flags = schemaFlags(tool.inputSchema);
84
+ const required = flags.filter((f) => f.required);
85
+ out.log(`${out.bold("scribeclock " + tool.name)} ${required.map((f) => `--${kebab(f.name)} <${f.types[0] ?? "value"}>`).join(" ")}${flags.length > required.length ? " [options]" : ""}`);
86
+ if (tool.title)
87
+ out.log(out.dim(tool.title));
88
+ out.log("");
89
+ out.log(wrap(tool.description, 100));
90
+ const a = tool.annotations;
91
+ const policy = sensitivity(tool.name, tool);
92
+ const tags = [a?.readOnlyHint ? "read-only" : "writes", a?.destructiveHint ? "destructive" : "", policy.confirm ? "asks for confirmation (use --yes in scripts)" : ""].filter(Boolean);
93
+ out.log(out.dim(tags.join(" · ")));
94
+ if (flags.length) {
95
+ out.log("");
96
+ out.log(out.bold("Arguments"));
97
+ const w = Math.max(...flags.map((f) => `--${kebab(f.name)}`.length));
98
+ for (const f of flags) {
99
+ const type = f.enum ? f.enum.join("|") : f.types.join("|") + (f.nullable ? "|null" : "");
100
+ const extras = [f.required ? "required" : "", f.default !== undefined ? `default ${JSON.stringify(f.default)}` : ""].filter(Boolean).join(", ");
101
+ out.log(` ${out.cyan(`--${kebab(f.name)}`.padEnd(w))} ${out.dim(`<${type}>`)}${extras ? out.dim(` (${extras})`) : ""}`);
102
+ if (f.description)
103
+ out.log(` ${" ".repeat(w)} ${wrap(f.description, 90, " ".repeat(w + 4))}`);
104
+ }
105
+ }
106
+ else {
107
+ out.log("");
108
+ out.log(out.dim("This tool takes no arguments."));
109
+ }
110
+ out.log("");
111
+ out.log(out.bold("Examples"));
112
+ const ex = required.map((f) => `--${kebab(f.name)} ${f.enum ? String(f.enum[0]) : exampleFor(f.name)}`).join(" ");
113
+ out.log(` ${out.dim("$")} scribeclock ${tool.name}${ex ? " " + ex : ""}`);
114
+ out.log(` ${out.dim("$")} scribeclock ${tool.name} --json${ex ? " " + ex : ""} | jq .`);
115
+ out.log(` ${out.dim("$")} scribeclock call ${tool.name} --args '${JSON.stringify(Object.fromEntries(required.map((f) => [f.name, f.enum ? f.enum[0] : exampleFor(f.name)])))}'`);
116
+ }
117
+ function exampleFor(name) {
118
+ if (name === "task_id")
119
+ return "'#123'";
120
+ if (name === "project_id")
121
+ return "'My project'";
122
+ if (name === "repo")
123
+ return "'owner/repo'";
124
+ if (/_id$/.test(name))
125
+ return "<uuid>";
126
+ if (name === "zone")
127
+ return "example.com";
128
+ if (name === "files")
129
+ return "src/app.ts,src/lib";
130
+ return `<${name}>`;
131
+ }
132
+ export function wrap(text, width, indent = "") {
133
+ const words = text.replace(/\s+/g, " ").trim().split(" ");
134
+ const lines = [];
135
+ let line = "";
136
+ for (const w of words) {
137
+ if ((line + " " + w).trim().length > width && line) {
138
+ lines.push(line);
139
+ line = w;
140
+ }
141
+ else
142
+ line = (line ? line + " " : "") + w;
143
+ }
144
+ if (line)
145
+ lines.push(line);
146
+ return lines.join("\n" + indent);
147
+ }
148
+ /** Find a tool by snake_case or kebab-case name. */
149
+ export async function findTool(ctx, word) {
150
+ const tools = await ctx.tools();
151
+ const name = snake(word);
152
+ return tools.find((t) => t.name === name);
153
+ }
154
+ export const toolsCommand = {
155
+ path: "tools",
156
+ group: "Raw tools",
157
+ summary: "List every server tool you can call, with a one-line description.",
158
+ description: "Lists the MCP tools your token can use (the server filters by granted scopes). Each one is also a command: `scribeclock <tool_name> --help`. Without a login, only the public tool names are shown.",
159
+ flags: [
160
+ { name: "filter", alias: "f", type: "string", description: "Only tools whose name or description contains this text." },
161
+ { name: "schemas", type: "boolean", description: "Include the full JSON schema of every tool (with --json)." },
162
+ ],
163
+ examples: ["scribeclock tools", "scribeclock tools --filter vault", "scribeclock tools --json --schemas | jq '.tools[].name'"],
164
+ async run(ctx, inv) {
165
+ const filter = typeof inv.flags.filter === "string" ? inv.flags.filter.toLowerCase() : "";
166
+ let tools = null;
167
+ try {
168
+ tools = await ctx.tools();
169
+ }
170
+ catch (err) {
171
+ if (!(err instanceof CliError && err.code === EXIT.AUTH))
172
+ throw err;
173
+ }
174
+ if (!tools) {
175
+ const names = (await ctx.catalog.namesOnly()).filter((n) => !filter || n.includes(filter));
176
+ if (ctx.flags.json)
177
+ ctx.out.jsonOut({ ok: true, authenticated: false, tools: names.map((name) => ({ name })) });
178
+ else {
179
+ ctx.out.info(ctx.out.dim("Not logged in — showing the public tool names. `scribeclock login` to see descriptions and arguments."));
180
+ for (const n of names)
181
+ ctx.out.log(n);
182
+ }
183
+ return EXIT.OK;
184
+ }
185
+ const shown = tools.filter((t) => !filter || t.name.includes(filter) || t.description.toLowerCase().includes(filter) || (t.title ?? "").toLowerCase().includes(filter));
186
+ if (ctx.flags.json) {
187
+ ctx.out.jsonOut({
188
+ ok: true,
189
+ authenticated: true,
190
+ count: shown.length,
191
+ tools: shown.map((t) => (inv.flags.schemas ? t : { name: t.name, title: t.title, description: t.description, read_only: t.annotations?.readOnlyHint === true, confirms: sensitivity(t.name, t).confirm })),
192
+ });
193
+ return EXIT.OK;
194
+ }
195
+ const rows = shown.map((t) => [t.name, t.annotations?.readOnlyHint ? ctx.out.dim("read") : sensitivity(t.name, t).confirm ? ctx.out.yellow("confirm") : "write", t.title ?? firstSentence(t.description)]);
196
+ ctx.out.table(["tool", "mode", "what it does"], rows, { maxWidth: 70 });
197
+ ctx.out.info(ctx.out.dim(`\n${shown.length} tools. Run any of them: scribeclock <tool> --help`));
198
+ return EXIT.OK;
199
+ },
200
+ };
201
+ export const callCommand = {
202
+ path: "call",
203
+ group: "Raw tools",
204
+ summary: "Call any server tool by name: call <tool> --arg value | --args '{json}'.",
205
+ description: "The generic escape hatch. Arguments are typed from the tool's schema (numbers, booleans, arrays as a,b or JSON, objects as JSON). `--args '{...}'` (or `--json '{...}'`) merges a JSON object; a value of @file reads a file, @- reads stdin.",
206
+ positionals: [{ name: "tool", required: true, description: "Tool name (snake_case or kebab-case)." }, { name: "args", variadic: true, description: "--key value, key=value or --args '{json}'" }],
207
+ allowUnknownFlags: true,
208
+ examples: [
209
+ "scribeclock call get_task --task-id '#42'",
210
+ "scribeclock call list_tasks --status open --assignee me --json | jq '.tasks[].title'",
211
+ "scribeclock call create_task --args '{\"title\":\"Fix login\",\"priority\":\"high\"}'",
212
+ "scribeclock call create_document --title Spec --content @spec.md",
213
+ ],
214
+ async run(ctx, inv) {
215
+ const [word, ...rest] = inv.rest;
216
+ if (!word || word.startsWith("-"))
217
+ throw usageError("call needs a tool name.", "Example: scribeclock call get_viewer");
218
+ const tool = await findTool(ctx, word);
219
+ if (!tool)
220
+ throw unknownTool(ctx, word, await ctx.tools());
221
+ if (ctx.flags.help) {
222
+ renderToolHelp(ctx, tool);
223
+ return EXIT.OK;
224
+ }
225
+ return runTool(ctx, tool, rest);
226
+ },
227
+ };
228
+ export function unknownTool(_ctx, word, tools) {
229
+ const near = closest(snake(word), tools.map((t) => t.name));
230
+ return usageError(`Unknown tool "${word}".`, near ? `Did you mean ${near}? \`scribeclock tools\` lists everything you can call.` : "`scribeclock tools` lists everything you can call.");
231
+ }
232
+ export const refreshCommand = {
233
+ path: "refresh",
234
+ group: "Raw tools",
235
+ summary: "Reload the tool catalog from the server (normally cached for 24 hours).",
236
+ async run(ctx) {
237
+ await ctx.tools(); // sets the cache key
238
+ const tools = await ctx.catalog.load({ force: true });
239
+ if (ctx.flags.json)
240
+ ctx.out.jsonOut({ ok: true, count: tools.length, tools: tools.map((t) => t.name) });
241
+ else
242
+ ctx.out.log(`Catalog refreshed: ${tools.length} tools available to this token.`);
243
+ return EXIT.OK;
244
+ },
245
+ };
246
+ /** Used by main() when the first word is not a built-in command. */
247
+ export async function runGeneratedToolCommand(ctx, word, rest, inv) {
248
+ void inv;
249
+ const tool = await findTool(ctx, word);
250
+ if (!tool)
251
+ return null;
252
+ if (ctx.flags.help) {
253
+ renderToolHelp(ctx, tool);
254
+ return EXIT.OK;
255
+ }
256
+ return runTool(ctx, tool, rest);
257
+ }
@@ -0,0 +1,133 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { usageError } from "../cli/errors.js";
3
+ import { fmtDate } from "../util/format.js";
4
+ import { PROJECT_FLAG, cmd, emit, guardedEmit, list, projectArg, requirePositional, str } from "./_shared.js";
5
+ export const vaultLs = cmd({
6
+ path: "vault ls",
7
+ group: "Vault & env",
8
+ summary: "What is in a project's vault: variable names, targets, sensitivity and credential titles (no values).",
9
+ tool: "vault_list_secrets",
10
+ positionals: [{ name: "project", description: "Project uuid or name (default from .scribeclock.json)." }],
11
+ flags: [
12
+ { name: "kind", alias: "k", type: "string", choices: ["env_var", "credential", "api_key", "note", "other"], description: "Only this kind." },
13
+ { name: "target", alias: "t", type: "string", choices: ["production", "preview", "development"], description: "Only variables for this environment." },
14
+ { name: "redact", type: "boolean", description: "Omit usernames and URLs." },
15
+ ],
16
+ async run(ctx, inv) {
17
+ const project_id = projectArg(ctx, inv, { required: true, positional: 0 });
18
+ const data = await ctx.call("vault_list_secrets", { project_id, kind: str(inv.flags.kind), target: str(inv.flags.target), redact_metadata: Boolean(inv.flags.redact) });
19
+ return emit(ctx, data, () => {
20
+ const secrets = (data.secrets ?? []);
21
+ ctx.out.log(ctx.out.bold(`${String(data.project_name ?? project_id)} vault — ${secrets.length} item${secrets.length === 1 ? "" : "s"}`));
22
+ ctx.out.table(["name", "kind", "targets", "sensitivity", "username", "url", "updated"], secrets.map((s) => [s.env_key ?? s.title, s.kind, s.env_targets?.join(",") ?? "", s.env_sensitivity ?? (s.has_password ? "password" : ""), s.username, s.url, fmtDate(String(s.updated_at ?? ""))]));
23
+ });
24
+ },
25
+ });
26
+ export const vaultGet = cmd({
27
+ path: "vault get",
28
+ group: "Vault & env",
29
+ summary: "Environment variables as .env text — values only for plain-text config; secrets stay write-only (asks; audited).",
30
+ description: "Prints the project's variables. Entries stored as secrets never come back through MCP (you get their names); entries stored as plain text config come back with values, masked unless --reveal. Every read is written to the project's vault audit log.",
31
+ tool: "vault_get_env",
32
+ positionals: [{ name: "project", description: "Project uuid or name (default from .scribeclock.json)." }],
33
+ flags: [
34
+ { name: "target", alias: "t", type: "string", choices: ["production", "preview", "development"], description: "Only this environment." },
35
+ { name: "keys", alias: "k", type: "array", description: "Only these variable names." },
36
+ { name: "env", type: "boolean", description: "Print .env text only (for redirection; implies --reveal)." },
37
+ ],
38
+ examples: ["scribeclock vault get 'Scribe Clock'", "scribeclock vault get -t development --keys API_URL,FEATURE_FLAGS --reveal", "scribeclock vault get --env --yes > .env # see also: scribeclock env pull"],
39
+ async run(ctx, inv) {
40
+ const project_id = projectArg(ctx, inv, { required: true, positional: 0 });
41
+ if (inv.flags.env)
42
+ ctx.flags.reveal = true;
43
+ return guardedEmit(ctx, "vault_get_env", { project_id, target: str(inv.flags.target), keys: list(inv.flags.keys) }, (data) => {
44
+ if (inv.flags.env) {
45
+ ctx.out.log(String(data.env ?? data.dotenv ?? ""));
46
+ return;
47
+ }
48
+ const map = (data.map ?? data.values ?? {});
49
+ const secretKeys = (data.secret_keys ?? []);
50
+ ctx.out.log(ctx.out.bold(`${String(data.project_name ?? project_id)}${inv.flags.target ? ` · ${String(inv.flags.target)}` : ""}`));
51
+ for (const [k, v] of Object.entries(map))
52
+ ctx.out.log(` ${k}=${v}`);
53
+ for (const k of secretKeys)
54
+ ctx.out.log(` ${k}=${ctx.out.dim("<secret: write-only via MCP>")}`);
55
+ if (!ctx.flags.reveal && Object.keys(map).length)
56
+ ctx.out.info(ctx.out.dim("Plain-text values are masked; add --reveal to print them."));
57
+ });
58
+ },
59
+ });
60
+ export const vaultSet = cmd({
61
+ path: "vault set",
62
+ group: "Vault & env",
63
+ summary: "Store or rotate variables (write-only): vault set KEY=value [KEY2=value2] | --file .env.",
64
+ tool: "vault_put_env",
65
+ positionals: [{ name: "KEY=value", variadic: true, description: "Variables to store (values are never echoed)." }],
66
+ flags: [
67
+ PROJECT_FLAG,
68
+ { name: "file", alias: "f", type: "string", placeholder: "<.env>", description: "Read KEY=value lines from a dotenv file (- for stdin)." },
69
+ { name: "target", alias: "t", type: "array", placeholder: "<production,preview,development>", description: "Environments (default all three)." },
70
+ { name: "text", type: "boolean", description: "Store as plain-text config (readable later) instead of secret." },
71
+ { name: "skip-existing", type: "boolean", description: "Leave existing keys untouched instead of updating them." },
72
+ ],
73
+ examples: ["scribeclock vault set --project 'Kisa Print' STRIPE_SECRET_KEY=sk_live_… -t production", "scribeclock vault set -p 'Kisa Print' --file .env.production -t production", "scribeclock vault set -p X PUBLIC_API_URL=https://api.example.com --text"],
74
+ async run(ctx, inv) {
75
+ const project_id = projectArg(ctx, inv, { required: true });
76
+ const pairs = inv.positionals.filter((p) => /^[A-Za-z_][A-Za-z0-9_]*=/.test(p));
77
+ const bad = inv.positionals.filter((p) => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(p));
78
+ if (bad.length)
79
+ throw usageError(`Not KEY=value: ${bad.join(", ")}`);
80
+ const file = str(inv.flags.file);
81
+ const dotenv = file ? (file === "-" ? readFileSync(0, "utf8") : readFileSync(file, "utf8")) : undefined;
82
+ if (!pairs.length && !dotenv)
83
+ throw usageError("Nothing to store.", "Pass KEY=value pairs or --file .env");
84
+ const entries = pairs.map((p) => {
85
+ const i = p.indexOf("=");
86
+ return { key: p.slice(0, i), value: p.slice(i + 1).replace(/^(["'])(.*)\1$/, "$2") };
87
+ });
88
+ return guardedEmit(ctx, "vault_put_env", { project_id, entries: entries.length ? entries : undefined, dotenv, targets: list(inv.flags.target), sensitivity: inv.flags.text ? "text" : "secret", duplicate_mode: inv.flags["skip-existing"] ? "skip" : "update" }, (data) => {
89
+ const n = Number(data.stored ?? data.count ?? (Array.isArray(data.keys) ? data.keys.length : entries.length));
90
+ ctx.out.log(`${ctx.out.green("✓")} Stored ${n} variable${n === 1 ? "" : "s"} in ${String(data.project_name ?? project_id)}${inv.flags.text ? " as plain-text config" : " (secret, write-only)"}.`);
91
+ const keys = (data.keys ?? data.updated ?? data.created);
92
+ if (Array.isArray(keys) && keys.length)
93
+ ctx.out.info(ctx.out.dim(keys.map(String).join(", ")));
94
+ });
95
+ },
96
+ });
97
+ export const vaultCredential = cmd({
98
+ path: "vault credential",
99
+ group: "Vault & env",
100
+ summary: "Store a login, API key or note: vault credential \"Title\" --username u --password - (reads stdin).",
101
+ tool: "vault_put_credential",
102
+ positionals: [{ name: "title", required: true, description: "Credential title." }],
103
+ flags: [
104
+ PROJECT_FLAG,
105
+ { name: "kind", type: "string", choices: ["credential", "api_key", "note", "other"], description: "Kind (default credential)." },
106
+ { name: "username", alias: "u", type: "string", description: "Username / login." },
107
+ { name: "password", type: "string", placeholder: "<value|->", description: "Secret value (- reads it from stdin so it stays out of shell history)." },
108
+ { name: "url", type: "string", description: "Where it is used." },
109
+ { name: "notes", type: "string", description: "Notes." },
110
+ ],
111
+ async run(ctx, inv) {
112
+ const project_id = projectArg(ctx, inv, { required: true });
113
+ const title = requirePositional(inv, 0, "A title", 'scribeclock vault credential "Stripe dashboard" -u ops@x --password -');
114
+ let password = str(inv.flags.password);
115
+ if (password === "-")
116
+ password = readFileSync(0, "utf8").trim();
117
+ return guardedEmit(ctx, "vault_put_credential", { project_id, title, kind: str(inv.flags.kind), username: str(inv.flags.username), password, url: str(inv.flags.url), notes: str(inv.flags.notes) }, () => ctx.out.log(`${ctx.out.green("✓")} Stored credential "${title}".`));
118
+ },
119
+ });
120
+ export const vaultRm = cmd({
121
+ path: "vault rm",
122
+ group: "Vault & env",
123
+ summary: "Securely wipe a secret: vault rm KEY --project P | vault rm <secret-id>.",
124
+ tool: "vault_delete_secret",
125
+ positionals: [{ name: "key-or-id", required: true, description: "Environment variable name (with --project) or secret uuid." }],
126
+ flags: [PROJECT_FLAG],
127
+ async run(ctx, inv) {
128
+ const target = requirePositional(inv, 0, "A key or secret id", "scribeclock vault rm OLD_TOKEN --project X");
129
+ const args = /^[0-9a-f-]{36}$/i.test(target) ? { secret_id: target } : { project_id: projectArg(ctx, inv, { required: true }), env_key: target };
130
+ return guardedEmit(ctx, "vault_delete_secret", args, () => ctx.out.log(`${ctx.out.green("✓")} Wiped ${target}.`));
131
+ },
132
+ });
133
+ export const VAULT_COMMANDS = [vaultLs, vaultGet, vaultSet, vaultCredential, vaultRm];